Add scripts/dist-ios-adhoc.sh and scripts/asc_api.py to build an ad-hoc signed IPA and the OTA web-install files (manifest.plist + index.html) for sharing the iOS client with registered devices before TestFlight. - asc_api.py: App Store Connect API helper (ES256 JWT via cryptography, urllib) to register device UDIDs and list registered devices. - dist-ios-adhoc.sh: registers UDIDs, builds the iOS device xcframework slice, archives + exports with method=release-testing using -allowProvisioningUpdates, and stages the install files into dist/ios-adhoc/. - Document the workflow in clients/apple/README.md; ignore __pycache__.
157 lines
8.8 KiB
Markdown
157 lines
8.8 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/`.
|
|
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
|
|
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.
|