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