Mirrors the Windows client's UI overhaul (commit97fa659+540ec13) adapted to Mac-native conventions. The main window is now just toolbar + channels + users + chat; audio device settings moved to a modeless Settings window. - NSToolbar: Join Voice, Share Screen Audio, Mute, Deafen (SF Symbol toggle buttons) + Output Volume slider (NSSlider 0-100, default 80). Voice actions, mute/deafen, and output volume moved out of the bottom panel into the toolbar - Audio device settings (input mode, VAD sensitivity, PTT key, device picker, level meter) moved to a new SettingsWindowController -- a modeless window opened via the app menu's "Settings..." (Cmd+,) item. Source-of-truth for audio state lives in MainWindowController so voice start applies settings even before the window has been opened; SettingsWindowController reads from / writes back to those properties and applies changes live when voice is active. Level meter forwarded from handleLevel -> updateLevel(rms:) - Unified log: chat NSTextView + activity NSTableView collapsed into a single NSTextView -- activity events in secondaryLabelColor (gray), chat in default - Private messaging: scope dropdown removed; compose always sends to the current channel. Each PM conversation opens in its own modeless PrivateMessageWindowController. Incoming .textMessage with .private scope routed to the right window; outgoing PMs echoed by server arrive through the same path. "Send Private Message..." added to user context menu. - Messages menu: "New Private Message..." (Cmd+Shift+N) opens a UserPickerSheet listing all server users so you can PM anyone on the server - Channel tree now shows live user counts, e.g. "General (3)"; refreshChannelTree called on .userJoined/.userLeft (was missing) - Voice menu: Join Voice (Cmd+Shift+V), Share Screen Audio (Cmd+Shift+S), Mute (Cmd+Shift+M), Deafen (Cmd+Shift+D) -- NSMenuItem key equivalents with [.command, .shift] mask, dispatched by the responder chain - setOutputVolume(_:) wrapper added to VoiceCatClient.swift (was missing -- the C ABI + C# wrapper shipped in commit97fa659but the Swift wrapper was never added); wired end-to-end: toolbar slider -> client.setOutputVolume(gain) Part A -- fixed and verified the previously-uncompiled Swift from the external PCM feed/tap commit (615d2a8): - Rebuilt the macOS xcframework slice (regenerated the module map from current voicecat.h, exposing vc_pcm_sink_cb / vc_stream_feed_pcm / vc_set_pcm_sink) - Fixed feedPcm type bug: size_t imports as Int in Swift not UInt; the original UInt(samplesPerChannel) was wrong - Added VoiceCatPcmSinkCallback typealias -- a Swift-idiomatic public alias for the C vc_pcm_sink_cb so consumers (tests, the macOS app) can declare a sink callback without directly importing the VoiceCatC C module. Mirrors the C# VcPcmSinkCallback delegate - keyCodeName helper deduplicated (was in PttKeyCaptureSheet.swift + MainWindowController.swift -- now shared) Platform-specific adaptations (vs. Windows): NSToolbar instead of ToolStrip; global menu bar + NSMenuItem key equivalents (Cmd not Ctrl, responder-chain dispatched, no custom key monitor needed); PM windows as modeless NSWindows; picker as Mac sheet; gray = secondaryLabelColor; SF Symbols for toolbar icons. swift test 10/10 (4 ExternalPcmTests + 6 VoiceCatClientSmokeTests against a live server); xcodebuild Debug + Release BUILD SUCCEEDED with 0 Swift warnings.
Apple client (macOS + iOS)
Built in M4 (see docs/roadmap.md). One shared Swift core
(VoiceCatCore package) wrapping the C ABI (core/include/voicecat.h),
with platform-specific UIs: AppKit for macOS (best VoiceOver accessibility), SwiftUI
for iOS. See docs/architecture.md §4 and
docs/tech-stack.md §2.
What's here now
VoiceCatCore Swift Package — ✓ complete (2026-06-18)
The shared Swift core that both the macOS AppKit app and the iOS SwiftUI app will consume.
Mirrors the Windows client's VoiceCat.Interop layer (clients/windows/)
using Swift-native C interop instead of P/Invoke.
clients/apple/
├── Package.swift # SPM: binary target (XCFramework) + VoiceCatCore library + tests
├── VoiceCatCore.xcframework/ # BUILT ARTIFACT — produced by scripts/build-xcframework.sh (gitignored)
├── scripts/
│ └── build-xcframework.sh # builds libvoicecat + vcpkg deps → fat .a → XCFramework + module map
├── Sources/VoiceCatCore/
│ ├── Enums.swift # Swift-idiomatic mirrors of the 9 voicecat.h C enums
│ ├── Config.swift # VoiceCatConfig (wraps vc_config)
│ ├── Event.swift # VoiceCatEvent — copies ev.text inside the callback (the #1 lifetime rule)
│ ├── Models.swift # Channel, User, Stream, Device, Permissions, Account, AudioConfig, …
│ ├── Marshaling.swift # C arrays → Swift arrays + immediate vc_free_* (callers never manage native lifetime)
│ ├── Callbacks.swift # @convention(c) on_event/on_level + Unmanaged.passUnretained context bridging
│ └── VoiceCatClient.swift # the public Swift surface — owns vc_client*, all 38 C functions, event delivery on @MainActor
└── Tests/VoiceCatCoreTests/
└── VoiceCatClientSmokeTests.swift # 6 XCTest smoke tests against a real voicecat-server (6/6 green)
Key patterns (carried over from the proven C# VoiceCat.Interop — see
docs/architecture.md §4 per-platform binding notes):
- C interop via module map:
import VoiceCatC— Swift sees all C enums/structs/functions directly. No manual struct/function redeclaration (unlike C# P/Invoke). The module map (module VoiceCatC { header "voicecat.h" }) is staged into the XCFramework headers bybuild-xcframework.sh. @convention(c)callbacks: plain C function pointers (not ARC-managed closures) +Unmanaged.passUnretained(self)as theusercontext — the Swift analog of C#'s[UnmanagedCallersOnly]+GCHandle.deinitcallsvc_client_destroy(joins all threads) before the object's memory is freed, so no callback can fire with a dangling pointer.- Config string lifetimes: the core stores raw pointers from
vc_config(doesn't copy). Native CString storage (strdup) is held for the client's entire lifetime, freed indeinitaftervc_client_destroy. - Event delivery: events buffered in a lock-protected array + coalesced
DispatchQueue.maindrain (one async block at a time) — the Swift analog of C#'sChannel<VoiceCatEvent>+ 30ms WinForms Timer pump.ev.textis copied toStringinside the callback before enqueueing (dangling-pointer rule). - Level meters: coalesced to latest-per-stream-id (intermediate values are visually
irrelevant, same as C#'s
ConcurrentDictionary<uint,float>). - Immediate
vc_free_*on list reads — callers never manage native list lifetime.
Tests — 6/6 green
swift test
# ✓ testVersionStringIsNonEmpty
# ✓ testResultStringRoundTrips
# ✓ testConnectTofuAuthListChannelsRoundTrips (connect → TOFU → confirm → guest auth → channels → permissions → guest ListAccounts rejected)
# ✓ testAdminChannelCrudAccountCrudRoundTrips (admin auth → channel create/edit/delete → account create/list/reset/delete)
# ✓ testScreenAudioStreamStartsAndStops (screen-audio stream start/stop through Swift interop)
# ✓ testPerStreamRecvControlsRoundTrip (two clients, per-stream gain/mute/NR round-trip)
Prerequisites for tests: cmake --preset dev && cmake --build --preset dev (builds
voicecat-server + voicecat-admin into build/dev/bin/).
What's NOT here yet (next steps)
- macOS AppKit app (
clients/apple/macOS/) — the M4 UI: connect dialog, saved-server list (Keychain for passwords), TOFU identity dialog, main window (NSOutlineView channel tree, NSTableView user list, NSTextView chat, activity log), voice controls, per-user tuning, full VoiceOver accessibility. Mirrors the WindowsVoiceCat.Appfeature set. - iOS SwiftUI app — AVAudioSession, mic permission, foreground voice.
vc_audio_suspend/vc_audio_resumeABI hooks — deferred until the iOS client milestone (keep ABI stable).- ReplayKit Broadcast Upload Extension for iOS
SCREEN_AUDIO(docs/voice.md§9). - macOS
SCREEN_AUDIOvia ScreenCaptureKit (currently stub returnsfalse). - iOS XCFramework slices —
apple-ios/apple-ios-simpresets are scaffolding; runscripts/build-xcframework.sh --allonce the iOS vcpkg triplets are validated.
Building the XCFramework
The XCFramework is a local build artifact (gitignored, like the Windows client's
build/windows-client/bin/voicecat.dll). Run the build script before swift build /
swift test:
# Prerequisites: VCPKG_ROOT set, Xcode installed
export VCPKG_ROOT=/path/to/vcpkg
# Build the macOS slice + fat static lib + XCFramework (validated)
scripts/build-xcframework.sh
# → clients/apple/VoiceCatCore.xcframework/ (macOS-arm64 slice)
# Build all 3 slices (macOS + iOS device + iOS sim) — iOS still scaffolding
scripts/build-xcframework.sh --all
Fat static library
The apple-dev CMake preset produces a 1.9 MB libvoicecat.a containing only voicecat's
own object files — vcpkg's static dependencies (protobuf, mbedtls, libsodium, opus, sqlite3,
spdlog, asio, abseil, …) are 107 separate .a files under vcpkg_installed/arm64-osx/lib/.
A Swift Package binary target can only link ONE .a per XCFramework slice, so
build-xcframework.sh merges them all into a single self-contained libvoicecat-fat.a
(~30 MB) using libtool -static. This is the Apple equivalent of how the Windows client
ships a single voicecat.dll with all deps statically linked (via MinGW's -static flags
in core/CMakeLists.txt).
Swift Package
swift build # builds VoiceCatCore library
swift test # runs 6 smoke tests against a real voicecat-server
The Package.swift declares:
- A binary target (
VoiceCatCoreXCF) pointing at the localVoiceCatCore.xcframework. - A library target (
VoiceCatCore) that depends on the binary target and provides the Swift wrapper. - A test target (
VoiceCatCoreTests) withlinkerSettings: [.linkedLibrary("c++")]— the fat static lib is C++20, so the final executable must link libc++ (the LLVM C++ standard library on macOS). vcpkg's static deps are already in the.a; macOS system frameworks (CoreAudio/CoreFoundation) are auto-discovered by the linker.