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
|
|
|
// 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,
|
feat(clients): expose all channel codec params + guest nickname everywhere
Channel create/edit UIs only surfaced a subset of the core's vc_audio_config,
and DRED was exposed nowhere. While adding it, found a latent ABI mismatch:
both Swift AudioConfig and the C# VcAudioConfigNative blittable struct were one
int short of the native vc_audio_config (missing the trailing `dred`), so native
read past the managed struct in vc_create_channel/vc_edit_channel.
- core marshaling: thread `dred` through Swift (Models/Marshaling/toNative) and
C# (Structs/Models/Marshaling/VoiceCatClient) -- fixes the ABI gap + enables it
- windows: add the one missing DRED checkbox to ChannelEditDialog
- macos: ChannelEditSheet now exposes application, sample rate, packet loss,
complexity, and DRED (was stereo/bitrate/frame/FEC/DTX only)
- ios: rebuild ChannelEditView into a full create+edit form (all params); add
SessionState.editChannel + an admin Edit swipe action (iOS had no edit UI)
- guest nickname: add a dedicated `nickname` to SavedServer on macOS+iOS
(backward-compatible Codable), shown in Guest mode, wired into the guest auth
path -- guests could not set a display name on either before (only Windows)
Verified: macOS + iOS (sim, arm64) xcodebuild BUILD SUCCEEDED; core ctest 22/23
(only external_pcm aborts on a pre-existing shutdown mutex race; no C++ changed).
2026-06-21 04:03:50 +02:00
|
|
|
dtx: c.dtx != 0, complexity: c.complexity, dred: c.dred != 0)
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|