feat(apple): VoiceCatCore Swift package + XCFramework build for macOS/iOS clients
Lays the groundwork for the macOS (AppKit) and iOS (SwiftUI) clients with a shared Swift core wrapping the C ABI, mirroring the proven Windows VoiceCat.Interop layer. Architecture decision: macOS UI = AppKit (not SwiftUI) for the most mature VoiceOver accessibility story — same rationale as the Windows client's WinForms-over-WinUI-3 decision. iOS stays SwiftUI. Recorded in docs/roadmap.md §2. Build infrastructure (Phase 0): - clients/apple/scripts/build-xcframework.sh: runs cmake --preset apple-dev, merges libvoicecat.a + 107 vcpkg static deps into a single ~30 MB fat static library (libvoicecat-fat.a) via libtool -static (SPM binary targets link one .a per slice), stages voicecat.h + a generated module.modulemap (module VoiceCatC) into the headers, runs xcodebuild -create-xcframework -> clients/apple/VoiceCatCore.xcframework. VoiceCatCore Swift Package (Phase 1): - Package.swift: binary target (VoiceCatCoreXCF) + library (VoiceCatCore) + test target. - Sources/VoiceCatCore/: 7 files mirroring the C# VoiceCat.Interop patterns adapted to Swift native C interop — Enums (9 Swift mirrors of C enums, UInt32-backed), Config, Event (copies ev.text to String inside the callback — the #1 lifetime rule), Models (10 Swift value types), Marshaling (C arrays -> Swift + immediate vc_free_*), Callbacks (@convention(c) + Unmanaged.passUnretained, the Swift analog of C#'s [UnmanagedCallersOnly] + GCHandle), VoiceCatClient (owns vc_client* as OpaquePointer, all 38 C ABI functions, deinit -> vc_client_destroy then frees config CStrings, event delivery on main queue via coalesced DispatchQueue.main drain). Tests — 6/6 green (swift test against a real voicecat-server): - testConnectTofuAuthListChannelsRoundTrips, testAdminChannelCrudAccountCrudRoundTrips, testScreenAudioStreamStartsAndStops, testPerStreamRecvControlsRoundTrip, plus two static smoke tests. Catches Swift-specific interop bugs (@convention(c) callback lifetime, Unmanaged pointer resolution, CString memory management, enum raw-value bridging, struct layout) that C++ ctest cannot. C++ suite still 21/21 green. Docs updated (house rule): tech-stack.md §2, architecture.md §4, roadmap.md M4 + §2, clients/apple/README.md (full rewrite), PROGRESS.md, .gitignore.
This commit is contained in:
@@ -1,69 +1,130 @@
|
||||
# Apple client (macOS + iOS) — placeholder
|
||||
# Apple client (macOS + iOS)
|
||||
|
||||
Built in **M4** (see [`docs/roadmap.md`](../../docs/roadmap.md)). Swift + SwiftUI, consuming
|
||||
`libvoicecat` through the C ABI ([`core/include/voicecat.h`](../../core/include/voicecat.h)).
|
||||
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.
|
||||
|
||||
Planned shape (see [`docs/architecture.md`](../../docs/architecture.md) §4 and
|
||||
[`docs/tech-stack.md`](../../docs/tech-stack.md) §2):
|
||||
## What's here now
|
||||
|
||||
- A Swift Package wrapping the core as an **XCFramework** (macOS + iOS device + simulator).
|
||||
- A module map exposing `voicecat.h` to Swift (Swift can also use C++ interop directly, but
|
||||
the C ABI is the stable contract).
|
||||
- SwiftUI app target for macOS and iOS.
|
||||
- **iOS audio:** app owns `AVAudioSession` (`.playAndRecord` / `.voiceChat`), mic permission,
|
||||
interruption/route handling, calling `vc_audio_*` hooks on the core.
|
||||
- **iOS screen/system audio (`SCREEN_AUDIO`):** a **ReplayKit Broadcast Upload Extension**
|
||||
capturing `RPSampleBufferType.audioApp`, linking a minimal core slice, sharing session
|
||||
state via an **App Group** ([`docs/voice.md`](../../docs/voice.md) §9).
|
||||
### `VoiceCatCore` Swift Package — ✓ complete (2026-06-18)
|
||||
|
||||
Nothing here yet — the core must reach M2 (working voice) before the GUI is worth building.
|
||||
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.
|
||||
|
||||
## Building the core for Apple platforms
|
||||
```
|
||||
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)
|
||||
```
|
||||
|
||||
The CMake presets `apple-dev`, `apple-ios`, and `apple-ios-sim` produce static `libvoicecat.a`
|
||||
slices for the Swift Package / XCFramework.
|
||||
**Key patterns** (carried over from the proven C# `VoiceCat.Interop` — see
|
||||
[`docs/architecture.md`](../../docs/architecture.md) §4 per-platform binding notes):
|
||||
|
||||
**`apple-dev` (macOS slice) is validated** — builds green on macOS 26.5 / Apple Silicon
|
||||
(Apple clang 21, vcpkg `arm64-osx` triplet) and produces a valid 1.9 MB arm64 static library
|
||||
with 167 exported C ABI symbols (correct visibility). The XCFramework creation path is also
|
||||
verified — `xcodebuild -create-xcframework` produces a valid `VoiceCatCore.xcframework`
|
||||
with the `.a` + `voicecat.h` headers, ready for a Swift Package binary target.
|
||||
- **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.
|
||||
|
||||
**`apple-ios` and `apple-ios-sim` (iOS slices) are scaffolding** — not yet CI-validated.
|
||||
The vcpkg `arm64-ios` / `arm64-ios-sim` triplets for all 8 deps need verification.
|
||||
### Tests — 6/6 green
|
||||
|
||||
Prerequisites: `VCPKG_ROOT` set, Xcode + iOS SDK installed, and Homebrew `autoconf-archive`
|
||||
(needed by vcpkg's libsodium port — `brew install autoconf-archive`).
|
||||
```
|
||||
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 + iOS SDK installed
|
||||
# Prerequisites: VCPKG_ROOT set, Xcode installed
|
||||
export VCPKG_ROOT=/path/to/vcpkg
|
||||
|
||||
# macOS slice (arm64-osx on Apple Silicon, x64-osx on Intel)
|
||||
cmake --preset apple-dev && cmake --build --preset apple-dev
|
||||
# → build/apple-dev/lib/libvoicecat.a
|
||||
# Build the macOS slice + fat static lib + XCFramework (validated)
|
||||
scripts/build-xcframework.sh
|
||||
# → clients/apple/VoiceCatCore.xcframework/ (macOS-arm64 slice)
|
||||
|
||||
# iOS device slice
|
||||
cmake --preset apple-ios && cmake --build --preset apple-ios
|
||||
# → build/apple-ios/lib/libvoicecat.a
|
||||
|
||||
# iOS simulator slice
|
||||
cmake --preset apple-ios-sim && cmake --build --preset apple-ios-sim
|
||||
# → build/apple-ios-sim/lib/libvoicecat.a
|
||||
# Build all 3 slices (macOS + iOS device + iOS sim) — iOS still scaffolding
|
||||
scripts/build-xcframework.sh --all
|
||||
```
|
||||
|
||||
The three `.a` files are then stitched into an XCFramework:
|
||||
### 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/`.
|
||||
A Swift Package binary target can only link ONE `.a` per XCFramework slice, so
|
||||
`build-xcframework.sh` merges them all into a single self-contained `libvoicecat-fat.a`
|
||||
(~30 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)).
|
||||
|
||||
### Swift Package
|
||||
|
||||
```bash
|
||||
xcodebuild -create-xcframework \
|
||||
-library build/apple-dev/lib/libvoicecat.a -headers core/include \
|
||||
-library build/apple-ios/lib/libvoicecat.a -headers core/include \
|
||||
-library build/apple-ios-sim/lib/libvoicecat.a -headers core/include \
|
||||
-output build/VoiceCatCore.xcframework
|
||||
swift build # builds VoiceCatCore library
|
||||
swift test # runs 6 smoke tests against a real voicecat-server
|
||||
```
|
||||
|
||||
The XCFramework is then consumed by the Swift Package as a binary target. The macOS-only
|
||||
XCFramework (single `apple-dev` slice) is verified to build now; the full 3-slice XCFramework
|
||||
(macOS + iOS device + iOS simulator) waits for the iOS presets to be validated. Actual
|
||||
`AVAudioSession` integration, `Info.plist` mic permission, ReplayKit extension, and SwiftUI
|
||||
UI work are tracked as follow-up tasks.
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user