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:
59
clients/apple/Package.swift
Normal file
59
clients/apple/Package.swift
Normal 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++"),
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -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.
|
||||
|
||||
51
clients/apple/Sources/VoiceCatCore/Callbacks.swift
Normal file
51
clients/apple/Sources/VoiceCatCore/Callbacks.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
30
clients/apple/Sources/VoiceCatCore/Config.swift
Normal file
30
clients/apple/Sources/VoiceCatCore/Config.swift
Normal 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
|
||||
}
|
||||
}
|
||||
155
clients/apple/Sources/VoiceCatCore/Enums.swift
Normal file
155
clients/apple/Sources/VoiceCatCore/Enums.swift
Normal 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) }
|
||||
}
|
||||
61
clients/apple/Sources/VoiceCatCore/Event.swift
Normal file
61
clients/apple/Sources/VoiceCatCore/Event.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
107
clients/apple/Sources/VoiceCatCore/Marshaling.swift
Normal file
107
clients/apple/Sources/VoiceCatCore/Marshaling.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
190
clients/apple/Sources/VoiceCatCore/Models.swift
Normal file
190
clients/apple/Sources/VoiceCatCore/Models.swift
Normal 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
|
||||
}
|
||||
}
|
||||
503
clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift
Normal file
503
clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift
Normal 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.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
|
||||
}
|
||||
}
|
||||
@@ -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 :<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()
|
||||
}
|
||||
}
|
||||
155
clients/apple/scripts/build-xcframework.sh
Executable file
155
clients/apple/scripts/build-xcframework.sh
Executable 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
|
||||
Reference in New Issue
Block a user