feat(apple): VoiceCatCore Swift package + XCFramework build for macOS/iOS clients

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.
This commit is contained in:
2026-06-18 14:20:38 +02:00
parent b2af1a3001
commit b4766d2f24
16 changed files with 2019 additions and 61 deletions

View File

@@ -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++"),
]
),
]
)

View File

@@ -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<VoiceCatEvent>` + 30ms WinForms Timer pump. `ev.text` is copied to `String`
inside the callback before enqueueing (dangling-pointer rule).
- **Level meters:** coalesced to latest-per-stream-id (intermediate values are visually
irrelevant, same as C#'s `ConcurrentDictionary<uint,float>`).
- **Immediate `vc_free_*`** on list reads — callers never manage native list lifetime.
**`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.

View File

@@ -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<vc_event>?
) -> Void = { user, ev in
guard let user, let ev else { return }
let client = Unmanaged<VoiceCatClient>.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<VoiceCatClient>.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)
}
}

View File

@@ -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
}
}

View File

@@ -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) }
}

View File

@@ -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
)
}
}

View File

@@ -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<CChar>?) -> 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..<list.count {
let d = items.advanced(by: i).pointee
result.append(Device(id: string(d.id), name: string(d.name), isDefault: d.is_default != 0))
}
vc_free_device_list(&list)
return result
}
static func channels(_ list: inout vc_channel_list) -> [Channel] {
guard let items = list.items else { vc_free_channel_list(&list); return [] }
var result: [Channel] = []
result.reserveCapacity(list.count)
for i in 0..<list.count {
let c = items.advanced(by: i).pointee
result.append(Channel(id: c.id, parentId: c.parent_id, name: string(c.name),
topic: string(c.topic), passwordProtected: c.password_protected != 0,
maxUsers: c.max_users))
}
vc_free_channel_list(&list)
return result
}
static func users(_ list: inout vc_user_list) -> [User] {
guard let items = list.items else { vc_free_user_list(&list); return [] }
var result: [User] = []
result.reserveCapacity(list.count)
for i in 0..<list.count {
let u = items.advanced(by: i).pointee
result.append(User(id: u.id, nickname: string(u.nickname), isGuest: u.is_guest != 0,
channelId: u.channel_id, selfMicMuted: u.self_mic_muted != 0,
selfDeafened: u.self_deafened != 0, serverMuted: u.server_muted != 0,
serverDeafened: u.server_deafened != 0))
}
vc_free_user_list(&list)
return result
}
static func streamSummaries(_ list: inout vc_stream_summary_list) -> [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..<list.count {
let s = items.advanced(by: i).pointee
result.append(StreamSummary(streamId: s.stream_id, kind: VoiceCatStreamKind(s.kind),
label: string(s.label)))
}
vc_free_stream_summary_list(&list)
return result
}
static func accounts(_ list: inout vc_account_list) -> [Account] {
guard let items = list.items else { vc_free_account_list(&list); return [] }
var result: [Account] = []
result.reserveCapacity(list.count)
for i in 0..<list.count {
let a = items.advanced(by: i).pointee
result.append(Account(username: string(a.username), isAdmin: a.is_admin != 0,
createdAtUnixMs: a.created_at_unix_ms,
lastLoginUnixMs: a.last_login_unix_ms))
}
vc_free_account_list(&list)
return result
}
static func remoteStreamState(_ s: vc_remote_stream_state) -> 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)
}
}

View File

@@ -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
}
}

View File

@@ -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<VoiceCatEvent>` 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<uint,float> 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<CChar>?
private var clientVersionPtr: UnsafeMutablePointer<CChar>?
private var tofuStorePathPtr: UnsafeMutablePointer<CChar>?
// 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<CChar>.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.01.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
}
}

View File

@@ -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 SwiftC 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 :<port> UDP :<port>" 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 :<port>". 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 :<port>" 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 :<port>".
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 :<port>" 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()
}
}

View File

@@ -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/<triplet>/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