Files
voice-cat/clients/apple
Talon bda37ec27b fix(ios): add AppIcon asset catalog and fix launch screen for TestFlight
Adds Assets.xcassets with a placeholder 1024x1024 AppIcon so Xcode
compiles CFBundleIconName and the required icon sizes into the bundle.
Replaces UILaunchStoryboardName (missing storyboard) with UILaunchScreen
dict, valid for iOS 14+ (min target is iOS 18). Fixes all four App Store
Connect validation errors blocking TestFlight upload.
2026-06-30 13:22:14 +01:00
..

Apple client (macOS + iOS)

Built in M4 (see docs/roadmap.md). One shared Swift core (VoiceCatCore package) wrapping the C ABI (core/include/voicecat.h), with platform-specific UIs: AppKit for macOS (best VoiceOver accessibility), SwiftUI for iOS. See docs/architecture.md §4 and docs/tech-stack.md §2.

What's here now

VoiceCatCore Swift Package — ✓ complete (2026-06-18)

The shared Swift core that both the macOS AppKit app and the iOS SwiftUI app will consume. Mirrors the Windows client's VoiceCat.Interop layer (clients/windows/) using Swift-native C interop instead of P/Invoke.

clients/apple/
├── Package.swift                          # SPM: binary target (XCFramework) + VoiceCatCore library + tests
├── VoiceCatCore.xcframework/              # BUILT ARTIFACT — produced by scripts/build-xcframework.sh (gitignored)
├── scripts/
│   └── build-xcframework.sh               # builds libvoicecat + vcpkg deps → fat .a → XCFramework + module map
├── Sources/VoiceCatCore/
│   ├── Enums.swift                        # Swift-idiomatic mirrors of the 9 voicecat.h C enums
│   ├── Config.swift                       # VoiceCatConfig (wraps vc_config)
│   ├── Event.swift                        # VoiceCatEvent — copies ev.text inside the callback (the #1 lifetime rule)
│   ├── Models.swift                       # Channel, User, Stream, Device, Permissions, Account, AudioConfig, …
│   ├── Marshaling.swift                   # C arrays → Swift arrays + immediate vc_free_* (callers never manage native lifetime)
│   ├── Callbacks.swift                    # @convention(c) on_event/on_level + Unmanaged.passUnretained context bridging
│   └── VoiceCatClient.swift               # the public Swift surface — owns vc_client*, all 38 C functions, event delivery on @MainActor
└── Tests/VoiceCatCoreTests/
    └── VoiceCatClientSmokeTests.swift     # 6 XCTest smoke tests against a real voicecat-server (6/6 green)

