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:
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user