From b4766d2f244026a265d389255d0f3e1df285e124 Mon Sep 17 00:00:00 2001 From: Talon Date: Thu, 18 Jun 2026 14:20:38 +0200 Subject: [PATCH] feat(apple): VoiceCatCore Swift package + XCFramework build for macOS/iOS clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 4 + PROGRESS.md | 57 ++ clients/apple/Package.swift | 59 ++ clients/apple/README.md | 159 ++++-- .../Sources/VoiceCatCore/Callbacks.swift | 51 ++ .../apple/Sources/VoiceCatCore/Config.swift | 30 ++ .../apple/Sources/VoiceCatCore/Enums.swift | 155 ++++++ .../apple/Sources/VoiceCatCore/Event.swift | 61 +++ .../Sources/VoiceCatCore/Marshaling.swift | 107 ++++ .../apple/Sources/VoiceCatCore/Models.swift | 190 +++++++ .../Sources/VoiceCatCore/VoiceCatClient.swift | 503 +++++++++++++++++ .../VoiceCatClientSmokeTests.swift | 505 ++++++++++++++++++ clients/apple/scripts/build-xcframework.sh | 155 ++++++ docs/architecture.md | 6 +- docs/roadmap.md | 28 +- docs/tech-stack.md | 10 +- 16 files changed, 2019 insertions(+), 61 deletions(-) create mode 100644 clients/apple/Package.swift create mode 100644 clients/apple/Sources/VoiceCatCore/Callbacks.swift create mode 100644 clients/apple/Sources/VoiceCatCore/Config.swift create mode 100644 clients/apple/Sources/VoiceCatCore/Enums.swift create mode 100644 clients/apple/Sources/VoiceCatCore/Event.swift create mode 100644 clients/apple/Sources/VoiceCatCore/Marshaling.swift create mode 100644 clients/apple/Sources/VoiceCatCore/Models.swift create mode 100644 clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift create mode 100644 clients/apple/Tests/VoiceCatCoreTests/VoiceCatClientSmokeTests.swift create mode 100755 clients/apple/scripts/build-xcframework.sh diff --git a/.gitignore b/.gitignore index 8d4116b..11ef11f 100644 --- a/.gitignore +++ b/.gitignore @@ -36,8 +36,12 @@ Thumbs.db # Apple / Windows client build artifacts (added in M4) clients/apple/**/build/ clients/apple/**/*.xcodeproj/xcuserdata/ +clients/apple/**/*.xcframework/ clients/windows/**/bin/ clients/windows/**/obj/ +# SwiftPM build artifacts +clients/apple/.build/ +clients/apple/Package.resolved # Test artifacts: TOFU pin store written by vc_client during headless tests # (core/src/core/client.cpp falls back to this relative path when tofu_store_path is unset). diff --git a/PROGRESS.md b/PROGRESS.md index 412936b..5c33923 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -10,6 +10,63 @@ up instantly. Newest status at the top. ## ▶ Where we left off / next action +- **Done:** **macOS/iOS Swift core — `VoiceCatCore` package + tests** (2026-06-18). The + shared Swift core for the macOS (AppKit) and iOS (SwiftUI) clients is built and verified. + This is the foundation that both platform UIs will build on — mirrors the Windows client's + proven `VoiceCat.Interop` layer using Swift-native C interop. + - **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 and + `docs/tech-stack.md` §2. + - **`VoiceCatCore` Swift Package** (`clients/apple/Package.swift`): binary target + (`VoiceCatCoreXCF` → `VoiceCatCore.xcframework`) + library target (`VoiceCatCore` — the + Swift wrapper) + test target (`VoiceCatCoreTests`). 7 source files: + `Enums.swift` (Swift mirrors of the 9 C enums), `Config.swift`, `Event.swift` + (copies `ev.text` to `String` 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_*`), + `Callbacks.swift` (`@convention(c)` + `Unmanaged.passUnretained` — the Swift analog of + C#'s `[UnmanagedCallersOnly]` + `GCHandle`), `VoiceCatClient.swift` (owns `vc_client*`, + all 38 C functions, `deinit` → `vc_client_destroy` then frees config CStrings, event + delivery on `@MainActor` via coalesced `DispatchQueue.main` drain). + - **`build-xcframework.sh`** (`clients/apple/scripts/`): 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`, stages `voicecat.h` + a generated + `module.modulemap` (`module VoiceCatC { header "voicecat.h" }`) into the XCFramework + headers, runs `xcodebuild -create-xcframework` → `clients/apple/VoiceCatCore.xcframework`. + The fat library is needed because SPM binary targets link ONE `.a` per slice — without + it, the final executable gets undefined-symbol errors for protobuf/mbedtls/sodium/opus/… + (the Apple equivalent of how the Windows client ships a single `voicecat.dll` with all + deps statically linked). + - **Tests — 6/6 green:** `swift test` against a real `voicecat-server` (built by + `cmake --preset dev`). Tests mirror the C# `VoiceCat.Interop.Tests`: + `testVersionStringIsNonEmpty`, `testResultStringRoundTrips`, + `testConnectTofuAuthListChannelsRoundTrips` (connect → TOFU → confirm → guest auth → + channels → permissions → guest ListAccounts rejected), + `testAdminChannelCrudAccountCrudRoundTrips` (admin auth → channel CRUD → account CRUD), + `testScreenAudioStreamStartsAndStops` (screen-audio stream lifecycle through Swift), + `testPerStreamRecvControlsRoundTrip` (two clients, per-stream gain/mute/NR round-trip). + Catches Swift-specific interop bugs (`@convention(c)` callback lifetime, `Unmanaged` + pointer resolution, CString memory management, enum raw-value bridging, struct layout) + that C++ ctest can't. + - **Key C interop discoveries:** (1) Swift imports `vc_client*` (incomplete C struct) as + `OpaquePointer?`, not a named type — `private var handle: OpaquePointer?`. (2) C enums + are imported as `UInt32`-backed (not `Int32`) — all Swift enum mirrors use `UInt32`; + `vc_event.result` is `int32_t` (signed), bridged via `UInt32(bitPattern:)`. (3) Swift + auto-marshals `String` to `const char*` for function params, but NOT for C struct fields + — `vc_stream_desc`/`vc_channel_info` string fields need `strdup` + `defer { free }`. + (4) `selfPointer` must be a computed property (not stored) to break the circular init + dependency (`Unmanaged.passUnretained(self)` needs `self` fully initialized, but stored + properties must be set first). + - **Docs updated:** `docs/tech-stack.md` §2 (AppKit macOS / SwiftUI iOS / shared + `VoiceCatCore` package / fat static lib), `docs/architecture.md` §4 (Swift binding + notes), `docs/roadmap.md` M4 + §2 (AppKit decision recorded), `clients/apple/README.md` + (full rewrite), `PROGRESS.md` (this entry), `.gitignore` (XCFramework + SPM artifacts). + - **Next:** macOS AppKit app (`clients/apple/macOS/`) — connect dialog, saved-server list + (Keychain), TOFU identity dialog, main window (NSOutlineView + NSTableView + NSTextView + + activity log), voice controls, per-user tuning, full VoiceOver accessibility. Mirrors + the Windows `VoiceCat.App` feature set. + - **Done:** **macOS port — `dev` + `apple-dev` presets validated** (2026-06-18). The core, server, tools, and tests now build and run on macOS (Apple Silicon, macOS 26.5, Apple clang 21). This lays the groundwork for the macOS/iOS Swift client. Three real bugs found and diff --git a/clients/apple/Package.swift b/clients/apple/Package.swift new file mode 100644 index 0000000..6402795 --- /dev/null +++ b/clients/apple/Package.swift @@ -0,0 +1,59 @@ +// swift-tools-version: 5.9 +// +// VoiceCatCore — the shared Swift core for the VoiceCat macOS (AppKit) and iOS (SwiftUI) +// clients. It wraps libvoicecat's C ABI (core/include/voicecat.h) as imported through the +// VoiceCatCore.xcframework binary target's module map (`import VoiceCatC`), and exposes a +// Swift-idiomatic, @MainActor-safe surface. +// +// Architecture: docs/architecture.md §4 ("one core, many faces"). The Windows C# client +// (clients/windows/VoiceCat.Interop) is the proven mirror of this same layering — the Swift +// wrapper follows the same patterns (callback-lifetime, string-lifetime, event-delivery +// thread handoff, immediate vc_free_* on list reads) adapted to Swift's interop model. +// +// The XCFramework is a LOCAL BUILD ARTIFACT — run `scripts/build-xcframework.sh` before +// `swift build` / `swift test`. See clients/apple/README.md. +import PackageDescription + +let package = Package( + name: "VoiceCatCore", + // macOS 14 (Sonoma) is the AppKit client's deployment target. iOS is deferred; when the + // iOS app lands, add `.iOS(.v17)` here and the apple-ios / apple-ios-sim slices to the + // XCFramework (scripts/build-xcframework.sh --all). + platforms: [ + .macOS(.v14), + ], + products: [ + .library(name: "VoiceCatCore", targets: ["VoiceCatCore"]), + ], + targets: [ + // Binary target — the prebuilt static lib + headers + module map. Produced by + // scripts/build-xcframework.sh from the `apple-dev` CMake preset. + .binaryTarget( + name: "VoiceCatCoreXCF", + path: "VoiceCatCore.xcframework" + ), + // The Swift wrapper library — what the macOS/iOS apps import as `import VoiceCatCore`. + .target( + name: "VoiceCatCore", + dependencies: ["VoiceCatCoreXCF"], + path: "Sources/VoiceCatCore" + ), + // Smoke tests against a real voicecat-server — mirrors clients/windows/ + // VoiceCat.Interop.Tests/VoiceCatClientSmokeTests.cs. Requires the `dev` CMake preset + // to be built (build/dev/bin/voicecat-server + voicecat-admin). + // + // linkerSettings: libvoicecat.a is a static C++20 library (built by the apple-dev + // preset with vcpkg's clang), so the final executable must link libc++ (the LLVM C++ + // standard library on macOS). vcpkg's static deps (mbedtls/sodium/opus/protobuf/ + // sqlite3/spdlog/asio) are already compiled into the .a; macOS system frameworks + // (CoreAudio/CoreFoundation) are auto-discovered by the linker (PROGRESS.md). + .testTarget( + name: "VoiceCatCoreTests", + dependencies: ["VoiceCatCore"], + path: "Tests/VoiceCatCoreTests", + linkerSettings: [ + .linkedLibrary("c++"), + ] + ), + ] +) diff --git a/clients/apple/README.md b/clients/apple/README.md index b78e797..e1af203 100644 --- a/clients/apple/README.md +++ b/clients/apple/README.md @@ -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` + 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`). +- **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. diff --git a/clients/apple/Sources/VoiceCatCore/Callbacks.swift b/clients/apple/Sources/VoiceCatCore/Callbacks.swift new file mode 100644 index 0000000..9ffef0f --- /dev/null +++ b/clients/apple/Sources/VoiceCatCore/Callbacks.swift @@ -0,0 +1,51 @@ +// Callbacks — the C function pointers passed to `vc_callbacks`. These are the Swift +// equivalent of the C# client's `[UnmanagedCallersOnly]` static methods (NativeCallbacks.cs). +// +// The critical patterns (carried over from the proven C# implementation): +// 1. `@convention(c)` closures — plain C function pointers, NOT GC/ARC-managed closures. +// A @convention(c) closure cannot capture context, which is why the `user` pointer is +// used to resolve back to the VoiceCatClient instance (the C# version uses GCHandle for +// the same thing; Swift uses Unmanaged). +// 2. `Unmanaged.passUnretained(self).toOpaque()` as the `user` context — a stable raw +// pointer to the Swift object WITHOUT incrementing the retain count. This is safe +// because `deinit` calls `vc_client_destroy` (which synchronously joins every internal +// thread) BEFORE the object's memory is freed — so no callback can fire after the object +// is gone. (The C# equivalent: GCHandle.Alloc + GCHandle.Free in Dispose.) +// 3. Copy `ev.text` to a Swift `String` INSIDE `onEvent` (via `VoiceCatEvent.from(_:)`) +// before returning — the raw pointer is dangling after the callback returns. This is +// the #1 lifetime rule from voicecat.h's vc_event doc comment. + +import VoiceCatC +import Foundation + +/// Internal: builds the `vc_callbacks` struct wired to VoiceCatClient's C function pointers. +/// The `user` context is an Unmanaged-passUnretained pointer to the client — resolved back +/// to the client inside `onEvent`/`onLevel` below. +internal enum Callbacks { + /// The `on_event` C function pointer. Non-capturing @convention(c) closure — resolves + /// the VoiceCatClient from `user` and enqueues a safe copy of the event. + static let onEvent: @convention(c) ( + UnsafeMutableRawPointer?, UnsafePointer? + ) -> Void = { user, ev in + guard let user, let ev else { return } + let client = Unmanaged.fromOpaque(user).takeUnretainedValue() + // Copy the event (including text) to a Swift value NOW — the raw vc_event is + // invalid after this callback returns. + client.enqueueEvent(VoiceCatEvent.from(ev.pointee)) + } + + /// The `on_level` C function pointer. Coalesces to "latest sample per stream_id" + /// (intermediate values are visually irrelevant — same as C#'s ConcurrentDictionary). + static let onLevel: @convention(c) ( + UnsafeMutableRawPointer?, UInt32, Float + ) -> Void = { user, streamId, rms in + guard let user else { return } + let client = Unmanaged.fromOpaque(user).takeUnretainedValue() + client.enqueueLevel(streamId, rms) + } + + /// Construct the vc_callbacks struct for a given client. + static func make(user: UnsafeMutableRawPointer) -> vc_callbacks { + vc_callbacks(on_event: onEvent, on_level: onLevel, user: user) + } +} diff --git a/clients/apple/Sources/VoiceCatCore/Config.swift b/clients/apple/Sources/VoiceCatCore/Config.swift new file mode 100644 index 0000000..ffd97d7 --- /dev/null +++ b/clients/apple/Sources/VoiceCatCore/Config.swift @@ -0,0 +1,30 @@ +// VoiceCatConfig — Swift-idiomatic mirror of `vc_config` (voicecat.h). Passed to +// VoiceCatClient.init. The native CString storage for the string fields is held for the +// client's entire lifetime inside VoiceCatClient — see VoiceCatClient.swift's doc comment +// on why (the core stores raw pointers from vc_config by value, it does not copy the data). + +import VoiceCatC + +/// Configuration for a `VoiceCatClient`. Mirrors `vc_config`. +public struct VoiceCatConfig: Sendable { + /// E.g. "VoiceCat-macOS". Forwarded in `ClientHello.client_name`. + public let clientName: String + /// E.g. "0.0.1". Forwarded in `ClientHello.client_version`. + public let clientVersion: String + public let logLevel: VoiceCatLogLevel + /// Path to the TOFU pin file (see `confirmServerIdentity` / docs/security.md §1.1). + /// nil = built-in relative default (only suitable for tests). + public let tofuStorePath: String? + + public init( + clientName: String, + clientVersion: String, + logLevel: VoiceCatLogLevel = .info, + tofuStorePath: String? = nil + ) { + self.clientName = clientName + self.clientVersion = clientVersion + self.logLevel = logLevel + self.tofuStorePath = tofuStorePath + } +} diff --git a/clients/apple/Sources/VoiceCatCore/Enums.swift b/clients/apple/Sources/VoiceCatCore/Enums.swift new file mode 100644 index 0000000..2f154c1 --- /dev/null +++ b/clients/apple/Sources/VoiceCatCore/Enums.swift @@ -0,0 +1,155 @@ +// Swift-idiomatic mirrors of the voicecat.h C enums. Keep these in lockstep with +// core/include/voicecat.h — values are append-only per the C ABI's house rule, so it's +// safe to add new cases at the end here too, but never renumber/remove existing ones. +// +// Swift imports the C enums directly via `import VoiceCatC` (e.g. VoiceCatC.VC_OK), but +// those case names are C-style (VC_ERR_NOT_IMPLEMENTED, VC_EVENT_SERVER_IDENTITY) — these +// mirrors give the Swift UI and tests clean dot-syntax (VoiceCatResult.notImplemented, +// VoiceCatEventType.serverIdentity) and a typed bridge to/from the C values. +// +// NOTE: Swift's Clang importer brings C `typedef enum` types in as UInt32-backed enums +// (all our C enum values are non-negative), so these mirrors use UInt32 raw values too. +// The one signed field in the ABI — `vc_event.result` is `int32_t` (not `vc_result`) — is +// bridged via `UInt32(bitPattern:)` in Event.swift. + +import VoiceCatC + +/// Result codes — mirrors `vc_result` (voicecat.h). Additive-only: new values go at the end. +public enum VoiceCatResult: UInt32, Sendable, Equatable { + case ok = 0 + case notImplemented = 1 + case invalidArg = 2 + case notConnected = 3 + case already = 4 + case authFailed = 5 + case permissionDenied = 6 + case timeout = 7 + case io = 8 + case protocolError = 9 + case crypto = 10 + case audio = 11 + case internalError = 12 + + /// Human-readable description from the core (vc_result_string returns a static literal). + public var description: String { + String(cString: vc_result_string(vc_result(rawValue))) + } + + /// Bridge from the C enum. + public init(_ cValue: vc_result) { self = VoiceCatResult(rawValue: cValue.rawValue) ?? .internalError } + /// Bridge to the C enum. + public var cValue: vc_result { vc_result(rawValue) } +} + +/// Log level — mirrors `vc_log_level`. +public enum VoiceCatLogLevel: UInt32, Sendable, Equatable { + case trace = 0 + case debug = 1 + case info = 2 + case warn = 3 + case error = 4 + case off = 5 + + public init(_ cValue: vc_log_level) { self = VoiceCatLogLevel(rawValue: cValue.rawValue) ?? .info } + public var cValue: vc_log_level { vc_log_level(rawValue) } +} + +/// Connection state — mirrors `vc_connection_state`. +public enum VoiceCatConnectionState: UInt32, Sendable, Equatable { + case disconnected = 0 + case connecting = 1 + case tlsHandshake = 2 + case authenticating = 3 + case connected = 4 + /// M4: handshake succeeded, waiting on `confirmServerIdentity()`. + case verifyingIdentity = 5 + + public init(_ cValue: vc_connection_state) { + self = VoiceCatConnectionState(rawValue: cValue.rawValue) ?? .disconnected + } + public var cValue: vc_connection_state { vc_connection_state(rawValue) } +} + +/// Text message scope — mirrors `vc_text_scope`. +public enum VoiceCatTextScope: UInt32, Sendable, Equatable { + case channel = 0 + case `private` = 1 + case server = 2 + + public init(_ cValue: vc_text_scope) { self = VoiceCatTextScope(rawValue: cValue.rawValue) ?? .channel } + public var cValue: vc_text_scope { vc_text_scope(rawValue) } +} + +/// Audio device kind — mirrors `vc_device_kind`. +public enum VoiceCatDeviceKind: UInt32, Sendable, Equatable { + case input = 0 + case output = 1 + + public init(_ cValue: vc_device_kind) { self = VoiceCatDeviceKind(rawValue: cValue.rawValue) ?? .input } + public var cValue: vc_device_kind { vc_device_kind(rawValue) } +} + +/// Stream kind — mirrors `vc_stream_kind`. +public enum VoiceCatStreamKind: UInt32, Sendable, Equatable { + case mic = 0 + /// System/desktop audio (docs/voice.md §9). + case screenAudio = 1 + case auxDevice = 2 + + public init(_ cValue: vc_stream_kind) { self = VoiceCatStreamKind(rawValue: cValue.rawValue) ?? .mic } + public var cValue: vc_stream_kind { vc_stream_kind(rawValue) } +} + +/// Send-side input gate mode (docs/voice.md §11) — mirrors `vc_input_mode`. +public enum VoiceCatInputMode: UInt32, Sendable, Equatable { + case voiceActivation = 0 + case pushToTalk = 1 + /// Transmit unconditionally, no VAD gate. + case alwaysOn = 2 + + public init(_ cValue: vc_input_mode) { self = VoiceCatInputMode(rawValue: cValue.rawValue) ?? .voiceActivation } + public var cValue: vc_input_mode { vc_input_mode(rawValue) } +} + +/// Event type — mirrors `vc_event_type`. Additive-only. +public enum VoiceCatEventType: UInt32, Sendable, Equatable { + case connectionState = 0 + case authResult = 1 + case channelList = 2 + case userJoined = 3 + case userLeft = 4 + case userUpdated = 5 + case textMessage = 6 + case streamStarted = 7 + case streamStopped = 8 + case talkState = 9 + case error = 10 + case disconnected = 11 + /// M4: reply to `joinChannel()` — see `VoiceCatEvent.result` / `.channelId`. + case joinResult = 12 + /// M4: the TOFU server-identity gate — see `VoiceCatEvent.tofuStatus` / `.text`. + case serverIdentity = 13 + /// M5: async result for moderation/admin/channel operations. + case genericResult = 14 + /// M5: reply to `requestAccountList()` — call `listAccounts()` to read. + case accountList = 15 + + public init(_ cValue: vc_event_type) { + self = VoiceCatEventType(rawValue: cValue.rawValue) ?? .error + } + public var cValue: vc_event_type { vc_event_type(rawValue) } +} + +/// TOFU server-identity classification — mirrors `vc_tofu_status`. Pins the TLS leaf +/// certificate's SHA-256 fingerprint (NOT the display-only Ed25519 value — see +/// docs/security.md §1.1 and `VoiceCatServerIdentity`). +public enum VoiceCatTofuStatus: UInt32, Sendable, Equatable { + case firstConnect = 0 + case matched = 1 + case mismatch = 2 + + public init(_ cValue: vc_tofu_status) { + self = VoiceCatTofuStatus(rawValue: cValue.rawValue) ?? .firstConnect + } + public var cValue: vc_tofu_status { vc_tofu_status(rawValue) } +} diff --git a/clients/apple/Sources/VoiceCatCore/Event.swift b/clients/apple/Sources/VoiceCatCore/Event.swift new file mode 100644 index 0000000..01859a6 --- /dev/null +++ b/clients/apple/Sources/VoiceCatCore/Event.swift @@ -0,0 +1,61 @@ +// VoiceCatEvent — a Swift value type that is safe to hold/queue past the native callback's +// return. This is the Swift analog of the C# client's `VoiceCatEvent` record. +// +// CRITICAL (voicecat.h's vc_event doc comment): the native `vc_event.text` pointer is owned +// by the core and valid ONLY for the duration of the `on_event` callback. `from(_:)` copies +// it to a Swift `String` immediately — never hold the raw `vc_event` across the callback +// boundary, or `text` will be a dangling pointer by the time it's read. This is the #1 +// lifetime rule carried over from the Windows client (NativeCallbacks.cs / VoiceCatEvent.cs). + +import VoiceCatC + +/// A Swift-safe copy of a `vc_event`. Produced inside the `on_event` callback (see +/// Callbacks.swift) — all pointer fields are converted to value types before the callback +/// returns. +public struct VoiceCatEvent: Sendable, Equatable { + public let type: VoiceCatEventType + public let connectionState: VoiceCatConnectionState + public let result: VoiceCatResult + public let userId: UInt32 + public let channelId: UInt32 + public let streamId: UInt32 + public let textScope: VoiceCatTextScope + /// Generic small payload, meaning per event type. For `.serverIdentity` this is the + /// `VoiceCatTofuStatus`; for `.genericResult` the server error code; for `.talkState` + /// talking(0/1). + public let u32a: UInt32 + /// Copied from the core's `vc_event.text` inside the callback. nil if the core passed NULL. + public let text: String? + public let timestampUnixMs: UInt64 + + /// Convenience: the TOFU status, valid when `type == .serverIdentity` (maps `u32a`). + public var tofuStatus: VoiceCatTofuStatus? { + type == .serverIdentity ? VoiceCatTofuStatus(rawValue: u32a) : nil + } + + /// Copy a native `vc_event` into a safe Swift value. MUST be called inside the callback + /// while `ev.text` is still valid — `String(cString:)` copies the bytes here. + @inline(__always) + public static func from(_ ev: vc_event) -> VoiceCatEvent { + let text: String? + if let raw = ev.text { + text = String(cString: raw) // copies — safe to hold past callback return + } else { + text = nil + } + // ev.result is int32_t (not vc_result) per voicecat.h — bridge via bitPattern. + // ev.u32a is uint32_t — matches VoiceCatTofuStatus's UInt32 raw value directly. + return VoiceCatEvent( + type: VoiceCatEventType(ev.type), + connectionState: VoiceCatConnectionState(ev.connection_state), + result: VoiceCatResult(rawValue: UInt32(bitPattern: ev.result)) ?? .internalError, + userId: ev.user_id, + channelId: ev.channel_id, + streamId: ev.stream_id, + textScope: VoiceCatTextScope(ev.text_scope), + u32a: ev.u32a, + text: text, + timestampUnixMs: ev.timestamp_unix_ms + ) + } +} diff --git a/clients/apple/Sources/VoiceCatCore/Marshaling.swift b/clients/apple/Sources/VoiceCatCore/Marshaling.swift new file mode 100644 index 0000000..9a75771 --- /dev/null +++ b/clients/apple/Sources/VoiceCatCore/Marshaling.swift @@ -0,0 +1,107 @@ +// Marshaling — shared "walk a native array of owned-struct entries, convert to Swift value +// types, free the native list" pattern. Identical shape for vc_device_list / vc_channel_list +// / vc_user_list / vc_stream_summary_list / vc_account_list (all core-allocated, caller-freed +// per voicecat.h). The matching vc_free_*_list call happens INSIDE each function here, +// immediately after the conversion, so callers never need to remember to free anything +// themselves. This is the Swift analog of the C# client's Marshaling.cs. + +import VoiceCatC +import Foundation + +/// Internal marshaling helpers — convert core-allocated C arrays to Swift arrays and +/// immediately free the native list. Not part of the public API. +internal enum Marshaling { + /// Convert a nullable `const char*` to a Swift `String` (empty if NULL). + @inline(__always) + static func string(_ ptr: UnsafePointer?) -> String { + guard let ptr else { return "" } + return String(cString: ptr) + } + + static func devices(_ list: inout vc_device_list) -> [Device] { + guard let items = list.items else { vc_free_device_list(&list); return [] } + var result: [Device] = [] + result.reserveCapacity(list.count) + for i in 0.. [Channel] { + guard let items = list.items else { vc_free_channel_list(&list); return [] } + var result: [Channel] = [] + result.reserveCapacity(list.count) + for i in 0.. [User] { + guard let items = list.items else { vc_free_user_list(&list); return [] } + var result: [User] = [] + result.reserveCapacity(list.count) + for i in 0.. [StreamSummary] { + guard let items = list.items else { vc_free_stream_summary_list(&list); return [] } + var result: [StreamSummary] = [] + result.reserveCapacity(list.count) + for i in 0.. [Account] { + guard let items = list.items else { vc_free_account_list(&list); return [] } + var result: [Account] = [] + result.reserveCapacity(list.count) + for i in 0.. RemoteStreamState { + RemoteStreamState(gain: s.gain, muted: s.muted != 0, noiseReduction: s.noise_reduction != 0) + } + + static func audioConfig(_ c: vc_audio_config) -> AudioConfig { + AudioConfig(codec: c.codec, stereo: c.mode != 0, sampleRate: c.sample_rate, + bitrateBps: c.bitrate_bps, frameMs: c.frame_ms, application: c.application, + fec: c.fec != 0, expectedPacketLoss: c.expected_packet_loss, + dtx: c.dtx != 0, complexity: c.complexity) + } + + static func permissions(_ p: vc_permissions) -> Permissions { + Permissions(canCreateTempChannel: p.can_create_temp_channel != 0, + canKick: p.can_kick != 0, canBan: p.can_ban != 0, + canMoveUsers: p.can_move_users != 0, + canAdminAccounts: p.can_admin_accounts != 0, + isAdmin: p.is_admin != 0) + } +} diff --git a/clients/apple/Sources/VoiceCatCore/Models.swift b/clients/apple/Sources/VoiceCatCore/Models.swift new file mode 100644 index 0000000..dcea0f4 --- /dev/null +++ b/clients/apple/Sources/VoiceCatCore/Models.swift @@ -0,0 +1,190 @@ +// Plain Swift value types — what survives past the native struct/free-list lifetime +// (Marshaling.swift converts the C structs into these and immediately frees the native +// list). Nothing here holds a raw pointer. This is the Swift analog of the C# client's +// Models.cs. Field naming follows Swift camelCase (the C structs use snake_case). + +import VoiceCatC + +/// Channel snapshot — mirrors `vc_channel` (the pull-based view; re-call `listChannels()` +/// after `.channelList` / `.userJoined` / `.userLeft` / `.userUpdated` events). +public struct Channel: Sendable, Equatable, Identifiable { + public let id: UInt32 + /// 0 = root. + public let parentId: UInt32 + public let name: String + public let topic: String + public let passwordProtected: Bool + /// 0 = unlimited. + public let maxUsers: UInt32 + + public init(id: UInt32, parentId: UInt32, name: String, topic: String, + passwordProtected: Bool, maxUsers: UInt32) { + self.id = id; self.parentId = parentId; self.name = name; self.topic = topic + self.passwordProtected = passwordProtected; self.maxUsers = maxUsers + } +} + +/// Channel creation/edition descriptor — mirrors `vc_channel_info`. Used by +/// `createChannel(_:)` and `editChannel(_:)`. `id == 0` means new channel (for create). +public struct ChannelEdit: Sendable, Equatable { + public let id: UInt32 // 0 = new channel for create + public let parentId: UInt32 // 0 = root + public let name: String + public let topic: String + public let passwordProtected: Bool + public let password: String? // nil/empty ignored if passwordProtected == false + public let maxUsers: UInt32 // 0 = unlimited + public let sortOrder: UInt32 + /// 0/nil fields use server defaults. + public let audio: AudioConfig + + public init(id: UInt32, parentId: UInt32, name: String, topic: String, + passwordProtected: Bool, password: String?, maxUsers: UInt32, + sortOrder: UInt32, audio: AudioConfig) { + self.id = id; self.parentId = parentId; self.name = name; self.topic = topic + self.passwordProtected = passwordProtected; self.password = password + self.maxUsers = maxUsers; self.sortOrder = sortOrder; self.audio = audio + } +} + +/// User snapshot — mirrors `vc_user`. +public struct User: Sendable, Equatable, Identifiable { + public let id: UInt32 + public let nickname: String + public let isGuest: Bool + public let channelId: UInt32 + public let selfMicMuted: Bool + public let selfDeafened: Bool + public let serverMuted: Bool + public let serverDeafened: Bool + + public init(id: UInt32, nickname: String, isGuest: Bool, channelId: UInt32, + selfMicMuted: Bool, selfDeafened: Bool, serverMuted: Bool, + serverDeafened: Bool) { + self.id = id; self.nickname = nickname; self.isGuest = isGuest; self.channelId = channelId + self.selfMicMuted = selfMicMuted; self.selfDeafened = selfDeafened + self.serverMuted = serverMuted; self.serverDeafened = serverDeafened + } +} + +/// Permission bitset — mirrors `vc_permissions` (M5). +public struct Permissions: Sendable, Equatable { + public let canCreateTempChannel: Bool + public let canKick: Bool + public let canBan: Bool + public let canMoveUsers: Bool + public let canAdminAccounts: Bool + public let isAdmin: Bool + + public init(canCreateTempChannel: Bool, canKick: Bool, canBan: Bool, + canMoveUsers: Bool, canAdminAccounts: Bool, isAdmin: Bool) { + self.canCreateTempChannel = canCreateTempChannel; self.canKick = canKick; self.canBan = canBan + self.canMoveUsers = canMoveUsers; self.canAdminAccounts = canAdminAccounts; self.isAdmin = isAdmin + } +} + +/// Account entry — mirrors `vc_account` (M5, reply to `listAccounts()`). +public struct Account: Sendable, Equatable { + public let username: String + public let isAdmin: Bool + public let createdAtUnixMs: UInt64 + public let lastLoginUnixMs: UInt64 + + public init(username: String, isAdmin: Bool, createdAtUnixMs: UInt64, + lastLoginUnixMs: UInt64) { + self.username = username; self.isAdmin = isAdmin + self.createdAtUnixMs = createdAtUnixMs; self.lastLoginUnixMs = lastLoginUnixMs + } +} + +/// Per-user stream summary — mirrors `vc_stream_summary`. For the full effective Opus +/// config of a specific (user_id, stream_id), use `VoiceCatClient.getStreamAudioConfig`. +public struct StreamSummary: Sendable, Equatable, Identifiable { + public let id: UInt32 // stream_id + public let kind: VoiceCatStreamKind + public let label: String + + public init(streamId: UInt32, kind: VoiceCatStreamKind, label: String) { + self.id = streamId; self.kind = kind; self.label = label + } +} + +/// Receive-side state the local listener chose for a specific remote stream — mirrors +/// `vc_remote_stream_state`. All LOCAL (no protocol traffic) — docs/voice.md §10. +/// Defaults (if `setRemoteStream` was never called): gain 1.0, unmuted, NR off. +public struct RemoteStreamState: Sendable, Equatable { + public let gain: Float // 0.0–… ; default 1.0 + public let muted: Bool + public let noiseReduction: Bool + + public init(gain: Float, muted: Bool, noiseReduction: Bool) { + self.gain = gain; self.muted = muted; self.noiseReduction = noiseReduction + } +} + +/// Audio device — mirrors `vc_device`. `id` is an opaque, internally-encoded handle +/// (currently hex-encoded `ma_device_id`) — always round-trip an id from `listDevices`; +/// never construct one by hand (docs/architecture.md §4). +public struct Device: Sendable, Equatable, Identifiable { + public let id: String + public let name: String + public let isDefault: Bool + + public init(id: String, name: String, isDefault: Bool) { + self.id = id; self.name = name; self.isDefault = isDefault + } +} + +/// Effective Opus configuration — mirrors `vc_audio_config`. +public struct AudioConfig: Sendable, Equatable { + public let codec: UInt32 // 0 = OPUS + public let stereo: Bool // mode: 0 = mono, 1 = stereo + public let sampleRate: UInt32 + public let bitrateBps: UInt32 + public let frameMs: UInt32 + public let application: UInt32 // 0 = VOIP, 1 = AUDIO, 2 = LOWDELAY + public let fec: Bool + public let expectedPacketLoss: UInt32 // % 0..100 + public let dtx: Bool + public let complexity: UInt32 // 0..10 + + public init(codec: UInt32 = 0, stereo: Bool = false, sampleRate: UInt32 = 48000, + bitrateBps: UInt32 = 64000, frameMs: UInt32 = 20, application: UInt32 = 0, + fec: Bool = true, expectedPacketLoss: UInt32 = 5, dtx: Bool = false, + complexity: UInt32 = 10) { + self.codec = codec; self.stereo = stereo; self.sampleRate = sampleRate + self.bitrateBps = bitrateBps; self.frameMs = frameMs; self.application = application + self.fec = fec; self.expectedPacketLoss = expectedPacketLoss; self.dtx = dtx + self.complexity = complexity + } +} + +/// Stream descriptor — mirrors `vc_stream_desc`. Used by `startStream(kind:deviceId:label:)`. +public struct StreamDescriptor: Sendable, Equatable { + public let kind: VoiceCatStreamKind + /// nil = default device for this kind. + public let deviceId: String? + public let label: String + + public init(kind: VoiceCatStreamKind, deviceId: String? = nil, label: String) { + self.kind = kind; self.deviceId = deviceId; self.label = label + } +} + +/// Server identity info — parsed from a `.serverIdentity` event + `getServerIdentityDisplay()`. +/// The `tlsCertFingerprint` (SHA-256 hex of the TLS leaf cert) is the value the TOFU gate +/// actually pins on; `ed25519Fingerprint` is display-only (docs/security.md §1.1). +public struct ServerIdentity: Sendable, Equatable { + public let tofuStatus: VoiceCatTofuStatus + /// SHA-256 hex of the TLS leaf certificate — the pinned value. No separators (64 chars). + public let tlsCertFingerprint: String + /// Ed25519 identity fingerprint from ServerHello, hex-formatted — display only. + /// Empty if not yet available. + public let ed25519Fingerprint: String + + public init(tofuStatus: VoiceCatTofuStatus, tlsCertFingerprint: String, + ed25519Fingerprint: String) { + self.tofuStatus = tofuStatus; self.tlsCertFingerprint = tlsCertFingerprint + self.ed25519Fingerprint = ed25519Fingerprint + } +} diff --git a/clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift b/clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift new file mode 100644 index 0000000..cdd512e --- /dev/null +++ b/clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift @@ -0,0 +1,503 @@ +// VoiceCatClient — the public, Swift-idiomatic surface over libvoicecat. This is the Swift +// analog of the C# client's `VoiceCatClient.cs` (clients/windows/VoiceCat.Interop). +// +// Key patterns carried over from the proven C# implementation (see docs/architecture.md §4 +// per-platform binding notes): +// +// 1. HANDLE OWNERSHIP: the class owns `vc_client*`; `deinit` calls `vc_client_destroy` +// (which synchronously joins every internal thread, so nothing can still be reading the +// config-string pointers or firing callbacks by the time it returns). +// +// 2. CONFIG STRING LIFETIMES: the core stores raw pointers from `vc_config` by value — it +// does NOT copy the string data. `client_name`/`client_version`/`tofu_store_path` are +// read later, whenever `connect()` actually runs on the io_thread_. So the native CString +// storage (`_clientNamePtr` etc.) must outlive the WHOLE client, not just `init`. It's +// freed in `deinit`, AFTER `vc_client_destroy` has returned. (C#: Marshal.StringToCoTask +// MemUTF8 in ctor, FreeCoTaskMem in Dispose after destroy.) +// +// 3. EVENT DELIVERY THREAD HANDOFF: `on_event` fires on the core's event thread. Events are +// buffered in a lock-protected array and drained on `DispatchQueue.main` — this is the +// boundary where the core's thread hands off to the UI thread. The C# analog is +// `Channel` drained by a 30ms WinForms Timer; the Swift analog is a +// coalesced main-queue drain (only one async block scheduled at a time). `on_event`'s +// `text` is copied to a Swift `String` inside the callback (Callbacks.swift) before +// enqueueing — the raw pointer is dangling by the time the main thread drains. +// +// 4. LEVEL METER COALESCING: `on_level` fires far more often than `on_event` and +// intermediate values are visually irrelevant — coalesced to "latest sample per +// stream_id" in a lock-protected dictionary, drained on main alongside events. +// (C#: ConcurrentDictionary cleared in PumpEvents.) +// +// 5. IMMEDIATE vc_free_* ON LIST READS: `listChannels()`/`listUsers()`/etc. walk the native +// array, convert to Swift value types, and call `vc_free_*_list` INSIDE the function — +// callers never manage native list lifetime. (C#: Marshaling.ToManaged does the same.) + +import VoiceCatC +import Foundation + +/// The Swift wrapper around `vc_client*`. Owns the native handle for its entire lifetime; +/// `deinit` destroys it. Events and level meters are delivered on the main queue via the +/// `onEvent` / `onLevel` closures. +/// +/// Thread-safety: the public methods are not thread-safe — call them from the main thread +/// (the standard AppKit/SwiftUI pattern). The internal event/level buffers are thread-safe +/// (lock-protected) because they're written from the core's event thread. +public final class VoiceCatClient { + + // MARK: - Stored properties + + /// The opaque C handle (`vc_client*` — Swift imports the incomplete C struct as + /// `OpaquePointer`). Set in `init`, passed to every C function, destroyed in `deinit`. + private var handle: OpaquePointer? + + /// Unmanaged pointer to `self` — passed as `vc_callbacks.user` so the C function-pointer + /// callbacks can resolve back to this instance. `passUnretained` (not `passRetained`) + /// because we want normal ARC to control the object's lifetime — `deinit` calls + /// `vc_client_destroy` (joins all threads) before the object's memory is freed, so no + /// callback can fire with a dangling `user` pointer. See Callbacks.swift. + /// + /// Computed (not stored) to break a circular init dependency: it needs `self`, but + /// stored properties must be initialized before `self` is available. `Unmanaged.passUn + /// retained(self).toOpaque()` always returns the same address for a given instance, so + /// computing it on demand is safe and consistent. + private var selfPointer: UnsafeMutableRawPointer { + Unmanaged.passUnretained(self).toOpaque() + } + + /// Native CString storage backing `vc_config` — must outlive the whole client (the core + /// stores raw pointers, doesn't copy). Freed in `deinit` after `vc_client_destroy`. + private var clientNamePtr: UnsafeMutablePointer? + private var clientVersionPtr: UnsafeMutablePointer? + private var tofuStorePathPtr: UnsafeMutablePointer? + + // MARK: - Event / level delivery (main-queue) + + /// Called on the main queue for every event, in order, never coalesced. Set this from + /// the main thread (AppKit/SwiftUI) to drive your UI. + public var onEvent: ((VoiceCatEvent) -> Void)? + + /// Called on the main queue with the latest RMS level per stream_id since the last drain. + /// Intermediate values are coalesced (only the latest per stream_id is delivered). + public var onLevel: ((UInt32, Float) -> Void)? + + /// Lock-protected buffers, written from the core's event thread, drained on main. + private let bufferLock = NSLock() + private var eventBuffer: [VoiceCatEvent] = [] + private var levelSamples: [UInt32: Float] = [:] + private var drainScheduled = false + + // MARK: - Init / deinit + + /// Create a client. `config.clientName`/`clientVersion`/`tofuStorePath` are copied to + /// native CString storage held for the client's entire lifetime (the core reads them + /// later, e.g. when `connect()` runs on the io thread). + public init(config: VoiceCatConfig) { + // Allocate native C strings — must persist until after vc_client_destroy in deinit. + // These don't need `self`, so they're safe to set first. + self.clientNamePtr = strdup(config.clientName) + self.clientVersionPtr = strdup(config.clientVersion) + self.tofuStorePathPtr = config.tofuStorePath.flatMap { strdup($0) } + self.handle = nil // placeholder — set below after callbacks are wired + + // All stored properties are now initialized → `self` is fully available, so we can + // call `selfPointer` (the computed property) to build the callbacks struct. + var nativeConfig = vc_config() + nativeConfig.client_name = UnsafePointer(clientNamePtr) + nativeConfig.client_version = UnsafePointer(clientVersionPtr) + nativeConfig.log_level = config.logLevel.cValue + nativeConfig.tofu_store_path = UnsafePointer(tofuStorePathPtr) + + let callbacks = Callbacks.make(user: selfPointer) + self.handle = vc_client_create(&nativeConfig, callbacks) + + if handle == nil { + free(clientNamePtr); clientNamePtr = nil + free(clientVersionPtr); clientVersionPtr = nil + if let tofuStorePathPtr { free(tofuStorePathPtr); self.tofuStorePathPtr = nil } + fatalError("vc_client_create returned nil") + } + } + + deinit { + if let handle { + // Joins every internal thread synchronously — no callbacks can fire after this + // returns, so the selfPointer and config-string pointers are safe to free. + vc_client_destroy(handle) + self.handle = nil + } + // Free config strings AFTER destroy (the core may have been reading them up until + // destroy joined the io thread). + free(clientNamePtr); clientNamePtr = nil + free(clientVersionPtr); clientVersionPtr = nil + if let tofuStorePathPtr { free(tofuStorePathPtr); self.tofuStorePathPtr = nil } + } + + // MARK: - Internal: event/level enqueue (called from the core's event thread) + + /// Called by Callbacks.onEvent on the core's event thread. Buffers the event and + /// schedules a coalesced main-queue drain. + internal func enqueueEvent(_ event: VoiceCatEvent) { + bufferLock.lock() + eventBuffer.append(event) + let shouldSchedule = !drainScheduled + drainScheduled = true + bufferLock.unlock() + if shouldSchedule { + DispatchQueue.main.async { [weak self] in self?.drain() } + } + } + + /// Called by Callbacks.onLevel on the core's event thread. Coalesces to latest-per-stream + /// and schedules a coalesced main-queue drain. + internal func enqueueLevel(_ streamId: UInt32, _ rms: Float) { + bufferLock.lock() + levelSamples[streamId] = rms + let shouldSchedule = !drainScheduled + drainScheduled = true + bufferLock.unlock() + if shouldSchedule { + DispatchQueue.main.async { [weak self] in self?.drain() } + } + } + + /// Drains buffered events + coalesced levels on the main queue. Only one drain is + /// scheduled at a time (debounced via `drainScheduled`). + private func drain() { + bufferLock.lock() + let events = eventBuffer + eventBuffer.removeAll() + let levels = levelSamples + levelSamples.removeAll() + drainScheduled = false + bufferLock.unlock() + + for event in events { onEvent?(event) } + for (streamId, rms) in levels { onLevel?(streamId, rms) } + } + + // MARK: - Lifecycle (statics) + + /// The core's version string (e.g. "VoiceCat 0.0.1 (protocol v1)"). Static literal — never freed. + public static var versionString: String { + String(cString: vc_version_string()) + } + + /// Human-readable description of a result code. Static literal — never freed. + public static func resultString(_ code: VoiceCatResult) -> String { + String(cString: vc_result_string(code.cValue)) + } + + // MARK: - Connection & auth (async; results via onEvent) + + @discardableResult + public func connect(host: String, port: UInt16) -> VoiceCatResult { + VoiceCatResult(vc_connect(handle, host, port)) + } + + @discardableResult + public func disconnect() -> VoiceCatResult { + VoiceCatResult(vc_disconnect(handle)) + } + + @discardableResult + public func authenticateGuest(_ nickname: String) -> VoiceCatResult { + VoiceCatResult(vc_authenticate_guest(handle, nickname)) + } + + @discardableResult + public func authenticateUser(_ username: String, password: String) -> VoiceCatResult { + VoiceCatResult(vc_authenticate_user(handle, username, password)) + } + + // MARK: - TOFU server-identity gate (M4) + + /// Accept or reject the pending server-identity check. Call after a `.serverIdentity` + /// event. `accept=true` on firstConnect/mismatch updates the pin file and proceeds; + /// `accept=false` aborts (emits `.disconnected` with `.crypto`). See docs/security.md §1.1. + @discardableResult + public func confirmServerIdentity(accept: Bool) -> VoiceCatResult { + VoiceCatResult(vc_confirm_server_identity(handle, accept ? 1 : 0)) + } + + /// The Ed25519 identity fingerprint from ServerHello, hex-formatted — DISPLAY ONLY, not + /// the value the TOFU gate pins on (see docs/security.md §1.1). Empty if not yet available. + /// Uses the two-call idiom: query size with nil buffer, then allocate + fetch. + public func getServerIdentityDisplay() -> String { + var len: Int = 0 + _ = vc_get_server_identity_display(handle, nil, 0, &len) + if len == 0 { return "" } + let buf = UnsafeMutablePointer.allocate(capacity: len + 1) + defer { buf.deallocate() } + _ = vc_get_server_identity_display(handle, buf, len + 1, &len) + return String(cString: buf) + } + + // MARK: - Channels + + /// Join a channel. Result arrives as a `.joinResult` event (not via the return value, + /// which only reflects "request queued"). `password` is for password-protected channels. + @discardableResult + public func joinChannel(_ channelId: UInt32, password: String? = nil) -> VoiceCatResult { + VoiceCatResult(vc_join_channel(handle, channelId, password)) + } + + @discardableResult + public func leaveChannel() -> VoiceCatResult { + VoiceCatResult(vc_leave_channel(handle)) + } + + /// Pull the current channel tree. Re-call after `.channelList`/`.userJoined`/`.userLeft`/ + /// `.userUpdated` events. The native list is freed inside this call — callers never + /// manage native lifetime. + public func listChannels() -> [Channel] { + var native = vc_channel_list() + _ = vc_list_channels(handle, &native) + return Marshaling.channels(&native) + } + + public func listUsers() -> [User] { + var native = vc_user_list() + _ = vc_list_users(handle, &native) + return Marshaling.users(&native) + } + + public func listUserStreams(_ userId: UInt32) -> [StreamSummary] { + var native = vc_stream_summary_list() + let r = vc_list_user_streams(handle, userId, &native) + guard r == VC_OK else { return [] } + return Marshaling.streamSummaries(&native) + } + + // MARK: - Local media streams + + /// Start a mic / screen-audio / aux stream. Returns `(result, streamId)` — `streamId` + /// is non-zero on success. The `label` and `deviceId` C strings are only needed for the + /// duration of the call (the core copies what it needs), so we use temporary strdup'd + /// buffers freed via `defer`. + @discardableResult + public func startStream(_ descriptor: StreamDescriptor) -> (VoiceCatResult, UInt32) { + var streamId: UInt32 = 0 + let labelPtr = strdup(descriptor.label) + defer { free(labelPtr) } + let deviceIdPtr = descriptor.deviceId.flatMap { strdup($0) } + defer { if let deviceIdPtr { free(deviceIdPtr) } } + + var desc = vc_stream_desc() + desc.kind = descriptor.kind.cValue + desc.device_id = deviceIdPtr.map { UnsafePointer($0) } + desc.label = UnsafePointer(labelPtr) + + let r = vc_stream_start(handle, &desc, &streamId) + return (VoiceCatResult(r), streamId) + } + + @discardableResult + public func stopStream(_ streamId: UInt32) -> VoiceCatResult { + VoiceCatResult(vc_stream_stop(handle, streamId)) + } + + @discardableResult + public func setInputDevice(streamId: UInt32, deviceId: String?) -> VoiceCatResult { + VoiceCatResult(vc_set_input_device(handle, streamId, deviceId)) + } + + @discardableResult + public func setInputMode(_ mode: VoiceCatInputMode) -> VoiceCatResult { + VoiceCatResult(vc_set_input_mode(handle, mode.cValue)) + } + + /// VAD threshold: normalized RMS 0.0–1.0 (default ~0.025). Takes effect immediately. + @discardableResult + public func setVadThreshold(_ threshold: Float) -> VoiceCatResult { + VoiceCatResult(vc_set_vad_threshold(handle, threshold)) + } + + @discardableResult + public func setPushToTalk(_ active: Bool) -> VoiceCatResult { + VoiceCatResult(vc_set_push_to_talk(handle, active ? 1 : 0)) + } + + @discardableResult + public func setSelfMute(micMuted: Bool, deafened: Bool) -> VoiceCatResult { + VoiceCatResult(vc_set_self_mute(handle, micMuted ? 1 : 0, deafened ? 1 : 0)) + } + + // MARK: - Receive-side, per remote stream (LOCAL — no protocol traffic; docs/voice.md §10) + + @discardableResult + public func setRemoteStream(userId: UInt32, streamId: UInt32, gain: Float, + muted: Bool, noiseReduction: Bool) -> VoiceCatResult { + VoiceCatResult(vc_set_remote_stream(handle, userId, streamId, gain, + muted ? 1 : 0, noiseReduction ? 1 : 0)) + } + + public func getRemoteStream(userId: UInt32, streamId: UInt32) -> (VoiceCatResult, RemoteStreamState?) { + var state = vc_remote_stream_state() + let r = vc_get_remote_stream(handle, userId, streamId, &state) + guard r == VC_OK else { return (VoiceCatResult(r), nil) } + return (VoiceCatResult(r), Marshaling.remoteStreamState(state)) + } + + public func getStreamAudioConfig(userId: UInt32, streamId: UInt32) -> (VoiceCatResult, AudioConfig?) { + var cfg = vc_audio_config() + let r = vc_get_stream_audio_config(handle, userId, streamId, &cfg) + guard r == VC_OK else { return (VoiceCatResult(r), nil) } + return (VoiceCatResult(r), Marshaling.audioConfig(cfg)) + } + + // MARK: - Text + + @discardableResult + public func sendText(scope: VoiceCatTextScope, targetId: UInt32, text: String) -> VoiceCatResult { + VoiceCatResult(vc_send_text(handle, scope.cValue, targetId, text)) + } + + // MARK: - Device enumeration (works pre-connect) + + public func listDevices(_ kind: VoiceCatDeviceKind) -> [Device] { + var native = vc_device_list() + _ = vc_list_devices(handle, kind.cValue, &native) + return Marshaling.devices(&native) + } + + // MARK: - M5: Moderation + + @discardableResult + public func kickUser(_ userId: UInt32, reason: String? = nil) -> VoiceCatResult { + VoiceCatResult(vc_kick_user(handle, userId, reason)) + } + + @discardableResult + public func banUser(_ userId: UInt32, reason: String? = nil, + expiresUnixMs: UInt64 = 0) -> VoiceCatResult { + VoiceCatResult(vc_ban_user(handle, userId, reason, expiresUnixMs)) + } + + @discardableResult + public func setPermission(_ userId: UInt32, perms: Permissions) -> VoiceCatResult { + var native = vc_permissions() + native.can_create_temp_channel = perms.canCreateTempChannel ? 1 : 0 + native.can_kick = perms.canKick ? 1 : 0 + native.can_ban = perms.canBan ? 1 : 0 + native.can_move_users = perms.canMoveUsers ? 1 : 0 + native.can_admin_accounts = perms.canAdminAccounts ? 1 : 0 + native.is_admin = perms.isAdmin ? 1 : 0 + return VoiceCatResult(vc_set_permission(handle, userId, &native)) + } + + @discardableResult + public func setServerMute(_ userId: UInt32, muted: Bool, deafened: Bool) -> VoiceCatResult { + VoiceCatResult(vc_set_server_mute(handle, userId, muted ? 1 : 0, deafened ? 1 : 0)) + } + + @discardableResult + public func moveUser(_ userId: UInt32, toChannel channelId: UInt32) -> VoiceCatResult { + VoiceCatResult(vc_move_user(handle, userId, channelId)) + } + + // MARK: - M5: Channel admin + + @discardableResult + public func createChannel(_ info: ChannelEdit) -> VoiceCatResult { + var native = vc_channel_info() + Self.populateChannelInfo(&native, from: info) + defer { Self.freeChannelInfoStrings(&native) } + return VoiceCatResult(vc_create_channel(handle, &native)) + } + + @discardableResult + public func editChannel(_ info: ChannelEdit) -> VoiceCatResult { + var native = vc_channel_info() + Self.populateChannelInfo(&native, from: info) + defer { Self.freeChannelInfoStrings(&native) } + return VoiceCatResult(vc_edit_channel(handle, &native)) + } + + @discardableResult + public func deleteChannel(_ channelId: UInt32) -> VoiceCatResult { + VoiceCatResult(vc_delete_channel(handle, channelId)) + } + + // MARK: - M5: Account admin + + @discardableResult + public func createAccount(_ username: String, password: String) -> VoiceCatResult { + VoiceCatResult(vc_create_account(handle, username, password)) + } + + @discardableResult + public func resetPassword(_ username: String, newPassword: String) -> VoiceCatResult { + VoiceCatResult(vc_reset_password(handle, username, newPassword)) + } + + @discardableResult + public func deleteAccount(_ username: String) -> VoiceCatResult { + VoiceCatResult(vc_delete_account(handle, username)) + } + + /// Request the account list — result arrives as a `.accountList` event, then call + /// `listAccounts()` to pull the cached list. + @discardableResult + public func requestAccountList() -> VoiceCatResult { + VoiceCatResult(vc_list_accounts(handle)) + } + + public func listAccounts() -> [Account] { + var native = vc_account_list() + _ = vc_get_account_list(handle, &native) + return Marshaling.accounts(&native) + } + + public func getPermissions() -> Permissions { + var native = vc_permissions() + _ = vc_get_permissions(handle, &native) + return Marshaling.permissions(native) + } +} + +// MARK: - Helpers for vc_channel_info / vc_audio_config construction + +extension VoiceCatClient { + /// Populate a `vc_channel_info` from a Swift `ChannelEdit`. The string fields + /// (`name`/`topic`/`password`) are strdup'd — the caller MUST call + /// `freeChannelInfoStrings(_:)` after the C call returns (the core copies what it needs + /// during the call, so the temporary buffers can be freed via `defer`). + internal static func populateChannelInfo(_ native: inout vc_channel_info, from info: ChannelEdit) { + native.id = info.id + native.parent_id = info.parentId + native.name = UnsafePointer(strdup(info.name)) + native.topic = UnsafePointer(strdup(info.topic)) + native.password_protected = info.passwordProtected ? 1 : 0 + native.password = (info.passwordProtected && !(info.password?.isEmpty ?? true)) + ? UnsafePointer(strdup(info.password!)) : nil + native.max_users = info.maxUsers + native.sort_order = info.sortOrder + native.audio = info.audio.toNative() + } + + /// Free the strdup'd string fields of a `vc_channel_info` populated by + /// `populateChannelInfo`. Call this in a `defer` after the C call. + internal static func freeChannelInfoStrings(_ native: inout vc_channel_info) { + if let p = native.name { free(UnsafeMutablePointer(mutating: p)); native.name = nil } + if let p = native.topic { free(UnsafeMutablePointer(mutating: p)); native.topic = nil } + if let p = native.password { free(UnsafeMutablePointer(mutating: p)); native.password = nil } + } +} + +extension AudioConfig { + /// Convert to a native `vc_audio_config`. + internal func toNative() -> vc_audio_config { + var n = vc_audio_config() + n.codec = codec + n.mode = stereo ? 1 : 0 + n.sample_rate = sampleRate + n.bitrate_bps = bitrateBps + n.frame_ms = frameMs + n.application = application + n.fec = fec ? 1 : 0 + n.expected_packet_loss = expectedPacketLoss + n.dtx = dtx ? 1 : 0 + n.complexity = complexity + return n + } +} diff --git a/clients/apple/Tests/VoiceCatCoreTests/VoiceCatClientSmokeTests.swift b/clients/apple/Tests/VoiceCatCoreTests/VoiceCatClientSmokeTests.swift new file mode 100644 index 0000000..21deeca --- /dev/null +++ b/clients/apple/Tests/VoiceCatCoreTests/VoiceCatClientSmokeTests.swift @@ -0,0 +1,505 @@ +// VoiceCatClientSmokeTests — exercises the full connect → TOFU → auth → channels → +// moderation flow purely through the Swift wrapper layer (VoiceCatClient), against a real +// `voicecat-server` (the same binary the C++ ctest suite uses, built by `cmake --preset dev`). +// This is the Swift analog of clients/windows/VoiceCat.Interop.Tests/VoiceCatClientSmokeTests.cs. +// +// Why this exists (same rationale as the C# tests): the C++ ctest suite proves the protocol +// works at the C++ level, but Swift-specific interop bugs — @convention(c) callback lifetime, +// Unmanaged pointer resolution, CString memory management, enum raw-value bridging, struct +// field layout — can only be caught by exercising the exact Swift→C boundary. These tests +// catch the same class of bugs the C# P/Invoke tests catch, for Swift. +// +// Prerequisites: `cmake --preset dev && cmake --build --preset dev` (builds voicecat-server +// and voicecat-admin into build/dev/bin/), AND `scripts/build-xcframework.sh` (builds the +// VoiceCatCore.xcframework that the Swift Package links). + +import XCTest +import Foundation +@testable import VoiceCatCore + +/// Manages a real `voicecat-server` process for the test suite's lifetime. Starts the server +/// on an ephemeral port (--port 0), parses the bound port from stdout, and provisions a known +/// admin account via `voicecat-admin`. Killed + cleaned up in deinit. +private final class ServerHarness { + let port: UInt16 + private let process: Process + let tempDir: String + + init() throws { + let repoRoot = Self.findRepoRoot() + let serverURL = URL(fileURLWithPath: repoRoot) + .appendingPathComponent("build/dev/bin/voicecat-server") + + guard FileManager.default.isExecutableFile(atPath: serverURL.path) else { + throw NSError(domain: "VoiceCatTest", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "voicecat-server not found at \(serverURL.path) — " + + "build the dev preset first: cmake --preset dev && cmake --build --preset dev", + ]) + } + + let tempDir = NSTemporaryDirectory() + "vc_swift_smoke_" + UUID().uuidString + try FileManager.default.createDirectory(atPath: tempDir, withIntermediateDirectories: true) + self.tempDir = tempDir + + let p = Process() + p.executableURL = serverURL + p.arguments = ["--port", "0", "--data-dir", tempDir, "--name", "SwiftSmokeTest"] + + // Pipe stdout to read the bound port; stderr to /dev/null. + let stdoutPipe = Pipe() + p.standardOutput = stdoutPipe + p.standardError = FileHandle(forWritingAtPath: "/dev/null") + try p.run() + self.process = p + + // Parse "[voicecat-server] ... — TCP : UDP :" from stdout. The server + // prints several lines before the port line (version, first-run admin box, etc.), so + // we keep reading until we find a line matching "TCP :". Read with a 10s timeout + // so a crashed/hung server can't hang the test forever. + guard let port = Self.readPortWithTimeout(stdoutPipe, timeout: 10) else { + p.terminate() + throw NSError(domain: "VoiceCatTest", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "voicecat-server did not report a bound TCP port within 10s", + ]) + } + self.port = port + + // Provision a known admin account for moderation/admin tests (M5). + let adminURL = URL(fileURLWithPath: repoRoot) + .appendingPathComponent("build/dev/bin/voicecat-admin") + guard FileManager.default.isExecutableFile(atPath: adminURL.path) else { + throw NSError(domain: "VoiceCatTest", code: 3, userInfo: [ + NSLocalizedDescriptionKey: "voicecat-admin not found at \(adminURL.path)", + ]) + } + let adminProc = Process() + adminProc.executableURL = adminURL + adminProc.arguments = ["--data-dir", tempDir, "account", "add", "admin2", + "--admin", "--password", "testpassword123"] + adminProc.standardOutput = FileHandle(forWritingAtPath: "/dev/null") + adminProc.standardError = FileHandle(forWritingAtPath: "/dev/null") + try adminProc.run() + adminProc.waitUntilExit() + guard adminProc.terminationStatus == 0 else { + throw NSError(domain: "VoiceCatTest", code: 4, userInfo: [ + NSLocalizedDescriptionKey: "voicecat-admin failed to provision admin2 (exit \(adminProc.terminationStatus))", + ]) + } + } + + deinit { + if process.isRunning { process.terminate() } + try? FileManager.default.removeItem(atPath: tempDir) + } + + private static func findRepoRoot() -> String { + var url = URL(fileURLWithPath: #file) + while url.path != "/" && !FileManager.default.fileExists(atPath: url.appendingPathComponent("CMakePresets.json").path) { + url = url.deletingLastPathComponent() + } + guard FileManager.default.fileExists(atPath: url.appendingPathComponent("CMakePresets.json").path) else { + fatalError("Could not find repo root (CMakePresets.json) above \(#file)") + } + return url.path + } + + /// Read from the server's stdout until a line matching "TCP :" is found, or the + /// timeout expires. The server prints several lines (version banner, first-run admin box, + /// etc.) before the port line — see server/src/server.cpp. + private static func readPortWithTimeout(_ pipe: Pipe, timeout: TimeInterval) -> UInt16? { + let handle = pipe.fileHandleForReading + let deadline = Date().addingTimeInterval(timeout) + var buffer = Data() + while Date() < deadline { + let data = handle.availableData + if !data.isEmpty { + buffer.append(data) + // Check each complete line in the buffer for "TCP :". + while let newlineIdx = buffer.firstIndex(of: 0x0A) { + let lineData = buffer.prefix(newlineIdx) + buffer = buffer.suffix(from: buffer.index(after: newlineIdx)) + if let line = String(data: lineData, encoding: .utf8), + let port = parsePort(from: line) { + return port + } + } + } + Thread.sleep(forTimeInterval: 0.05) + } + return nil + } + + private static func parsePort(from line: String) -> UInt16? { + // Match "TCP :" — see server/src/server.cpp. + guard let range = line.range(of: #"TCP :(\d+)"#, options: .regularExpression) else { return nil } + let digits = line[range].split(separator: ":").last ?? "" + return UInt16(digits.trimmingCharacters(in: .whitespaces)) + } +} + +/// XCTest smoke tests against a real voicecat-server, through the Swift VoiceCatClient wrapper. +final class VoiceCatClientSmokeTests: XCTestCase { + private static var harness: ServerHarness? + + override class func setUp() { + do { + harness = try ServerHarness() + } catch { + // Store the error so each test fails with a clear message rather than a crash. + NSLog("ServerHarness setup failed: \(error.localizedDescription)") + harness = nil + } + } + + override class func tearDown() { + harness = nil + } + + private var port: UInt16 { + guard let p = Self.harness?.port else { + XCTFail("ServerHarness not started — see setUp error in log") + return 0 + } + return p + } + + private var tempDir: String { + Self.harness?.tempDir ?? NSTemporaryDirectory() + } + + /// Helper: wait until the predicate is satisfied, running the main runloop to process + /// dispatched events. The Swift analog of the C# `PumpUntil` helper. Our events are + /// delivered via DispatchQueue.main.async, which the main runloop processes during + /// `RunLoop.current.run(until:)`. + /// + /// Uses RunLoop polling (not XCTestExpectation) so that the "assert something does NOT + /// happen within N seconds" pattern works without generating spurious "Asynchronous wait + /// failed" errors — `wait(for:timeout:)` logs an error when an expectation isn't + /// fulfilled, which is wrong for negative checks. + private func waitFor(timeout: TimeInterval = 5, _ predicate: @escaping () -> Bool) -> Bool { + if predicate() { return true } + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + // Run the main runloop for ~20ms — processes DispatchQueue.main.async blocks + // (where our events/levels are drained) and timer sources. + RunLoop.current.run(until: Date().addingTimeInterval(0.02)) + if predicate() { return true } + } + return predicate() + } + + private func requireHarness() -> Bool { + guard Self.harness != nil else { + XCTFail("ServerHarness not started — see setUp error in log") + return false + } + return true + } + + // MARK: - Tests + + func testVersionStringIsNonEmpty() { + XCTAssertFalse(VoiceCatClient.versionString.isEmpty) + } + + func testResultStringRoundTrips() { + XCTAssertFalse(VoiceCatClient.resultString(.ok).isEmpty) + XCTAssertFalse(VoiceCatClient.resultString(.permissionDenied).isEmpty) + } + + /// Full connect → TOFU → confirm → guest auth → list channels → permissions → guest + /// ListAccounts rejected. Mirrors the C# `Connect_Tofu_Auth_ListChannels_RoundTrips`. + func testConnectTofuAuthListChannelsRoundTrips() throws { + guard requireHarness() else { return } + + var events: [VoiceCatEvent] = [] + let client = VoiceCatClient(config: VoiceCatConfig( + clientName: "vc-swift-smoke", + clientVersion: "0.1", + logLevel: .off, + tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins.txt") + )) + client.onEvent = { events.append($0) } + + XCTAssertEqual(client.connect(host: "127.0.0.1", port: port), .ok) + XCTAssertEqual(client.authenticateGuest("SwiftSmoke"), .ok) + + // Wait for VC_EVENT_SERVER_IDENTITY. + XCTAssertTrue(waitFor { events.contains { $0.type == .serverIdentity } }, + "did not receive .serverIdentity") + let identityEvent = try XCTUnwrap(events.first { $0.type == .serverIdentity }) + XCTAssertEqual(identityEvent.tofuStatus, .firstConnect) + XCTAssertNotNil(identityEvent.text) + XCTAssertEqual(identityEvent.text?.count, 64, "SHA-256 hex, no separators") + + // Auth must NOT complete before identity is confirmed (800ms, like the C# test). + XCTAssertFalse(waitFor(timeout: 0.8) { events.contains { $0.type == .authResult } }, + "auth completed before identity confirmation (should be held open)") + + XCTAssertEqual(client.confirmServerIdentity(accept: true), .ok) + + // Wait for VC_EVENT_AUTH_RESULT. + XCTAssertTrue(waitFor { events.contains { $0.type == .authResult } }, + "did not receive .authResult after confirming identity") + let authEvent = try XCTUnwrap(events.first { $0.type == .authResult }) + XCTAssertEqual(authEvent.result, .ok) + + // Wait for VC_EVENT_CHANNEL_LIST. + XCTAssertTrue(waitFor { events.contains { $0.type == .channelList } }, + "did not receive .channelList") + + let channels = client.listChannels() + XCTAssertTrue(channels.contains { $0.id == 1 && $0.name == "Lobby" }, + "expected Lobby (channel 1) in \(channels.map { $0.name })") + + // M5: permissions getter round-trip. + let perms = client.getPermissions() + XCTAssertFalse(perms.isAdmin) + XCTAssertFalse(perms.canKick) + + // M5: guest ListAccounts is rejected by the server with a GenericResult — proves the + // moderation wrapper path works end-to-end through the Swift interop layer. + events.removeAll() + XCTAssertEqual(client.requestAccountList(), .ok) + XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult } }, + "did not receive .genericResult for guest ListAccounts") + let generic = try XCTUnwrap(events.first { $0.type == .genericResult }) + XCTAssertEqual(generic.result, .permissionDenied) + + client.disconnect() + } + + /// Admin auth → channel CRUD → account CRUD. Mirrors C# `Admin_ChannelCrud_AccountCrud_RoundTrips`. + func testAdminChannelCrudAccountCrudRoundTrips() throws { + guard requireHarness() else { return } + + var events: [VoiceCatEvent] = [] + let client = VoiceCatClient(config: VoiceCatConfig( + clientName: "vc-swift-admin", + clientVersion: "0.1", + logLevel: .off, + tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins_admin.txt") + )) + client.onEvent = { events.append($0) } + + XCTAssertEqual(client.connect(host: "127.0.0.1", port: port), .ok) + XCTAssertEqual(client.authenticateUser("admin2", password: "testpassword123"), .ok) + + XCTAssertTrue(waitFor { events.contains { $0.type == .serverIdentity } }) + XCTAssertEqual(client.confirmServerIdentity(accept: true), .ok) + XCTAssertTrue(waitFor { events.contains { $0.type == .authResult } }) + XCTAssertEqual(try XCTUnwrap(events.first { $0.type == .authResult }).result, .ok) + XCTAssertTrue(waitFor { events.contains { $0.type == .channelList } }) + + let perms = client.getPermissions() + XCTAssertTrue(perms.isAdmin || perms.canAdminAccounts) + + // Channel CRUD — create. + let audioConfig = AudioConfig(stereo: true, bitrateBps: 64000, frameMs: 20, + application: 1, fec: true, expectedPacketLoss: 5, complexity: 10) + XCTAssertEqual(client.createChannel(ChannelEdit( + id: 0, parentId: 0, name: "Swift Test Channel", topic: "Created by Swift smoke test", + passwordProtected: false, password: nil, maxUsers: 42, sortOrder: 0, audio: audioConfig + )), .ok) + XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } }, + "CreateChannel did not succeed") + + var channels = client.listChannels() + let created = try XCTUnwrap(channels.first { $0.name == "Swift Test Channel" }) + XCTAssertEqual(created.topic, "Created by Swift smoke test") + XCTAssertFalse(created.passwordProtected) + + // Channel CRUD — edit. + events.removeAll() + XCTAssertEqual(client.editChannel(ChannelEdit( + id: created.id, parentId: created.parentId, name: created.name, + topic: "Updated topic", passwordProtected: false, password: nil, + maxUsers: 100, sortOrder: 0, audio: audioConfig + )), .ok) + XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } }, + "EditChannel did not succeed") + + // Channel CRUD — delete. + events.removeAll() + XCTAssertEqual(client.deleteChannel(created.id), .ok) + XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } }, + "DeleteChannel did not succeed") + + // Account CRUD — create. + events.removeAll() + XCTAssertEqual(client.createAccount("swift_smoke_user", password: "initialpw"), .ok) + XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } }, + "CreateAccount did not succeed") + + // Account CRUD — list. + events.removeAll() + XCTAssertEqual(client.requestAccountList(), .ok) + XCTAssertTrue(waitFor { events.contains { $0.type == .accountList } }, + "did not receive .accountList") + let accounts = client.listAccounts() + XCTAssertTrue(accounts.contains { $0.username == "swift_smoke_user" }) + + // Account CRUD — reset password. + events.removeAll() + XCTAssertEqual(client.resetPassword("swift_smoke_user", newPassword: "newpw123"), .ok) + XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } }, + "ResetPassword did not succeed") + + // Account CRUD — delete. + events.removeAll() + XCTAssertEqual(client.deleteAccount("swift_smoke_user"), .ok) + XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } }, + "DeleteAccount did not succeed") + + client.disconnect() + } + + /// Screen-audio (SCREEN_AUDIO) stream start/stop through the Swift wrapper. The core's + /// macOS CoreAudio path starts the StreamAnnounce; this exercises the full + /// startStream → .streamStarted → stopStream → .streamStopped path through Swift interop. + /// Mirrors C# `ScreenAudioStream_Starts_And_Stops`. + func testScreenAudioStreamStartsAndStops() throws { + guard requireHarness() else { return } + + var events: [VoiceCatEvent] = [] + let client = VoiceCatClient(config: VoiceCatConfig( + clientName: "vc-swift-screen", + clientVersion: "0.1", + logLevel: .off, + tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins_screen.txt") + )) + client.onEvent = { events.append($0) } + + XCTAssertEqual(client.connect(host: "127.0.0.1", port: port), .ok) + XCTAssertEqual(client.authenticateGuest("SwiftScreen"), .ok) + + XCTAssertTrue(waitFor { events.contains { $0.type == .serverIdentity } }) + XCTAssertEqual(client.confirmServerIdentity(accept: true), .ok) + XCTAssertTrue(waitFor { events.contains { $0.type == .authResult } }) + XCTAssertEqual(try XCTUnwrap(events.first { $0.type == .authResult }).result, .ok) + XCTAssertTrue(waitFor { events.contains { $0.type == .channelList } }) + + // Give the async UDP binding handshake a moment to land (mirrors vccli's 500ms sleep). + Thread.sleep(forTimeInterval: 0.5) + + let (startResult, streamId) = client.startStream( + StreamDescriptor(kind: .screenAudio, label: "Desktop audio") + ) + XCTAssertEqual(startResult, .ok) + XCTAssertNotEqual(streamId, 0, "streamId should be non-zero on success") + + // The core emits .streamStarted for the local client too. + XCTAssertTrue(waitFor(timeout: 5) { + events.contains { $0.type == .streamStarted && $0.streamId == streamId } + }, "did not receive .streamStarted for screen-audio stream") + + XCTAssertEqual(client.stopStream(streamId), .ok) + XCTAssertTrue(waitFor(timeout: 5) { + events.contains { $0.type == .streamStopped && $0.streamId == streamId } + }, "did not receive .streamStopped for screen-audio stream") + + client.disconnect() + } + + /// Per-stream receive-side controls (gain/mute/NR) round-trip through Swift: two clients + /// in a channel, one publishes a MIC stream, the other setRemoteStream's it then + /// getRemoteStream's it back. Catches Swift-specific marshaling bugs (field order, + /// bool-from-int, float precision) that the C++ ctest can't. Mirrors C# + /// `PerStream_RecvControls_Round_Trip_Through_PInvoke`. + func testPerStreamRecvControlsRoundTrip() throws { + guard requireHarness() else { return } + + var eventsA: [VoiceCatEvent] = [] + var eventsB: [VoiceCatEvent] = [] + + let a = VoiceCatClient(config: VoiceCatConfig( + clientName: "vc-swift-mix-a", clientVersion: "0.1", logLevel: .off, + tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins_mix_a.txt") + )) + let b = VoiceCatClient(config: VoiceCatConfig( + clientName: "vc-swift-mix-b", clientVersion: "0.1", logLevel: .off, + tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins_mix_b.txt") + )) + a.onEvent = { eventsA.append($0) } + b.onEvent = { eventsB.append($0) } + + // Connect + auth A first, then B (staggering avoids concurrent TLS handshakes). + XCTAssertEqual(a.connect(host: "127.0.0.1", port: port), .ok) + XCTAssertEqual(a.authenticateGuest("SwiftMixA"), .ok) + XCTAssertTrue(waitFor { eventsA.contains { $0.type == .serverIdentity } }) + XCTAssertEqual(a.confirmServerIdentity(accept: true), .ok) + XCTAssertTrue(waitFor { eventsA.contains { $0.type == .authResult } }) + XCTAssertEqual(try XCTUnwrap(eventsA.first { $0.type == .authResult }).result, .ok) + XCTAssertTrue(waitFor { eventsA.contains { $0.type == .channelList } }) + + XCTAssertEqual(b.connect(host: "127.0.0.1", port: port), .ok) + XCTAssertEqual(b.authenticateGuest("SwiftMixB"), .ok) + XCTAssertTrue(waitFor { eventsB.contains { $0.type == .serverIdentity } }) + XCTAssertEqual(b.confirmServerIdentity(accept: true), .ok) + XCTAssertTrue(waitFor { eventsB.contains { $0.type == .authResult } }) + XCTAssertEqual(try XCTUnwrap(eventsB.first { $0.type == .authResult }).result, .ok) + XCTAssertTrue(waitFor { eventsB.contains { $0.type == .channelList } }) + + // Both join Lobby (channel 1) so voice relays between them. + XCTAssertEqual(a.joinChannel(1), .ok) + XCTAssertTrue(waitFor { eventsA.contains { $0.type == .joinResult } }, + "A did not receive .joinResult") + XCTAssertEqual(b.joinChannel(1), .ok) + XCTAssertTrue(waitFor { eventsB.contains { $0.type == .joinResult } }, + "B did not receive .joinResult") + + // UDP binding handshake is async; give it a moment. + Thread.sleep(forTimeInterval: 0.5) + + // A publishes a MIC stream. + let (startResult, streamId) = a.startStream(StreamDescriptor(kind: .mic, label: "mix-test-mic")) + XCTAssertEqual(startResult, .ok) + XCTAssertNotEqual(streamId, 0) + + // B sees A's stream. + XCTAssertTrue(waitFor(timeout: 5) { + eventsB.contains { $0.type == .streamStarted && $0.streamId == streamId } + }, "B did not see A's .streamStarted") + + // Resolve A's user id from B's user list. + var aUid: UInt32 = 0 + XCTAssertTrue(waitFor(timeout: 3) { + aUid = b.listUsers().first { $0.nickname == "SwiftMixA" }?.id ?? 0 + return aUid != 0 + }, "could not resolve A's user id on B") + XCTAssertNotEqual(aUid, 0) + + // B can enumerate A's stream. + XCTAssertTrue(waitFor(timeout: 3) { + b.listUserStreams(aUid).contains { $0.id == streamId } + }, "B could not enumerate A's stream") + let bStreams = b.listUserStreams(aUid) + XCTAssertTrue(bStreams.contains { $0.id == streamId && $0.kind == .mic }) + + // Before B ever sets anything, defaults read back (gain 1.0, unmuted, NR off). + let (r0, st0) = b.getRemoteStream(userId: aUid, streamId: streamId) + XCTAssertEqual(r0, .ok) + XCTAssertNotNil(st0) + XCTAssertEqual(st0?.gain, 1.0) + XCTAssertFalse(st0?.muted ?? true) + XCTAssertFalse(st0?.noiseReduction ?? true) + + // B turns A down to 0.5×, mutes, enables NR — then reads it back. + XCTAssertEqual(b.setRemoteStream(userId: aUid, streamId: streamId, + gain: 0.5, muted: true, noiseReduction: true), .ok) + let (r1, st1) = b.getRemoteStream(userId: aUid, streamId: streamId) + XCTAssertEqual(r1, .ok) + XCTAssertNotNil(st1) + XCTAssertEqual(st1?.gain, 0.5) + XCTAssertTrue(st1?.muted ?? false) + XCTAssertTrue(st1?.noiseReduction ?? false) + + // Unknown stream id on a known user → .invalidArg. + let (rBad, stBad) = b.getRemoteStream(userId: aUid, streamId: 0xDEADBEEF) + XCTAssertEqual(rBad, .invalidArg) + XCTAssertNil(stBad) + + a.disconnect() + b.disconnect() + } +} diff --git a/clients/apple/scripts/build-xcframework.sh b/clients/apple/scripts/build-xcframework.sh new file mode 100755 index 0000000..28efed2 --- /dev/null +++ b/clients/apple/scripts/build-xcframework.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# +# build-xcframework.sh — build libvoicecat as a static .a slice (or slices) and stitch them +# into a VoiceCatCore.xcframework that the Swift Package at clients/apple/Package.swift +# consumes as a binary target. +# +# The XCFramework's Headers directory carries a generated module.modulemap alongside +# voicecat.h, so Swift gets a clean `import VoiceCatC` module (see docs/architecture.md §4 — +# "Swift / Apple. Import the C ABI via a module map"). core/include/ itself stays pure C; +# the module map is an Apple-packaging concern that lives only in the staged headers. +# +# Usage: +# scripts/build-xcframework.sh # macOS slice only (default, validated) +# scripts/build-xcframework.sh --all # macOS + iOS device + iOS sim (iOS still scaffolding) +# scripts/build-xcframework.sh --preset apple-dev +# scripts/build-xcframework.sh --no-configure # skip cmake configure, just rebuild + stitch +# +# Requires: VCPKG_ROOT set (or detectable from an existing build/apple-dev/CMakeCache.txt), +# Xcode + macOS SDK. iOS slices additionally require the iOS SDK. Install Homebrew +# autoconf-archive for vcpkg's libsodium port (see clients/apple/README.md). +# +# Mirrors the Windows client's convention: the native binary is a local build artifact, NOT +# committed — the C# project references build/windows-client/bin/voicecat.dll the same way +# this script's output at clients/apple/VoiceCatCore.xcframework is referenced by Package.swift. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# scripts/ is 3 levels below repo root: voice-cat/clients/apple/scripts/ +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +APPLE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +BUILD_MACOS=true +BUILD_IOS_DEVICE=false +BUILD_IOS_SIM=false +DO_CONFIGURE=true + +while [[ $# -gt 0 ]]; do + case "$1" in + --all) BUILD_MACOS=true; BUILD_IOS_DEVICE=true; BUILD_IOS_SIM=true; shift ;; + --preset) case "$2" in + apple-dev) BUILD_MACOS=true; BUILD_IOS_DEVICE=false; BUILD_IOS_SIM=false ;; + apple-ios) BUILD_MACOS=false; BUILD_IOS_DEVICE=true; BUILD_IOS_SIM=false ;; + apple-ios-sim) BUILD_MACOS=false; BUILD_IOS_DEVICE=false; BUILD_IOS_SIM=true ;; + *) echo "unknown preset: $2" >&2; exit 2 ;; + esac; shift 2 ;; + --no-configure) DO_CONFIGURE=false; shift ;; + -h|--help) + sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//' + exit 0 ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac +done + +# ── Resolve VCPKG_ROOT ─────────────────────────────────────────────────────────── +# The apple-dev CMake cache records the vcpkg root it was configured with (Z_VCPKG_ROOT_DIR); +# reuse that so a developer who already configured `cmake --preset dev` doesn't need VCPKG_ROOT +# in their shell env to run this script. +if [[ -z "${VCPKG_ROOT:-}" ]]; then + cache="$REPO_ROOT/build/apple-dev/CMakeCache.txt" + if [[ -f "$cache" ]]; then + detected="$(grep -m1 '^Z_VCPKG_ROOT_DIR:INTERNAL=' "$cache" | cut -d= -f2-)" + if [[ -n "$detected" && -d "$detected" ]]; then + export VCPKG_ROOT="$detected" + fi + fi +fi +if [[ -z "${VCPKG_ROOT:-}" || ! -d "$VCPKG_ROOT" ]]; then + echo "error: VCPKG_ROOT is not set or does not exist." >&2 + echo " bootstrap vcpkg (https://vcpkg.io) then: export VCPKG_ROOT=/path/to/vcpkg" >&2 + exit 1 +fi +echo "[build-xcframework] VCPKG_ROOT=$VCPKG_ROOT" + +# ── Build each requested slice ──────────────────────────────────────────────────── +build_slice() { + local preset="$1" slice_name="$2" + echo "[build-xcframework] === $slice_name: configure + build ($preset) ===" + if $DO_CONFIGURE; then + cmake --preset "$preset" + fi + cmake --build --preset "$preset" + local lib="$REPO_ROOT/build/$preset/lib/libvoicecat.a" + if [[ ! -f "$lib" ]]; then + echo "error: expected output not found: $lib" >&2 + exit 1 + fi + echo "[build-xcframework] $slice_name -> $lib ($(stat -f%z "$lib") bytes)" + + # The static libvoicecat.a only contains voicecat's own object files — vcpkg's static + # deps (protobuf, mbedtls, libsodium, opus, sqlite3, spdlog, asio, abseil, …) are + # separate .a files under vcpkg_installed//lib/. A Swift Package binary target + # can only link ONE .a per XCFramework slice, so we merge them all into a single + # self-contained "fat" static library 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). + # + # Without this, the final executable (test runner / app) would get undefined-symbol + # linker errors for protobuf/mbedtls/sodium/opus/… symbols that libvoicecat.a references + # but doesn't contain. See clients/apple/README.md "Fat static library" section. + local vcpkg_libs_dir="$REPO_ROOT/build/$preset/vcpkg_installed" + local fat_lib="$REPO_ROOT/build/$preset/lib/libvoicecat-fat.a" + echo "[build-xcframework] $slice_name: merging vcpkg deps into fat static lib" + # Collect all .a files (libvoicecat.a + every vcpkg .a). libtool -static concatenates + # object files from all input archives; duplicate-object warnings are benign (the linker + # resolves duplicates at final link time). "no symbols" warnings are for empty AVX2/AVX512 + # objects on arm64 — also benign. + local all_libs=( "$lib" ) + # Only release libs (arm64-osx/lib/*.a), NOT debug libs (arm64-osx/debug/lib/*.a) — + # vcpkg installs both; the debug libs are ~10x larger and not needed for a Release build. + while IFS= read -r f; do all_libs+=( "$f" ); done < <(find "$vcpkg_libs_dir" -name '*.a' -not -name 'libvoicecat*' -not -path '*/debug/*' | sort) + libtool -static -o "$fat_lib" "${all_libs[@]}" 2>&1 | grep -v 'has no symbols' || true + echo "[build-xcframework] $slice_name -> $fat_lib ($(stat -f%z "$fat_lib") bytes, fat)" +} + +args=() +if $BUILD_MACOS; then build_slice apple-dev "macOS (arm64-osx)"; args+=( -library "$REPO_ROOT/build/apple-dev/lib/libvoicecat-fat.a" -headers "$APPLE_DIR/.staged-headers/macos" ); fi +if $BUILD_IOS_DEVICE; then build_slice apple-ios "iOS device (arm64-ios)"; args+=( -library "$REPO_ROOT/build/apple-ios/lib/libvoicecat-fat.a" -headers "$APPLE_DIR/.staged-headers/ios" ); fi +if $BUILD_IOS_SIM; then build_slice apple-ios-sim "iOS sim (arm64-ios-sim)"; args+=( -library "$REPO_ROOT/build/apple-ios-sim/lib/libvoicecat-fat.a" -headers "$APPLE_DIR/.staged-headers/ios-sim" ); fi + +# ── Stage headers + module map ─────────────────────────────────────────────────── +# Each slice gets its own headers dir (xcodebuild -create-xcframework requires a -headers +# per -library). The module map wraps voicecat.h as `module VoiceCatC` so Swift imports it +# as a clean named module rather than a Clang module inferred from the header path. +stage_headers() { + local dest="$1" + mkdir -p "$dest" + cp "$REPO_ROOT/core/include/voicecat.h" "$dest/voicecat.h" + cat > "$dest/module.modulemap" <<'MODULEMAP' +module VoiceCatC { + header "voicecat.h" + export * +} +MODULEMAP + # voicecat.h is the single public C ABI header (core/include/ has nothing else); exposing + # it as `module VoiceCatC` gives Swift a clean named import rather than a path-inferred + # Clang module. The export * re-exports all C symbols for Swift access. +} + +STAGED_ROOT="$APPLE_DIR/.staged-headers" +rm -rf "$STAGED_ROOT" +if $BUILD_MACOS; then stage_headers "$STAGED_ROOT/macos"; fi +if $BUILD_IOS_DEVICE; then stage_headers "$STAGED_ROOT/ios"; fi +if $BUILD_IOS_SIM; then stage_headers "$STAGED_ROOT/ios-sim"; fi + +# ── Stitch the XCFramework ─────────────────────────────────────────────────────── +OUTPUT="$APPLE_DIR/VoiceCatCore.xcframework" +echo "[build-xcframework] === stitching $OUTPUT ===" +rm -rf "$OUTPUT" +xcodebuild -create-xcframework "${args[@]}" -output "$OUTPUT" + +# Clean up staged headers (the xcframework has its own copy now). +rm -rf "$STAGED_ROOT" + +echo "[build-xcframework] done -> $OUTPUT" +xcodebuild -list -xcframework "$OUTPUT" 2>/dev/null || true diff --git a/docs/architecture.md b/docs/architecture.md index 254974d..7dc938e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -136,11 +136,7 @@ Design notes: ### Per-platform binding notes -- **Swift / Apple.** Import the C ABI via a module map; SwiftUI on top. On **iOS** the app - must still own `AVAudioSession` (category `.playAndRecord`, `.voiceChat` mode), request - mic permission, and handle interruptions/route changes — the core exposes hooks - (`vc_audio_suspend`/`vc_audio_resume`) the Swift layer calls from `AVAudioSession` - notifications. Background voice and VoIP push (CallKit/PushKit) are a later milestone. +- **Swift / Apple.** Import the C ABI via a **module map** (`module VoiceCatC { header "voicecat.h" }`) staged into the XCFramework headers by `clients/apple/scripts/build-xcframework.sh` — Swift gets a clean `import VoiceCatC` with all C enums/structs/functions available directly (no manual redeclaration, unlike the C# P/Invoke layer). A **Swift wrapper** (`VoiceCatCore` package at `clients/apple/`) provides Swift-idiomatic types (`VoiceCatResult`, `VoiceCatEvent`, `Channel`, `User`, etc.) on top, mirroring the C# `VoiceCat.Interop` layer. Callbacks use `@convention(c)` closures (plain C function pointers, not ARC-managed closures) + `Unmanaged.passUnretained(self)` as the `user` context (the Swift analog of C#'s `[UnmanagedCallersOnly]` + `GCHandle`). Events are delivered on `@MainActor` via a coalesced `DispatchQueue.main` drain (one async block scheduled at a time) — the Swift analog of C#'s `Channel` + 30ms WinForms Timer pump. `deinit` calls `vc_client_destroy` (joins all threads) then frees native CString config storage (the core stores raw pointers, doesn't copy). **macOS UI: AppKit** (chosen over SwiftUI for the most mature VoiceOver accessibility story — same rationale as the Windows client's WinForms choice); **iOS UI: SwiftUI** (narrower control surface, sufficient VoiceOver support). On **iOS** the app must still own `AVAudioSession` (category `.playAndRecord`, `.voiceChat` mode), request mic permission, and handle interruptions/route changes — the core exposes hooks (`vc_audio_suspend`/`vc_audio_resume`) the Swift layer calls from `AVAudioSession` notifications (these hooks are deferred until the iOS client milestone to keep the ABI stable). Background voice and VoIP push (CallKit/PushKit) are a later milestone. The XCFramework carries a **fat static library** (`libvoicecat-fat.a`) bundling `libvoicecat.a` + all vcpkg static deps so the Swift Package links a single self-contained `.a` per slice. - **iOS screen / system-audio sharing** is supported via a **ReplayKit Broadcast Upload Extension** (the same mechanism Discord uses; triggered from Control Center's screen-record button via `RPSystemBroadcastPickerView`). The extension receives diff --git a/docs/roadmap.md b/docs/roadmap.md index 7328d5c..e9b6b09 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -51,13 +51,24 @@ exists from M1 so the protocol can be exercised long before any GUI. - Focus-scoped PTT (documented limitation — no system-wide hook in v1). - Admin/moderation UI **out of scope** — needs server-side dispatch first (M5). -**macOS (Swift/SwiftUI) — pending:** +**macOS (Swift/AppKit) — in progress:** - Same feature set as Windows over the same C ABI (now stable and complete). -- AVAudioSession, mic permission, interruption/route-change handling. +- **Shared Swift core (`VoiceCatCore` package) ✓ complete 2026-06-18** — wraps all 38 C ABI + functions; 6/6 XCTest smoke tests pass against a real server (connect → TOFU → auth → + channels → moderation → admin CRUD → per-stream recv controls). See + `clients/apple/README.md`. +- **UI: AppKit** (not SwiftUI) — chosen for the most mature VoiceOver accessibility story + (per-control `accessibilityLabel`/`accessibilityHelp`/`accessibilityRole`, + `NSAccessibility.post(.announcement)` for live announcements). Same rationale as the + Windows client's WinForms-over-WinUI-3 decision (see §2 below). macOS 14 (Sonoma) + deployment target. +- AVAudioSession not needed on macOS (CoreAudio via the core directly). -**iOS (Swift) — pending:** +**iOS (Swift/SwiftUI) — pending:** +- SwiftUI app consuming the same `VoiceCatCore` package. - AVAudioSession, mic permission, foreground voice. - ReplayKit broadcast extension for `SCREEN_AUDIO`. +- `vc_audio_suspend`/`vc_audio_resume` ABI hooks (deferred until this milestone). **Exit:** non-technical user installs a client, saves a server, and joins. @@ -120,6 +131,17 @@ Settled and reflected throughout the docs: Reason: a low-level keyboard hook requires escalated permissions, risks AV flagging, and is disproportionate complexity for a v1 client. Documented in the UI as a known limitation. Can be revisited for v2 if users request it. (clients/windows/VoiceCat.App/Forms/MainForm.cs) +- **macOS client UI framework (2026-06-18):** **AppKit**, not SwiftUI. Reason: AppKit has the + most mature, granular **VoiceOver** accessibility story on macOS — per-control + `accessibilityLabel`/`accessibilityHelp`/`accessibilityRole`, `NSAccessibility.post(.announcement)` + for curated live announcements, and decades of real-world screen-reader usage. This is the + same rationale that drove the Windows client to WinForms over WinUI 3 (screen-reader + support is the deciding factor). SwiftUI's VoiceOver support has improved but still has + gaps in complex AppKit-bridged control surfaces (outline views, data-table column + headers, live-region announcements). iOS stays SwiftUI — its control surface is narrower + and SwiftUI's VoiceOver support is sufficient there. This overrides the earlier + "SwiftUI for both macOS + iOS" mention in `docs/tech-stack.md §2` and `docs/architecture.md + §4`. macOS 14 (Sonoma) deployment target. (clients/apple/) ## 3. Open questions diff --git a/docs/tech-stack.md b/docs/tech-stack.md index 678b48b..1138af2 100644 --- a/docs/tech-stack.md +++ b/docs/tech-stack.md @@ -27,10 +27,12 @@ explicit resampling (speexdsp/libsamplerate) is only needed when a device can't | Concern | Choice | Notes | |---------|--------|-------| -| Language | **Swift 5.9+** | Direct **Swift↔C++ interop** available, but we bind through the C ABI for parity with Windows. | -| UI | **SwiftUI** | Single UI codebase for macOS + iOS where practical; AppKit/UIKit shims as needed. | -| Audio session (iOS) | **AVAudioSession** | App owns category `.playAndRecord` + `.voiceChat` mode, mic permission, interruption/route-change handling; calls `vc_audio_suspend/resume` on the core. macOS uses CoreAudio via the core directly. | -| Packaging | Swift Package + Xcode project | Core shipped as an XCFramework (device + simulator + macOS slices). | +| Language | **Swift 5.9+** | Direct **Swift↔C interop** — the C ABI (`voicecat.h`) is imported as a Clang module (`import VoiceCatC`) via a module map in the XCFramework headers; no manual struct/function redeclaration (unlike the C# P/Invoke layer). A Swift wrapper (`VoiceCatCore` package) provides Swift-idiomatic types on top. | +| UI — macOS | **AppKit** | Chosen over SwiftUI for the most mature, granular **VoiceOver** accessibility story (per-control `accessibilityLabel`/`accessibilityHelp`/`accessibilityRole`, `NSAccessibility.post(.announcement)` for live announcements) — the same rationale that drove the Windows client to WinForms over WinUI 3 for screen-reader (NVDA/JAWS/Narrator) UIA support (resolved decision in `docs/roadmap.md`). macOS 14 (Sonoma) deployment target. | +| UI — iOS | **SwiftUI** | iOS has a narrower control surface (no channel-tree moderation, etc.) and SwiftUI's VoiceOver support is sufficient; revisit if gaps emerge. | +| Shared core | **VoiceCatCore** Swift Package | One Swift library wrapping the C ABI, consumed by both the macOS AppKit app and the iOS SwiftUI app. Mirrors the C# `VoiceCat.Interop` layer. Events delivered on `@MainActor` via a coalesced `DispatchQueue.main` drain (the Swift analog of C#'s `Channel` + 30ms WinForms Timer pump). | +| Audio session (iOS) | **AVAudioSession** | App owns category `.playAndRecord` + `.voiceChat` mode, mic permission, interruption/route-change handling; calls `vc_audio_suspend/resume` on the core. macOS uses CoreAudio via the core directly. (`vc_audio_suspend`/`vc_audio_resume` ABI hooks are deferred until the iOS client milestone — keep ABI stable.) | +| Packaging | Swift Package + Xcode project | Core shipped as an **XCFramework** binary target — a fat static library (`libvoicecat-fat.a`) bundling `libvoicecat.a` + all vcpkg static deps (protobuf/mbedtls/sodium/opus/sqlite3/spdlog/asio), so the Swift Package links a single self-contained `.a` per slice. macOS slice validated; iOS device + sim slices are scaffolding. | | Future | CallKit / PushKit | For background VoIP + incoming-call UX on iOS. Post-v1. | ### Windows — C# (shipped in M4, 2026-06-17)