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