# 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.