Key patterns (carried over from the proven C# VoiceCat.Interop — see docs/architecture.md §4 per-platform binding notes):

  • C interop via module map: import VoiceCatC — Swift sees all C enums/structs/functions directly. No manual struct/function redeclaration (unlike C# P/Invoke). The module map (module VoiceCatC { header "voicecat.h" }) is staged into the XCFramework headers by build-xcframework.sh.
  • @convention(c) callbacks: plain C function pointers (not ARC-managed closures) + Unmanaged.passUnretained(self) as the user context — the Swift analog of C#'s [UnmanagedCallersOnly] + GCHandle. deinit calls vc_client_destroy (joins all threads) before the object's memory is freed, so no callback can fire with a dangling pointer.
  • Config string lifetimes: the core stores raw pointers from vc_config (doesn't copy). Native CString storage (strdup) is held for the client's entire lifetime, freed in deinit after vc_client_destroy.
  • Event delivery: events buffered in a lock-protected array + coalesced DispatchQueue.main drain (one async block at a time) — the Swift analog of C#'s Channel<VoiceCatEvent> + 30ms WinForms Timer pump. ev.text is copied to String inside the callback before enqueueing (dangling-pointer rule).
  • Level meters: coalesced to latest-per-stream-id (intermediate values are visually irrelevant, same as C#'s ConcurrentDictionary<uint,float>).
  • Immediate vc_free_* on list reads — callers never manage native list lifetime.

Tests — 6/6 green

swift test
# ✓ testVersionStringIsNonEmpty
# ✓ testResultStringRoundTrips
# ✓ testConnectTofuAuthListChannelsRoundTrips    (connect → TOFU → confirm → guest auth → channels → permissions → guest ListAccounts rejected)
# ✓ testAdminChannelCrudAccountCrudRoundTrips    (admin auth → channel create/edit/delete → account create/list/reset/delete)
# ✓ testScreenAudioStreamStartsAndStops          (screen-audio stream start/stop through Swift interop)
# ✓ testPerStreamRecvControlsRoundTrip           (two clients, per-stream gain/mute/NR round-trip)

Prerequisites for tests: cmake --preset dev && cmake --build --preset dev (builds voicecat-server + voicecat-admin into build/dev/bin/).

What's NOT here yet (next steps)

  • macOS AppKit app (clients/apple/macOS/) — the M4 UI: connect dialog, saved-server list (Keychain for passwords), TOFU identity dialog, main window (NSOutlineView channel tree, NSTableView user list, NSTextView chat, activity log), voice controls, per-user tuning, full VoiceOver accessibility. Mirrors the Windows VoiceCat.App feature set.
  • iOS SwiftUI app — AVAudioSession, mic permission, foreground voice.
  • vc_audio_suspend/vc_audio_resume ABI hooks — deferred until the iOS client milestone (keep ABI stable).
  • ReplayKit Broadcast Upload Extension for iOS SCREEN_AUDIO (docs/voice.md §9).
  • macOS SCREEN_AUDIO via ScreenCaptureKit (currently stub returns false).
  • iOS XCFramework slicesapple-ios / apple-ios-sim presets are scaffolding; run scripts/build-xcframework.sh --all once the iOS vcpkg triplets are validated.

Building the XCFramework

The XCFramework is a local build artifact (gitignored, like the Windows client's build/windows-client/bin/voicecat.dll). Run the build script before swift build / swift test:

# Prerequisites: VCPKG_ROOT set, Xcode installed
export VCPKG_ROOT=/path/to/vcpkg

# Build the macOS slice + fat static lib + XCFramework (validated)
scripts/build-xcframework.sh
# → clients/apple/VoiceCatCore.xcframework/ (macOS-arm64 slice)

# Build all 3 slices (macOS + iOS device + iOS sim) — iOS still scaffolding
scripts/build-xcframework.sh --all

Fat static library

The apple-dev CMake preset produces a 1.9 MB libvoicecat.a containing only voicecat's own object files — vcpkg's static dependencies (protobuf, mbedtls, libsodium, opus, sqlite3, spdlog, asio, abseil, …) are 107 separate .a files under vcpkg_installed/arm64-osx/lib/, and the vendored RNNoise noise-suppression lib (third_party/rnnoise/, built as a CMake target → build/<preset>/lib/librnnoise.a) is another. A Swift Package binary target can only link ONE .a per XCFramework slice, so build-xcframework.sh merges them all — vcpkg deps plus the locally-built vendored libs — into a single self-contained libvoicecat-fat.a (~33 MB) using libtool -static. This is the Apple equivalent of how the Windows client ships a single voicecat.dll with all deps statically linked (via MinGW's -static flags in core/CMakeLists.txt). If you add another vendored (non-vcpkg) static-lib target to the core, it's picked up automatically as long as it lands in build/<preset>/lib/ and isn't named libvoicecat*.

Swift Package

swift build          # builds VoiceCatCore library
swift test           # runs 6 smoke tests against a real voicecat-server

The Package.swift declares:

  • A binary target (VoiceCatCoreXCF) pointing at the local VoiceCatCore.xcframework.
  • A library target (VoiceCatCore) that depends on the binary target and provides the Swift wrapper.
  • A test target (VoiceCatCoreTests) with linkerSettings: [.linkedLibrary("c++")] — the fat static lib is C++20, so the final executable must link libc++ (the LLVM C++ standard library on macOS). vcpkg's static deps are already in the .a; macOS system frameworks (CoreAudio/CoreFoundation) are auto-discovered by the linker.

Ad-hoc distribution (iOS, pre-TestFlight)

To hand the iOS app to a handful of friends before TestFlight, use scripts/dist-ios-adhoc.sh. It registers each device's UDID, builds an ad-hoc-signed VoiceCatiOS.ipa, and generates the manifest.plist + index.html for an over-the-air (itms-services://) web install. Ad-hoc builds only run on devices whose UDID is registered before signing, and stock iOS won't install a bare .ipa without a sideloading tool — so the web-install page is the friend-friendly path.

# One-time: create an App Store Connect API "Team Key" (.p8, Admin/App Manager access) at
#   App Store Connect → Users and Access → Integrations → App Store Connect API
export ASC_KEY_ID=ABC123 ASC_ISSUER_ID=1111-... ASC_KEY_PATH=~/.appstoreconnect/AuthKey_ABC123.p8

# Register a device + build + stage everything into dist/ios-adhoc/
scripts/dist-ios-adhoc.sh --udid <UDID> --name "Friend iPhone" \
    --base-url https://example.com/voicecat

Then upload the three staged files (VoiceCatiOS.ipa, manifest.plist, index.html) to that HTTPS folder and open index.html in Safari on a registered iPhone (iOS 18+). Device UDID registration is automated via scripts/asc_api.py (asc_api.py list shows the registered devices against the 100-iOS-devices/year cap). Requires a paid Apple Developer Program membership. Run scripts/dist-ios-adhoc.sh --help for all flags.