Files
voice-cat/clients/apple/Sources/VoiceCatCore/Event.swift

62 lines
2.9 KiB
Swift
Raw Permalink Normal View History

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.
2026-06-18 14:20:38 +02:00
// 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
)
}
}