Changes main app bundle ID from cat.voice.VoiceCatiOS, broadcast extension from cat.voice.VoiceCatiOS.broadcast, and App Group from group.cat.voice.VoiceCat to match the registered App Store identifier.
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 bybuild-xcframework.sh. @convention(c)callbacks: plain C function pointers (not ARC-managed closures) +Unmanaged.passUnretained(self)as theusercontext — the Swift analog of C#'s[UnmanagedCallersOnly]+GCHandle.deinitcallsvc_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 indeinitaftervc_client_destroy. - Event delivery: events buffered in a lock-protected array + coalesced
DispatchQueue.maindrain (one async block at a time) — the Swift analog of C#'sChannel<VoiceCatEvent>+ 30ms WinForms Timer pump.ev.textis copied toStringinside 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 WindowsVoiceCat.Appfeature set. - iOS SwiftUI app — AVAudioSession, mic permission, foreground voice.
vc_audio_suspend/vc_audio_resumeABI hooks — deferred until the iOS client milestone (keep ABI stable).- ReplayKit Broadcast Upload Extension for iOS
SCREEN_AUDIO(docs/voice.md§9). - macOS
SCREEN_AUDIOvia ScreenCaptureKit (currently stub returnsfalse). - iOS XCFramework slices —
apple-ios/apple-ios-simpresets are scaffolding; runscripts/build-xcframework.sh --allonce 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 localVoiceCatCore.xcframework. - A library target (
VoiceCatCore) that depends on the binary target and provides the Swift wrapper. - A test target (
VoiceCatCoreTests) withlinkerSettings: [.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.