Both VoiceCatMac and VoiceCatiOS failed to link with 'Undefined symbols for architecture arm64: _rnnoise_create/_rnnoise_destroy/_rnnoise_process_frame'. Root cause: build-xcframework.sh merged vcpkg deps into the fat static lib but NOT the locally-built vendored librnnoise.a (a CMake target from third_party/rnnoise/ linked privately into voicecat via VOICECAT_HAS_NS — not a vcpkg dep). The xcframework had been rebuilt after the RNNoise commit but still omitted the symbols, so every slice's libvoicecat-fat.a referenced _rnnoise_* with no defining object. The iOS slices were also stale (pre-rnnoise) and absent from the xcframework entirely. Fix: build-xcframework.sh now also collects .a files from build/<preset>/lib/ (excluding libvoicecat*) so vendored CMake-target static libs like librnnoise.a get merged in. Future-proof: any new vendored static-lib target landing in build/<preset>/lib/ is picked up automatically. README 'Fat static library' section updated. Verify: rebuilt VoiceCatCore.xcframework --all → all 3 slices (macos-arm64, ios-arm64, ios-arm64-simulator) now carry the 10 _rnnoise_* symbols; fat lib ~30 MB → ~33 MB. xcodebuild Debug BUILD SUCCEEDED for VoiceCatMac, VoiceCatiOS (iphonesimulator arm64), and VoiceCatiOS (iphoneos arm64). No core/ABI/proto changes — xcframework artifact + build script only.
161 lines
9.2 KiB
Markdown
161 lines
9.2 KiB
Markdown
# 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<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`](../../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/<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`](../../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
|
|
|
|
```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 <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.
|