# Apple client (macOS + iOS) Built in **M4** (see [`docs/roadmap.md`](../../docs/roadmap.md)). One shared **Swift core** (`VoiceCatCore` package) wrapping the C ABI ([`core/include/voicecat.h`](../../core/include/voicecat.h)), with platform-specific UIs: **AppKit** for macOS (best VoiceOver accessibility), **SwiftUI** for iOS. See [`docs/architecture.md`](../../docs/architecture.md) §4 and [`docs/tech-stack.md`](../../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/`](../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`](../../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` + 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`). - **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`](../../docs/voice.md) §9). - **macOS `SCREEN_AUDIO`** via ScreenCaptureKit (currently stub returns `false`). - **iOS XCFramework slices** — `apple-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`: ```bash # 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//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`](../../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//lib/` and isn't named `libvoicecat*`. ### Swift Package ```bash 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`](../../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. ```bash # 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 --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`](../../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.