Retire legacy implementations and flatten managed layout
This commit is contained in:
@@ -1,68 +0,0 @@
|
||||
// swift-tools-version: 6.0
|
||||
//
|
||||
// VoiceCatCore — the shared Swift core for the VoiceCat macOS (AppKit) and iOS (SwiftUI)
|
||||
// clients. It wraps libvoicecat's C ABI (core/include/voicecat.h) as imported through the
|
||||
// VoiceCatCore.xcframework binary target's module map (`import VoiceCatC`), and exposes a
|
||||
// Swift-idiomatic, @MainActor-safe surface.
|
||||
//
|
||||
// Architecture: docs/architecture.md §4 ("one core, many faces"). The Windows C# client
|
||||
// (clients/windows/VoiceCat.Interop) is the proven mirror of this same layering — the Swift
|
||||
// wrapper follows the same patterns (callback-lifetime, string-lifetime, event-delivery
|
||||
// thread handoff, immediate vc_free_* on list reads) adapted to Swift's interop model.
|
||||
//
|
||||
// The XCFramework is a LOCAL BUILD ARTIFACT — run `scripts/build-xcframework.sh` before
|
||||
// `swift build` / `swift test`. See clients/apple/README.md.
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "VoiceCatCore",
|
||||
// macOS 14 (Sonoma) is the AppKit client's deployment target. iOS 18 is the SwiftUI client
|
||||
// target (clients/apple/iOS/) — 18.0 unlocks the newest AVAudioSession APIs (stereo capture,
|
||||
// polar patterns, data sources). Run `scripts/build-xcframework.sh --all` to produce all
|
||||
// three slices: macos-arm64, ios-arm64, ios-arm64-simulator.
|
||||
// swift-tools-version 6.0 is required for .iOS(.v18); swiftLanguageVersions .v5 keeps the
|
||||
// Swift 5 language mode (avoids Swift 6 strict concurrency checking on pre-existing code).
|
||||
platforms: [
|
||||
.macOS(.v14),
|
||||
.iOS(.v18),
|
||||
],
|
||||
products: [
|
||||
.library(name: "VoiceCatCore", targets: ["VoiceCatCore"]),
|
||||
],
|
||||
targets: [
|
||||
// Binary target — the prebuilt static lib + headers + module map. Produced by
|
||||
// scripts/build-xcframework.sh from the `apple-dev` CMake preset.
|
||||
.binaryTarget(
|
||||
name: "VoiceCatCoreXCF",
|
||||
path: "VoiceCatCore.xcframework"
|
||||
),
|
||||
// The Swift wrapper library — what the macOS/iOS apps import as `import VoiceCatCore`.
|
||||
.target(
|
||||
name: "VoiceCatCore",
|
||||
dependencies: ["VoiceCatCoreXCF"],
|
||||
path: "Sources/VoiceCatCore",
|
||||
// Event-cue WAVs (shared with the Windows client) bundled into the package's
|
||||
// resource bundle; EventFeedback loads them via Bundle.module. Copied from
|
||||
// assets/sounds/ into Sources/VoiceCatCore/Sounds/.
|
||||
resources: [.process("Sounds")]
|
||||
),
|
||||
// Smoke tests against a real voicecat-server — mirrors clients/windows/
|
||||
// VoiceCat.Interop.Tests/VoiceCatClientSmokeTests.cs. Requires the `dev` CMake preset
|
||||
// to be built (build/dev/bin/voicecat-server + voicecat-admin).
|
||||
//
|
||||
// linkerSettings: libvoicecat.a is a static C++20 library (built by the apple-dev
|
||||
// preset with vcpkg's clang), so the final executable must link libc++ (the LLVM C++
|
||||
// standard library on macOS). vcpkg's static deps (mbedtls/sodium/opus/protobuf/
|
||||
// sqlite3/spdlog/asio) are already compiled into the .a; macOS system frameworks
|
||||
// (CoreAudio/CoreFoundation) are auto-discovered by the linker (PROGRESS.md).
|
||||
.testTarget(
|
||||
name: "VoiceCatCoreTests",
|
||||
dependencies: ["VoiceCatCore"],
|
||||
path: "Tests/VoiceCatCoreTests",
|
||||
linkerSettings: [
|
||||
.linkedLibrary("c++"),
|
||||
]
|
||||
),
|
||||
],
|
||||
swiftLanguageModes: [.v5]
|
||||
)
|
||||
+17
-152
@@ -1,160 +1,25 @@
|
||||
# Apple client (macOS + iOS)
|
||||
# Apple clients
|
||||
|
||||
Built in **M4** (see [`docs/roadmap.md`](../../docs/roadmap.md)). One shared **Swift core**
|
||||
(`VoiceCatCore` package) wrapping the C ABI ([`core/include/voicecat.h`](../../core/include/voicecat.h)),
|
||||
with platform-specific UIs: **AppKit** for macOS (best VoiceOver accessibility), **SwiftUI**
|
||||
for iOS. See [`docs/architecture.md`](../../docs/architecture.md) §4 and
|
||||
[`docs/tech-stack.md`](../../docs/tech-stack.md) §2.
|
||||
`VoiceCat.Mac` and `VoiceCat.iOS` are .NET 10 AppKit and UIKit clients over the shared managed
|
||||
core. The ReplayKit upload extension under `native/apple/broadcast` remains Swift because it
|
||||
runs under the extension memory limit and writes the versioned shared audio ring.
|
||||
|
||||
## 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/`](../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`](../../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 by
|
||||
`build-xcframework.sh`.
|
||||
- **`@convention(c)` callbacks:** plain C function pointers (not ARC-managed closures) +
|
||||
`Unmanaged.passUnretained(self)` as the `user` context — the Swift analog of C#'s
|
||||
`[UnmanagedCallersOnly]` + `GCHandle`. `deinit` calls `vc_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 in
|
||||
`deinit` after `vc_client_destroy`.
|
||||
- **Event delivery:** events buffered in a lock-protected array + coalesced
|
||||
`DispatchQueue.main` drain (one async block at a time) — the Swift analog of C#'s
|
||||
`Channel<VoiceCatEvent>` + 30ms WinForms Timer pump. `ev.text` is copied to `String`
|
||||
inside 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 Windows `VoiceCat.App` feature set.
|
||||
- **iOS SwiftUI app** — AVAudioSession, mic permission, foreground voice.
|
||||
- **`vc_audio_suspend`/`vc_audio_resume` ABI hooks** — deferred until the iOS client
|
||||
milestone (keep ABI stable).
|
||||
- **ReplayKit Broadcast Upload Extension** for iOS `SCREEN_AUDIO` ([`docs/voice.md`](../../docs/voice.md) §9).
|
||||
- **macOS `SCREEN_AUDIO`** via ScreenCaptureKit (currently stub returns `false`).
|
||||
- **iOS XCFramework slices** — `apple-ios` / `apple-ios-sim` presets are scaffolding; run
|
||||
`scripts/build-xcframework.sh --all` once 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`:
|
||||
Build on macOS with Xcode and the pinned .NET workloads:
|
||||
|
||||
```bash
|
||||
# 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
|
||||
./scripts/build-native.ps1
|
||||
./scripts/build-native-ios.sh
|
||||
dotnet restore clients/apple/VoiceCat.Apple.slnx
|
||||
dotnet build clients/apple/VoiceCat.Apple.slnx -c Debug --no-restore
|
||||
```
|
||||
|
||||
### Fat static library
|
||||
Use `publish-macos.sh --dry-run` to validate an ad-hoc macOS bundle. For distribution, set
|
||||
`VOICECAT_CODESIGN_IDENTITY`; optional notarization uses `APPLE_ID`, `APPLE_TEAM_ID`, and
|
||||
`APPLE_APP_PASSWORD`.
|
||||
|
||||
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/`,
|
||||
and the vendored RNNoise noise-suppression lib (`native/rnnoise/`, built as a CMake
|
||||
target → `build/<preset>/lib/librnnoise.a`) is another. A Swift Package binary target can
|
||||
only link ONE `.a` per XCFramework slice, so `build-xcframework.sh` merges them all — vcpkg
|
||||
deps plus the locally-built vendored libs — into a single self-contained `libvoicecat-fat.a`
|
||||
(~33 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`](../../core/CMakeLists.txt)). If you add another vendored (non-vcpkg)
|
||||
static-lib target to the core, it's picked up automatically as long as it lands in
|
||||
`build/<preset>/lib/` and isn't named `libvoicecat*`.
|
||||
For a physical iOS device, use `build-ios-device.sh` and `deploy-ios-device.sh`. The host and
|
||||
ReplayKit extension require signing profiles with App Group `group.me.iamtalon.voicecat`.
|
||||
Hardware validation must cover VoiceOver, background and lock behavior, interruptions, route
|
||||
changes, Bluetooth, ReplayKit, and iOS 27 ScreenCaptureKit audio.
|
||||
|
||||
### Swift Package
|
||||
|
||||
```bash
|
||||
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 local `VoiceCatCore.xcframework`.
|
||||
- A **library target** (`VoiceCatCore`) that depends on the binary target and provides the
|
||||
Swift wrapper.
|
||||
- A **test target** (`VoiceCatCoreTests`) with `linkerSettings: [.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.
|
||||
|
||||
## Ad-hoc distribution (iOS, pre-TestFlight)
|
||||
|
||||
To hand the iOS app to a handful of friends before TestFlight, use
|
||||
[`scripts/dist-ios-adhoc.sh`](../../scripts/dist-ios-adhoc.sh). It registers each device's
|
||||
UDID, builds an ad-hoc-signed `VoiceCatiOS.ipa`, and generates the `manifest.plist` +
|
||||
`index.html` for an over-the-air (`itms-services://`) web install. Ad-hoc builds only run on
|
||||
devices whose UDID is registered *before* signing, and stock iOS won't install a bare `.ipa`
|
||||
without a sideloading tool — so the web-install page is the friend-friendly path.
|
||||
|
||||
```bash
|
||||
# One-time: create an App Store Connect API "Team Key" (.p8, Admin/App Manager access) at
|
||||
# App Store Connect → Users and Access → Integrations → App Store Connect API
|
||||
export ASC_KEY_ID=ABC123 ASC_ISSUER_ID=1111-... ASC_KEY_PATH=~/.appstoreconnect/AuthKey_ABC123.p8
|
||||
|
||||
# Register a device + build + stage everything into dist/ios-adhoc/
|
||||
scripts/dist-ios-adhoc.sh --udid <UDID> --name "Friend iPhone" \
|
||||
--base-url https://example.com/voicecat
|
||||
```
|
||||
|
||||
Then upload the three staged files (`VoiceCatiOS.ipa`, `manifest.plist`, `index.html`) to
|
||||
that **HTTPS** folder and open `index.html` in Safari on a registered iPhone (iOS 18+).
|
||||
Device UDID registration is automated via [`scripts/asc_api.py`](../../scripts/asc_api.py)
|
||||
(`asc_api.py list` shows the registered devices against the 100-iOS-devices/year cap).
|
||||
Requires a paid Apple Developer Program membership. Run `scripts/dist-ios-adhoc.sh --help`
|
||||
for all flags.
|
||||
The shared ring contract is documented in `docs/broadcast-ring-format.md`.
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
// C callbacks use an unretained `user` context. Client destruction joins callback threads,
|
||||
// and transient event pointers are copied before the callback returns.
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
// VoiceCatConfig — Swift-idiomatic mirror of `vc_config` (voicecat.h). Passed to
|
||||
// VoiceCatClient.init. The native CString storage for the string fields is held for the
|
||||
// client's entire lifetime inside VoiceCatClient — see VoiceCatClient.swift's doc comment
|
||||
// on why (the core stores raw pointers from vc_config by value, it does not copy the data).
|
||||
|
||||
import VoiceCatC
|
||||
|
||||
/// Configuration for a `VoiceCatClient`. Mirrors `vc_config`.
|
||||
public struct VoiceCatConfig: Sendable {
|
||||
/// E.g. "VoiceCat-macOS". Forwarded in `ClientHello.client_name`.
|
||||
public let clientName: String
|
||||
/// E.g. "0.0.1". Forwarded in `ClientHello.client_version`.
|
||||
public let clientVersion: String
|
||||
public let logLevel: VoiceCatLogLevel
|
||||
/// Path to the TOFU pin file (see `confirmServerIdentity` / docs/security.md §1.1).
|
||||
/// nil = built-in relative default (only suitable for tests).
|
||||
public let tofuStorePath: String?
|
||||
|
||||
public init(
|
||||
clientName: String,
|
||||
clientVersion: String,
|
||||
logLevel: VoiceCatLogLevel = .info,
|
||||
tofuStorePath: String? = nil
|
||||
) {
|
||||
self.clientName = clientName
|
||||
self.clientVersion = clientVersion
|
||||
self.logLevel = logLevel
|
||||
self.tofuStorePath = tofuStorePath
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
// Swift-idiomatic mirrors of the voicecat.h C enums. Keep these in lockstep with
|
||||
// core/include/voicecat.h — values are append-only per the C ABI's house rule, so it's
|
||||
// safe to add new cases at the end here too, but never renumber/remove existing ones.
|
||||
//
|
||||
// Swift imports the C enums directly via `import VoiceCatC` (e.g. VoiceCatC.VC_OK), but
|
||||
// those case names are C-style (VC_ERR_NOT_IMPLEMENTED, VC_EVENT_SERVER_IDENTITY) — these
|
||||
// mirrors give the Swift UI and tests clean dot-syntax (VoiceCatResult.notImplemented,
|
||||
// VoiceCatEventType.serverIdentity) and a typed bridge to/from the C values.
|
||||
//
|
||||
// NOTE: Swift's Clang importer brings C `typedef enum` types in as UInt32-backed enums
|
||||
// (all our C enum values are non-negative), so these mirrors use UInt32 raw values too.
|
||||
// The one signed field in the ABI — `vc_event.result` is `int32_t` (not `vc_result`) — is
|
||||
// bridged via `UInt32(bitPattern:)` in Event.swift.
|
||||
|
||||
import VoiceCatC
|
||||
|
||||
/// Result codes — mirrors `vc_result` (voicecat.h). Additive-only: new values go at the end.
|
||||
public enum VoiceCatResult: UInt32, Sendable, Equatable {
|
||||
case ok = 0
|
||||
case notImplemented = 1
|
||||
case invalidArg = 2
|
||||
case notConnected = 3
|
||||
case already = 4
|
||||
case authFailed = 5
|
||||
case permissionDenied = 6
|
||||
case timeout = 7
|
||||
case io = 8
|
||||
case protocolError = 9
|
||||
case crypto = 10
|
||||
case audio = 11
|
||||
case internalError = 12
|
||||
|
||||
/// Human-readable description from the core (vc_result_string returns a static literal).
|
||||
public var description: String {
|
||||
String(cString: vc_result_string(vc_result(rawValue)))
|
||||
}
|
||||
|
||||
/// Bridge from the C enum.
|
||||
public init(_ cValue: vc_result) { self = VoiceCatResult(rawValue: cValue.rawValue) ?? .internalError }
|
||||
/// Bridge to the C enum.
|
||||
public var cValue: vc_result { vc_result(rawValue) }
|
||||
}
|
||||
|
||||
/// Log level — mirrors `vc_log_level`.
|
||||
public enum VoiceCatLogLevel: UInt32, Sendable, Equatable {
|
||||
case trace = 0
|
||||
case debug = 1
|
||||
case info = 2
|
||||
case warn = 3
|
||||
case error = 4
|
||||
case off = 5
|
||||
|
||||
public init(_ cValue: vc_log_level) { self = VoiceCatLogLevel(rawValue: cValue.rawValue) ?? .info }
|
||||
public var cValue: vc_log_level { vc_log_level(rawValue) }
|
||||
}
|
||||
|
||||
/// Connection state — mirrors `vc_connection_state`.
|
||||
public enum VoiceCatConnectionState: UInt32, Sendable, Equatable {
|
||||
case disconnected = 0
|
||||
case connecting = 1
|
||||
case tlsHandshake = 2
|
||||
case authenticating = 3
|
||||
case connected = 4
|
||||
/// Handshake succeeded, waiting on `confirmServerIdentity()`.
|
||||
case verifyingIdentity = 5
|
||||
|
||||
public init(_ cValue: vc_connection_state) {
|
||||
self = VoiceCatConnectionState(rawValue: cValue.rawValue) ?? .disconnected
|
||||
}
|
||||
public var cValue: vc_connection_state { vc_connection_state(rawValue) }
|
||||
}
|
||||
|
||||
/// Text message scope — mirrors `vc_text_scope`.
|
||||
public enum VoiceCatTextScope: UInt32, Sendable, Equatable {
|
||||
case channel = 0
|
||||
case `private` = 1
|
||||
case server = 2
|
||||
|
||||
public init(_ cValue: vc_text_scope) { self = VoiceCatTextScope(rawValue: cValue.rawValue) ?? .channel }
|
||||
public var cValue: vc_text_scope { vc_text_scope(rawValue) }
|
||||
}
|
||||
|
||||
/// Audio device kind — mirrors `vc_device_kind`.
|
||||
public enum VoiceCatDeviceKind: UInt32, Sendable, Equatable {
|
||||
case input = 0
|
||||
case output = 1
|
||||
|
||||
public init(_ cValue: vc_device_kind) { self = VoiceCatDeviceKind(rawValue: cValue.rawValue) ?? .input }
|
||||
public var cValue: vc_device_kind { vc_device_kind(rawValue) }
|
||||
}
|
||||
|
||||
/// Stream kind — mirrors `vc_stream_kind`.
|
||||
public enum VoiceCatStreamKind: UInt32, Sendable, Equatable {
|
||||
case mic = 0
|
||||
/// System/desktop audio (docs/voice.md §9).
|
||||
case screenAudio = 1
|
||||
case auxDevice = 2
|
||||
|
||||
public init(_ cValue: vc_stream_kind) { self = VoiceCatStreamKind(rawValue: cValue.rawValue) ?? .mic }
|
||||
public var cValue: vc_stream_kind { vc_stream_kind(rawValue) }
|
||||
}
|
||||
|
||||
/// Send-side input gate mode (docs/voice.md §11) — mirrors `vc_input_mode`.
|
||||
public enum VoiceCatInputMode: UInt32, Sendable, Equatable {
|
||||
case voiceActivation = 0
|
||||
case pushToTalk = 1
|
||||
/// Transmit unconditionally, no VAD gate.
|
||||
case alwaysOn = 2
|
||||
|
||||
public init(_ cValue: vc_input_mode) { self = VoiceCatInputMode(rawValue: cValue.rawValue) ?? .voiceActivation }
|
||||
public var cValue: vc_input_mode { vc_input_mode(rawValue) }
|
||||
}
|
||||
|
||||
/// Event type — mirrors `vc_event_type`. Additive-only.
|
||||
public enum VoiceCatEventType: UInt32, Sendable, Equatable {
|
||||
case connectionState = 0
|
||||
case authResult = 1
|
||||
case channelList = 2
|
||||
case userJoined = 3
|
||||
case userLeft = 4
|
||||
case userUpdated = 5
|
||||
case textMessage = 6
|
||||
case streamStarted = 7
|
||||
case streamStopped = 8
|
||||
case talkState = 9
|
||||
case error = 10
|
||||
case disconnected = 11
|
||||
/// Reply to `joinChannel()` — see `VoiceCatEvent.result` / `.channelId`.
|
||||
case joinResult = 12
|
||||
/// The TOFU server-identity gate — see `VoiceCatEvent.tofuStatus` / `.text`.
|
||||
case serverIdentity = 13
|
||||
/// Async result for moderation/admin/channel operations.
|
||||
case genericResult = 14
|
||||
/// Reply to `requestAccountList()` — call `listAccounts()` to read.
|
||||
case accountList = 15
|
||||
/// Voice-plane subscription state. `u32a` = 1 (subscribed) or 0 (unsubscribed).
|
||||
case voiceState = 16
|
||||
|
||||
public init(_ cValue: vc_event_type) {
|
||||
self = VoiceCatEventType(rawValue: cValue.rawValue) ?? .error
|
||||
}
|
||||
public var cValue: vc_event_type { vc_event_type(rawValue) }
|
||||
}
|
||||
|
||||
/// TOFU server-identity classification — mirrors `vc_tofu_status`. Pins the TLS leaf
|
||||
/// certificate's SHA-256 fingerprint (NOT the display-only Ed25519 value — see
|
||||
/// docs/security.md §1.1 and `VoiceCatServerIdentity`).
|
||||
public enum VoiceCatTofuStatus: UInt32, Sendable, Equatable {
|
||||
case firstConnect = 0
|
||||
case matched = 1
|
||||
case mismatch = 2
|
||||
|
||||
public init(_ cValue: vc_tofu_status) {
|
||||
self = VoiceCatTofuStatus(rawValue: cValue.rawValue) ?? .firstConnect
|
||||
}
|
||||
public var cValue: vc_tofu_status { vc_tofu_status(rawValue) }
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
// 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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
// EventFeedback — shared audible + spoken feedback for session events, used by both the macOS
|
||||
// (AppKit) and iOS (SwiftUI) clients. Mirrors the Windows client's EventFeedback policy
|
||||
// (clients/windows/.../Notifications/EventFeedback.cs): the platform event handlers decide WHAT
|
||||
// to play (they own the model/nickname/channel context); this type owns the "should I, and how"
|
||||
// policy plus the AVFoundation playback/synthesis.
|
||||
//
|
||||
// Sound effects use AVAudioPlayer; spoken announcements use the OS-native AVSpeechSynthesizer.
|
||||
//
|
||||
// NOTE (iOS): on the voice path the app runs a play-and-record AVAudioSession (VPIO). Playing
|
||||
// these cues / speech over that session can interact with the live call (ducking, route, or the
|
||||
// mute switch). The session category should allow mixing — verify on device. This is the most
|
||||
// likely place for platform bugs to surface.
|
||||
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
|
||||
/// User preferences for event sounds and spoken feedback, backed by UserDefaults so the macOS
|
||||
/// and iOS settings screens and this player share one source of truth.
|
||||
public struct FeedbackSettings: Sendable {
|
||||
public var sounds: Bool
|
||||
public var speech: Bool
|
||||
public var volume: Float
|
||||
public var selfTalkSounds: Bool
|
||||
public var pttSound: Bool
|
||||
|
||||
static let keySounds = "feedback.sounds"
|
||||
static let keySpeech = "feedback.speech"
|
||||
static let keyVolume = "feedback.volume"
|
||||
static let keySelfTalk = "feedback.selfTalk"
|
||||
static let keyPtt = "feedback.ptt"
|
||||
|
||||
/// Default values registered with UserDefaults (so "unset" reads as the intended default
|
||||
/// rather than false/0).
|
||||
static let defaults: [String: Any] = [
|
||||
keySounds: true,
|
||||
keySpeech: false,
|
||||
keyVolume: 1.0,
|
||||
keySelfTalk: false,
|
||||
keyPtt: false,
|
||||
]
|
||||
|
||||
/// The current settings, read live from UserDefaults.
|
||||
public static var current: FeedbackSettings {
|
||||
let d = UserDefaults.standard
|
||||
d.register(defaults: defaults) // idempotent — ensures "unset" reads as the intended default
|
||||
return FeedbackSettings(
|
||||
sounds: d.bool(forKey: keySounds),
|
||||
speech: d.bool(forKey: keySpeech),
|
||||
volume: d.float(forKey: keyVolume),
|
||||
selfTalkSounds: d.bool(forKey: keySelfTalk),
|
||||
pttSound: d.bool(forKey: keyPtt))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public final class EventFeedback {
|
||||
public static let shared = EventFeedback()
|
||||
|
||||
private var players: [SoundEvent: AVAudioPlayer] = [:]
|
||||
private let synthesizer = AVSpeechSynthesizer()
|
||||
|
||||
private init() {
|
||||
UserDefaults.standard.register(defaults: FeedbackSettings.defaults)
|
||||
}
|
||||
|
||||
// MARK: - Sounds
|
||||
|
||||
/// Play an event cue, honouring the user's settings. The two opt-in categories (your own
|
||||
/// voice-activity, and the PTT cue) are gated by their own flags.
|
||||
public func play(_ event: SoundEvent) {
|
||||
let s = FeedbackSettings.current
|
||||
guard s.sounds, s.volume > 0 else { return }
|
||||
if (event == .vaStart || event == .vaStop), !s.selfTalkSounds { return }
|
||||
if event == .ptt, !s.pttSound { return }
|
||||
|
||||
guard let player = player(for: event) else { return }
|
||||
player.volume = s.volume
|
||||
player.currentTime = 0
|
||||
player.play()
|
||||
}
|
||||
|
||||
/// Lazily load and cache an AVAudioPlayer for the event's bundled WAV. Returns nil (silent)
|
||||
/// if the resource is missing or fails to load.
|
||||
private func player(for event: SoundEvent) -> AVAudioPlayer? {
|
||||
if let cached = players[event] { return cached }
|
||||
guard let url = Bundle.module.url(forResource: event.resourceName, withExtension: "wav"),
|
||||
let player = try? AVAudioPlayer(contentsOf: url) else {
|
||||
return nil
|
||||
}
|
||||
player.prepareToPlay()
|
||||
players[event] = player
|
||||
return player
|
||||
}
|
||||
|
||||
// MARK: - Speech
|
||||
|
||||
/// Speak `text` when spoken feedback is enabled. Utterances queue (do not interrupt prior
|
||||
/// speech) so a burst of events is read in order.
|
||||
public func speak(_ text: String) {
|
||||
guard FeedbackSettings.current.speech else { return }
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
synthesizer.speak(AVSpeechUtterance(string: trimmed))
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
// SoundEvent — the cross-platform set of audible event cues. Each maps to a WAV bundled as an
|
||||
// SPM resource (Sources/VoiceCatCore/Sounds/, copied from assets/sounds/). The same logical set
|
||||
// is mirrored in the Windows client (clients/windows/.../Notifications/SoundEvent.cs) so feedback
|
||||
// stays consistent across platforms.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum SoundEvent: CaseIterable, Sendable {
|
||||
case channelJoin // another user joined my channel
|
||||
case channelLeave // another user left my channel
|
||||
case channelRecv // channel text message from someone else
|
||||
case channelSent // channel text message I sent
|
||||
case pmRecv // private message received
|
||||
case pmSent // private message I sent
|
||||
case login // connected / authenticated
|
||||
case logout // clean disconnect
|
||||
case connectionLost // unexpected disconnect
|
||||
case voiceOn // my microphone stream started
|
||||
case voiceOff // my microphone stream stopped
|
||||
case vaStart // my voice-activity began (off by default)
|
||||
case vaStop // my voice-activity ended (off by default)
|
||||
case ptt // push-to-talk engaged (off by default)
|
||||
|
||||
/// Resource name (without extension) as bundled in Sources/VoiceCatCore/Sounds/.
|
||||
var resourceName: String {
|
||||
switch self {
|
||||
case .channelJoin: return "channel_join"
|
||||
case .channelLeave: return "channel_leave"
|
||||
case .channelRecv: return "channel_recv"
|
||||
case .channelSent: return "channel_sent"
|
||||
case .pmRecv: return "pm_recv"
|
||||
case .pmSent: return "pm_sent"
|
||||
case .login: return "login"
|
||||
case .logout: return "logout"
|
||||
case .connectionLost: return "connection_lost"
|
||||
case .voiceOn: return "voice_on"
|
||||
case .voiceOff: return "voice_off"
|
||||
case .vaStart: return "va_start"
|
||||
case .vaStop: return "va_stop"
|
||||
case .ptt: return "ptt"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
// 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, sortOrder: c.sort_order,
|
||||
audio: audioConfig(c.audio)))
|
||||
}
|
||||
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,
|
||||
voiceSubscribed: u.voice_subscribed != 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,
|
||||
dtx: c.dtx != 0, complexity: c.complexity, dred: c.dred != 0)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
// Plain Swift value types — what survives past the native struct/free-list lifetime
|
||||
// (Marshaling.swift converts the C structs into these and immediately frees the native
|
||||
// list). Nothing here holds a raw pointer. This is the Swift analog of the C# client's
|
||||
// Models.cs. Field naming follows Swift camelCase (the C structs use snake_case).
|
||||
|
||||
import VoiceCatC
|
||||
|
||||
/// Channel snapshot — mirrors `vc_channel` (the pull-based view; re-call `listChannels()`
|
||||
/// after `.channelList` / `.userJoined` / `.userLeft` / `.userUpdated` events).
|
||||
public struct Channel: Sendable, Equatable, Identifiable {
|
||||
public let id: UInt32
|
||||
/// 0 = root.
|
||||
public let parentId: UInt32
|
||||
public let name: String
|
||||
public let topic: String
|
||||
public let passwordProtected: Bool
|
||||
/// 0 = unlimited.
|
||||
public let maxUsers: UInt32
|
||||
public let sortOrder: UInt32
|
||||
/// Authoritative channel Opus params (docs/voice.md §3). Populated from the Channel proto
|
||||
/// so the edit dialog can read back the current config.
|
||||
public let audio: AudioConfig
|
||||
|
||||
public init(id: UInt32, parentId: UInt32, name: String, topic: String,
|
||||
passwordProtected: Bool, maxUsers: UInt32, sortOrder: UInt32,
|
||||
audio: AudioConfig) {
|
||||
self.id = id; self.parentId = parentId; self.name = name; self.topic = topic
|
||||
self.passwordProtected = passwordProtected; self.maxUsers = maxUsers
|
||||
self.sortOrder = sortOrder; self.audio = audio
|
||||
}
|
||||
}
|
||||
|
||||
/// Channel creation/edition descriptor — mirrors `vc_channel_info`. Used by
|
||||
/// `createChannel(_:)` and `editChannel(_:)`. `id == 0` means new channel (for create).
|
||||
public struct ChannelEdit: Sendable, Equatable {
|
||||
public let id: UInt32 // 0 = new channel for create
|
||||
public let parentId: UInt32 // 0 = root
|
||||
public let name: String
|
||||
public let topic: String
|
||||
public let passwordProtected: Bool
|
||||
public let password: String? // nil/empty ignored if passwordProtected == false
|
||||
public let maxUsers: UInt32 // 0 = unlimited
|
||||
public let sortOrder: UInt32
|
||||
/// 0/nil fields use server defaults.
|
||||
public let audio: AudioConfig
|
||||
|
||||
public init(id: UInt32, parentId: UInt32, name: String, topic: String,
|
||||
passwordProtected: Bool, password: String?, maxUsers: UInt32,
|
||||
sortOrder: UInt32, audio: AudioConfig) {
|
||||
self.id = id; self.parentId = parentId; self.name = name; self.topic = topic
|
||||
self.passwordProtected = passwordProtected; self.password = password
|
||||
self.maxUsers = maxUsers; self.sortOrder = sortOrder; self.audio = audio
|
||||
}
|
||||
}
|
||||
|
||||
/// User snapshot — mirrors `vc_user`.
|
||||
public struct User: Sendable, Equatable, Identifiable {
|
||||
public let id: UInt32
|
||||
public let nickname: String
|
||||
public let isGuest: Bool
|
||||
public let channelId: UInt32
|
||||
public let selfMicMuted: Bool
|
||||
public let selfDeafened: Bool
|
||||
public let serverMuted: Bool
|
||||
public let serverDeafened: Bool
|
||||
public let voiceSubscribed: Bool
|
||||
|
||||
public init(id: UInt32, nickname: String, isGuest: Bool, channelId: UInt32,
|
||||
selfMicMuted: Bool, selfDeafened: Bool, serverMuted: Bool,
|
||||
serverDeafened: Bool, voiceSubscribed: Bool) {
|
||||
self.id = id; self.nickname = nickname; self.isGuest = isGuest; self.channelId = channelId
|
||||
self.selfMicMuted = selfMicMuted; self.selfDeafened = selfDeafened
|
||||
self.serverMuted = serverMuted; self.serverDeafened = serverDeafened
|
||||
self.voiceSubscribed = voiceSubscribed
|
||||
}
|
||||
}
|
||||
|
||||
/// Permission bitset — mirrors `vc_permissions`.
|
||||
public struct Permissions: Sendable, Equatable {
|
||||
public let canCreateTempChannel: Bool
|
||||
public let canKick: Bool
|
||||
public let canBan: Bool
|
||||
public let canMoveUsers: Bool
|
||||
public let canAdminAccounts: Bool
|
||||
public let isAdmin: Bool
|
||||
|
||||
public init(canCreateTempChannel: Bool, canKick: Bool, canBan: Bool,
|
||||
canMoveUsers: Bool, canAdminAccounts: Bool, isAdmin: Bool) {
|
||||
self.canCreateTempChannel = canCreateTempChannel; self.canKick = canKick; self.canBan = canBan
|
||||
self.canMoveUsers = canMoveUsers; self.canAdminAccounts = canAdminAccounts; self.isAdmin = isAdmin
|
||||
}
|
||||
}
|
||||
|
||||
/// Account entry — mirrors `vc_account` (reply to `listAccounts()`).
|
||||
public struct Account: Sendable, Equatable {
|
||||
public let username: String
|
||||
public let isAdmin: Bool
|
||||
public let createdAtUnixMs: UInt64
|
||||
public let lastLoginUnixMs: UInt64
|
||||
|
||||
public init(username: String, isAdmin: Bool, createdAtUnixMs: UInt64,
|
||||
lastLoginUnixMs: UInt64) {
|
||||
self.username = username; self.isAdmin = isAdmin
|
||||
self.createdAtUnixMs = createdAtUnixMs; self.lastLoginUnixMs = lastLoginUnixMs
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-user stream summary — mirrors `vc_stream_summary`. For the full effective Opus
|
||||
/// config of a specific (user_id, stream_id), use `VoiceCatClient.getStreamAudioConfig`.
|
||||
public struct StreamSummary: Sendable, Equatable, Identifiable {
|
||||
public let id: UInt32 // stream_id
|
||||
public let kind: VoiceCatStreamKind
|
||||
public let label: String
|
||||
|
||||
public init(streamId: UInt32, kind: VoiceCatStreamKind, label: String) {
|
||||
self.id = streamId; self.kind = kind; self.label = label
|
||||
}
|
||||
}
|
||||
|
||||
/// Receive-side state the local listener chose for a specific remote stream — mirrors
|
||||
/// `vc_remote_stream_state`. All LOCAL (no protocol traffic) — docs/voice.md §10.
|
||||
/// Defaults (if `setRemoteStream` was never called): gain 1.0, unmuted, NR off.
|
||||
public struct RemoteStreamState: Sendable, Equatable {
|
||||
public let gain: Float // 0.0–… ; default 1.0
|
||||
public let muted: Bool
|
||||
public let noiseReduction: Bool
|
||||
|
||||
public init(gain: Float, muted: Bool, noiseReduction: Bool) {
|
||||
self.gain = gain; self.muted = muted; self.noiseReduction = noiseReduction
|
||||
}
|
||||
}
|
||||
|
||||
/// Audio device — mirrors `vc_device`. `id` is an opaque, internally-encoded handle
|
||||
/// (currently hex-encoded `ma_device_id`) — always round-trip an id from `listDevices`;
|
||||
/// never construct one by hand (docs/architecture.md §4).
|
||||
public struct Device: Sendable, Equatable, Identifiable {
|
||||
public let id: String
|
||||
public let name: String
|
||||
public let isDefault: Bool
|
||||
|
||||
public init(id: String, name: String, isDefault: Bool) {
|
||||
self.id = id; self.name = name; self.isDefault = isDefault
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS audio input port — derived from `AVAudioSession.availableInputs`. Unlike the
|
||||
/// miniaudio-based `Device` (which returns ~2 entries on iOS), this exposes the real
|
||||
/// AVAudioSession input ports (builtInMic, bluetoothHFP, headsetMic, usbAudio, airPlay)
|
||||
/// with their data sources (orientation: front/back/top/bottom) and polar patterns
|
||||
/// (omni/cardioid/subcardioid/bidirectional). Used by `IOSAudioRouter` + `SettingsView`.
|
||||
public struct IOSAudioInputPort: Identifiable, Hashable {
|
||||
public let id: String // port UID (stable across route changes)
|
||||
public let name: String // human-readable port name
|
||||
public let portType: String // AVAudioSession.Port raw value as string
|
||||
public let dataSources: [IOSAudioDataSource]?
|
||||
public let isSelected: Bool // true if this is the current preferredInput
|
||||
|
||||
public init(id: String, name: String, portType: String,
|
||||
dataSources: [IOSAudioDataSource]?, isSelected: Bool) {
|
||||
self.id = id; self.name = name; self.portType = portType
|
||||
self.dataSources = dataSources; self.isSelected = isSelected
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS audio data source — a sub-selection of an input port (e.g. built-in mic
|
||||
/// orientation: front/back/top/bottom). May have polar pattern options.
|
||||
public struct IOSAudioDataSource: Identifiable, Hashable {
|
||||
public let id: String // dataSource UID
|
||||
public let name: String // "Front", "Back", "Top", "Bottom"
|
||||
public let polarPatterns: [String]? // AVAudioSession.PolarPattern raw values
|
||||
public let isSelected: Bool // true if this is the current preferredDataSource
|
||||
public let selectedPolarPattern: String?
|
||||
|
||||
public init(id: String, name: String, polarPatterns: [String]?,
|
||||
isSelected: Bool, selectedPolarPattern: String?) {
|
||||
self.id = id; self.name = name; self.polarPatterns = polarPatterns
|
||||
self.isSelected = isSelected; self.selectedPolarPattern = selectedPolarPattern
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS audio output route — read-only display of `AVAudioSession.currentRoute.outputs`.
|
||||
public struct IOSAudioOutputRoute: Identifiable, Hashable {
|
||||
public let id: String // port UID
|
||||
public let name: String // human-readable route name
|
||||
public let portType: String // AVAudioSession.Port raw value as string
|
||||
|
||||
public init(id: String, name: String, portType: String) {
|
||||
self.id = id; self.name = name; self.portType = portType
|
||||
}
|
||||
}
|
||||
|
||||
/// Effective Opus configuration — mirrors `vc_audio_config`.
|
||||
public struct AudioConfig: Sendable, Equatable {
|
||||
public let codec: UInt32 // 0 = OPUS
|
||||
public let stereo: Bool // mode: 0 = mono, 1 = stereo
|
||||
public let sampleRate: UInt32
|
||||
public let bitrateBps: UInt32
|
||||
public let frameMs: UInt32
|
||||
public let application: UInt32 // 0 = VOIP, 1 = AUDIO, 2 = LOWDELAY
|
||||
public let fec: Bool
|
||||
public let expectedPacketLoss: UInt32 // % 0..100
|
||||
public let dtx: Bool
|
||||
public let complexity: UInt32 // 0..10
|
||||
public let dred: Bool // Deep REDundancy (Opus 1.6), off by default
|
||||
|
||||
public init(codec: UInt32 = 0, stereo: Bool = false, sampleRate: UInt32 = 48000,
|
||||
bitrateBps: UInt32 = 64000, frameMs: UInt32 = 20, application: UInt32 = 0,
|
||||
fec: Bool = true, expectedPacketLoss: UInt32 = 5, dtx: Bool = false,
|
||||
complexity: UInt32 = 10, dred: Bool = false) {
|
||||
self.codec = codec; self.stereo = stereo; self.sampleRate = sampleRate
|
||||
self.bitrateBps = bitrateBps; self.frameMs = frameMs; self.application = application
|
||||
self.fec = fec; self.expectedPacketLoss = expectedPacketLoss; self.dtx = dtx
|
||||
self.complexity = complexity; self.dred = dred
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream descriptor — mirrors `vc_stream_desc`. Used by `startStream(kind:deviceId:label:)`.
|
||||
public struct StreamDescriptor: Sendable, Equatable {
|
||||
public let kind: VoiceCatStreamKind
|
||||
/// nil = default device for this kind.
|
||||
public let deviceId: String?
|
||||
public let label: String
|
||||
/// When true the caller feeds PCM via `feedPcm` (e.g. the iOS VPIO mic path) and the core
|
||||
/// skips opening a hardware capture device for this stream. Mirrors `vc_stream_desc.external_feed`.
|
||||
public let externalFeed: Bool
|
||||
|
||||
public init(kind: VoiceCatStreamKind, deviceId: String? = nil, label: String,
|
||||
externalFeed: Bool = false) {
|
||||
self.kind = kind; self.deviceId = deviceId; self.label = label
|
||||
self.externalFeed = externalFeed
|
||||
}
|
||||
}
|
||||
|
||||
/// Server identity info — parsed from a `.serverIdentity` event + `getServerIdentityDisplay()`.
|
||||
/// The `tlsCertFingerprint` (SHA-256 hex of the TLS leaf cert) is the value the TOFU gate
|
||||
/// actually pins on; `ed25519Fingerprint` is display-only (docs/security.md §1.1).
|
||||
public struct ServerIdentity: Sendable, Equatable {
|
||||
public let tofuStatus: VoiceCatTofuStatus
|
||||
/// SHA-256 hex of the TLS leaf certificate — the pinned value. No separators (64 chars).
|
||||
public let tlsCertFingerprint: String
|
||||
/// Ed25519 identity fingerprint from ServerHello, hex-formatted — display only.
|
||||
/// Empty if not yet available.
|
||||
public let ed25519Fingerprint: String
|
||||
|
||||
public init(tofuStatus: VoiceCatTofuStatus, tlsCertFingerprint: String,
|
||||
ed25519Fingerprint: String) {
|
||||
self.tofuStatus = tofuStatus; self.tlsCertFingerprint = tlsCertFingerprint
|
||||
self.ed25519Fingerprint = ed25519Fingerprint
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,594 +0,0 @@
|
||||
// Swift binding invariants: native config strings outlive the handle, destroy joins callback
|
||||
// threads before deallocation, and callback payloads are copied before main-queue delivery.
|
||||
// See docs/architecture.md §4 for the complete binding contract.
|
||||
|
||||
import VoiceCatC
|
||||
import Foundation
|
||||
|
||||
/// Swift-idiomatic alias for the C `vc_pcm_sink_cb` function-pointer type from
|
||||
/// `voicecat.h`. Exposed publicly so consumers (`VoiceCatMac`, tests) can declare a sink
|
||||
/// callback without directly importing the `VoiceCatC` C module. Mirrors the C# wrapper's
|
||||
/// `VcPcmSinkCallback` delegate.
|
||||
public typealias VoiceCatPcmSinkCallback = vc_pcm_sink_cb
|
||||
|
||||
/// Swift-idiomatic alias for the C `vc_mixed_output_cb` function-pointer type from `voicecat.h`
|
||||
/// — the external mixed-output sink used by the iOS VPIO path (see `setMixedOutputSink`).
|
||||
public typealias VoiceCatMixedOutputCallback = vc_mixed_output_cb
|
||||
|
||||
/// The Swift wrapper around `vc_client*`. Owns the native handle for its entire lifetime;
|
||||
/// `deinit` destroys it. Events and level meters are delivered on the main queue via the
|
||||
/// `onEvent` / `onLevel` closures.
|
||||
///
|
||||
/// Thread-safety: the public methods are not thread-safe — call them from the main thread
|
||||
/// (the standard AppKit/SwiftUI pattern). The internal event/level buffers are thread-safe
|
||||
/// (lock-protected) because they're written from the core's event thread.
|
||||
public final class VoiceCatClient {
|
||||
|
||||
// MARK: - Stored properties
|
||||
|
||||
private var handle: OpaquePointer?
|
||||
|
||||
/// Unretained callback context; destroying the handle joins callback threads first.
|
||||
private var selfPointer: UnsafeMutableRawPointer {
|
||||
Unmanaged.passUnretained(self).toOpaque()
|
||||
}
|
||||
|
||||
/// The core retains these pointers for the handle's lifetime.
|
||||
private var clientNamePtr: UnsafeMutablePointer<CChar>?
|
||||
private var clientVersionPtr: UnsafeMutablePointer<CChar>?
|
||||
private var tofuStorePathPtr: UnsafeMutablePointer<CChar>?
|
||||
|
||||
// MARK: - Event / level delivery (main-queue)
|
||||
|
||||
/// Called on the main queue for every event, in order, never coalesced. Set this from
|
||||
/// the main thread (AppKit/SwiftUI) to drive your UI.
|
||||
public var onEvent: ((VoiceCatEvent) -> Void)?
|
||||
|
||||
/// Called on the main queue with the latest RMS level per stream_id since the last drain.
|
||||
/// Intermediate values are coalesced (only the latest per stream_id is delivered).
|
||||
public var onLevel: ((UInt32, Float) -> Void)?
|
||||
|
||||
private let bufferLock = NSLock()
|
||||
private var eventBuffer: [VoiceCatEvent] = []
|
||||
private var levelSamples: [UInt32: Float] = [:]
|
||||
private var drainScheduled = false
|
||||
|
||||
// MARK: - Init / deinit
|
||||
|
||||
public init(config: VoiceCatConfig) {
|
||||
self.clientNamePtr = strdup(config.clientName)
|
||||
self.clientVersionPtr = strdup(config.clientVersion)
|
||||
self.tofuStorePathPtr = config.tofuStorePath.flatMap { strdup($0) }
|
||||
self.handle = nil // placeholder — set below after callbacks are wired
|
||||
|
||||
var nativeConfig = vc_config()
|
||||
nativeConfig.client_name = UnsafePointer(clientNamePtr)
|
||||
nativeConfig.client_version = UnsafePointer(clientVersionPtr)
|
||||
nativeConfig.log_level = config.logLevel.cValue
|
||||
nativeConfig.tofu_store_path = UnsafePointer(tofuStorePathPtr)
|
||||
|
||||
let callbacks = Callbacks.make(user: selfPointer)
|
||||
self.handle = vc_client_create(&nativeConfig, callbacks)
|
||||
|
||||
if handle == nil {
|
||||
free(clientNamePtr); clientNamePtr = nil
|
||||
free(clientVersionPtr); clientVersionPtr = nil
|
||||
if let tofuStorePathPtr { free(tofuStorePathPtr); self.tofuStorePathPtr = nil }
|
||||
fatalError("vc_client_create returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let handle {
|
||||
// Joins every internal thread synchronously — no callbacks can fire after this
|
||||
// returns, so the selfPointer and config-string pointers are safe to free.
|
||||
vc_client_destroy(handle)
|
||||
self.handle = nil
|
||||
}
|
||||
// Free config strings AFTER destroy (the core may have been reading them up until
|
||||
// destroy joined the io thread).
|
||||
free(clientNamePtr); clientNamePtr = nil
|
||||
free(clientVersionPtr); clientVersionPtr = nil
|
||||
if let tofuStorePathPtr { free(tofuStorePathPtr); self.tofuStorePathPtr = nil }
|
||||
}
|
||||
|
||||
// MARK: - Internal: event/level enqueue (called from the core's event thread)
|
||||
|
||||
/// Called by Callbacks.onEvent on the core's event thread. Buffers the event and
|
||||
/// schedules a coalesced main-queue drain.
|
||||
internal func enqueueEvent(_ event: VoiceCatEvent) {
|
||||
bufferLock.lock()
|
||||
eventBuffer.append(event)
|
||||
let shouldSchedule = !drainScheduled
|
||||
drainScheduled = true
|
||||
bufferLock.unlock()
|
||||
if shouldSchedule {
|
||||
DispatchQueue.main.async { [weak self] in self?.drain() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Called by Callbacks.onLevel on the core's event thread. Coalesces to latest-per-stream
|
||||
/// and schedules a coalesced main-queue drain.
|
||||
internal func enqueueLevel(_ streamId: UInt32, _ rms: Float) {
|
||||
bufferLock.lock()
|
||||
levelSamples[streamId] = rms
|
||||
let shouldSchedule = !drainScheduled
|
||||
drainScheduled = true
|
||||
bufferLock.unlock()
|
||||
if shouldSchedule {
|
||||
DispatchQueue.main.async { [weak self] in self?.drain() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Drains buffered events + coalesced levels on the main queue. Only one drain is
|
||||
/// scheduled at a time (debounced via `drainScheduled`).
|
||||
private func drain() {
|
||||
bufferLock.lock()
|
||||
let events = eventBuffer
|
||||
eventBuffer.removeAll()
|
||||
let levels = levelSamples
|
||||
levelSamples.removeAll()
|
||||
drainScheduled = false
|
||||
bufferLock.unlock()
|
||||
|
||||
for event in events { onEvent?(event) }
|
||||
for (streamId, rms) in levels { onLevel?(streamId, rms) }
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle (statics)
|
||||
|
||||
/// The core's version string (e.g. "VoiceCat 0.0.1 (protocol v1)"). Static literal — never freed.
|
||||
public static var versionString: String {
|
||||
String(cString: vc_version_string())
|
||||
}
|
||||
|
||||
/// Human-readable description of a result code. Static literal — never freed.
|
||||
public static func resultString(_ code: VoiceCatResult) -> String {
|
||||
String(cString: vc_result_string(code.cValue))
|
||||
}
|
||||
|
||||
// MARK: - Connection & auth (async; results via onEvent)
|
||||
|
||||
@discardableResult
|
||||
public func connect(host: String, port: UInt16) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_connect(handle, host, port))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func disconnect() -> VoiceCatResult {
|
||||
VoiceCatResult(vc_disconnect(handle))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func authenticateGuest(_ nickname: String) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_authenticate_guest(handle, nickname))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func authenticateUser(_ username: String, password: String) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_authenticate_user(handle, username, password))
|
||||
}
|
||||
|
||||
// MARK: - TOFU server-identity gate
|
||||
|
||||
/// Accept or reject the pending server-identity check. Call after a `.serverIdentity`
|
||||
/// event. `accept=true` on firstConnect/mismatch updates the pin file and proceeds;
|
||||
/// `accept=false` aborts (emits `.disconnected` with `.crypto`). See docs/security.md §1.1.
|
||||
@discardableResult
|
||||
public func confirmServerIdentity(accept: Bool) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_confirm_server_identity(handle, accept ? 1 : 0))
|
||||
}
|
||||
|
||||
/// The Ed25519 identity fingerprint from ServerHello, hex-formatted — DISPLAY ONLY, not
|
||||
/// the value the TOFU gate pins on (see docs/security.md §1.1). Empty if not yet available.
|
||||
/// Uses the two-call idiom: query size with nil buffer, then allocate + fetch.
|
||||
public func getServerIdentityDisplay() -> String {
|
||||
var len: Int = 0
|
||||
_ = vc_get_server_identity_display(handle, nil, 0, &len)
|
||||
if len == 0 { return "" }
|
||||
let buf = UnsafeMutablePointer<CChar>.allocate(capacity: len + 1)
|
||||
defer { buf.deallocate() }
|
||||
_ = vc_get_server_identity_display(handle, buf, len + 1, &len)
|
||||
return String(cString: buf)
|
||||
}
|
||||
|
||||
// MARK: - Channels
|
||||
|
||||
/// Join a channel. Result arrives as a `.joinResult` event (not via the return value,
|
||||
/// which only reflects "request queued"). `password` is for password-protected channels.
|
||||
@discardableResult
|
||||
public func joinChannel(_ channelId: UInt32, password: String? = nil) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_join_channel(handle, channelId, password))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func leaveChannel() -> VoiceCatResult {
|
||||
VoiceCatResult(vc_leave_channel(handle))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func joinVoice() -> VoiceCatResult {
|
||||
VoiceCatResult(vc_join_voice(handle))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func leaveVoice() -> VoiceCatResult {
|
||||
VoiceCatResult(vc_leave_voice(handle))
|
||||
}
|
||||
|
||||
/// Pull the current channel tree. Re-call after `.channelList`/`.userJoined`/`.userLeft`/
|
||||
/// `.userUpdated` events. The native list is freed inside this call — callers never
|
||||
/// manage native lifetime.
|
||||
public func listChannels() -> [Channel] {
|
||||
var native = vc_channel_list()
|
||||
_ = vc_list_channels(handle, &native)
|
||||
return Marshaling.channels(&native)
|
||||
}
|
||||
|
||||
public func listUsers() -> [User] {
|
||||
var native = vc_user_list()
|
||||
_ = vc_list_users(handle, &native)
|
||||
return Marshaling.users(&native)
|
||||
}
|
||||
|
||||
public func listUserStreams(_ userId: UInt32) -> [StreamSummary] {
|
||||
var native = vc_stream_summary_list()
|
||||
let r = vc_list_user_streams(handle, userId, &native)
|
||||
guard r == VC_OK else { return [] }
|
||||
return Marshaling.streamSummaries(&native)
|
||||
}
|
||||
|
||||
// MARK: - Local media streams
|
||||
|
||||
/// Start a mic / screen-audio / aux stream. Returns `(result, streamId)` — `streamId`
|
||||
/// is non-zero on success. The `label` and `deviceId` C strings are only needed for the
|
||||
/// duration of the call (the core copies what it needs), so we use temporary strdup'd
|
||||
/// buffers freed via `defer`.
|
||||
@discardableResult
|
||||
public func startStream(_ descriptor: StreamDescriptor) -> (VoiceCatResult, UInt32) {
|
||||
var streamId: UInt32 = 0
|
||||
let labelPtr = strdup(descriptor.label)
|
||||
defer { free(labelPtr) }
|
||||
let deviceIdPtr = descriptor.deviceId.flatMap { strdup($0) }
|
||||
defer { if let deviceIdPtr { free(deviceIdPtr) } }
|
||||
|
||||
var desc = vc_stream_desc()
|
||||
desc.kind = descriptor.kind.cValue
|
||||
desc.device_id = deviceIdPtr.map { UnsafePointer($0) }
|
||||
desc.label = UnsafePointer(labelPtr)
|
||||
desc.external_feed = descriptor.externalFeed ? 1 : 0
|
||||
|
||||
let r = vc_stream_start(handle, &desc, &streamId)
|
||||
return (VoiceCatResult(r), streamId)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func stopStream(_ streamId: UInt32) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_stream_stop(handle, streamId))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func setInputDevice(streamId: UInt32, deviceId: String?) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_set_input_device(handle, streamId, deviceId))
|
||||
}
|
||||
|
||||
/// Set the capture channel count for a local MIC stream (1 = mono, 2 = stereo interleaved).
|
||||
/// Takes effect on the next AudioEngine restart (immediately if already running). Used by
|
||||
/// the iOS `IOSAudioRouter` when the user picks stereo built-in mic capture.
|
||||
@discardableResult
|
||||
public func setCaptureChannels(streamId: UInt32, channels: UInt32) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_set_capture_channels(handle, streamId, channels))
|
||||
}
|
||||
|
||||
// MARK: - External PCM feed / tap
|
||||
|
||||
/// External PCM feed — drives a local stream's encode pipeline with caller-supplied PCM
|
||||
/// instead of (or in addition to) a hardware capture device. Intended for ReplayKit
|
||||
/// Broadcast Extension (iOS), ScreenCaptureKit (macOS), bots, and soundboard use cases.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - streamId: The stream returned by `startStream`. Must be active.
|
||||
/// - pcm: Raw int16 PCM pointer. Caller must keep the buffer alive for the duration of the call.
|
||||
/// - samplesPerChannel: Samples per channel (e.g. 960 for 20 ms @ 48 kHz).
|
||||
/// - channels: 1 (mono) or 2 (stereo interleaved L/R).
|
||||
@discardableResult
|
||||
public func feedPcm(streamId: UInt32, pcm: UnsafePointer<Int16>,
|
||||
samplesPerChannel: Int, channels: UInt32) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_stream_feed_pcm(handle, streamId, pcm,
|
||||
samplesPerChannel, channels))
|
||||
}
|
||||
|
||||
/// Convenience overload for feeding from a Swift `[Int16]` array.
|
||||
@discardableResult
|
||||
public func feedPcm(streamId: UInt32, pcm: [Int16],
|
||||
samplesPerChannel: Int, channels: UInt32) -> VoiceCatResult {
|
||||
pcm.withUnsafeBufferPointer {
|
||||
feedPcm(streamId: streamId, pcm: $0.baseAddress!,
|
||||
samplesPerChannel: samplesPerChannel, channels: channels)
|
||||
}
|
||||
}
|
||||
|
||||
/// External PCM tap — receive decoded per-stream audio as raw int16 PCM before it
|
||||
/// reaches the hardware mix. Fires once per decoded Opus frame per remote stream.
|
||||
///
|
||||
/// The callback is a C function pointer (`@convention(c)`) receiving:
|
||||
/// `(user, userId, streamId, pcm, samplesPerChannel, channels, sampleRate)`
|
||||
///
|
||||
/// Pass `nil` to disable (default). The callback MUST NOT block or allocate.
|
||||
@discardableResult
|
||||
public func setPcmSink(_ cb: VoiceCatPcmSinkCallback?, user: UnsafeMutableRawPointer?) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_set_pcm_sink(handle, cb, user))
|
||||
}
|
||||
|
||||
/// External mixed-output sink (iOS VPIO) — receives the FINAL mixed remote audio as int16
|
||||
/// PCM on the core's mixer-timer thread when external playback is enabled. The Swift VPIO
|
||||
/// renderer copies this into its ring and plays it through the voice-processing output so
|
||||
/// echo cancellation has its reference signal. Pass `nil` to disable. Mirrors
|
||||
/// `vc_set_mixed_output_sink`. The callback MUST NOT block or allocate.
|
||||
@discardableResult
|
||||
public func setMixedOutputSink(_ cb: VoiceCatMixedOutputCallback?,
|
||||
user: UnsafeMutableRawPointer?) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_set_mixed_output_sink(handle, cb, user))
|
||||
}
|
||||
|
||||
/// Enable/disable external-playback mode (iOS VPIO). When enabled, the core opens NO hardware
|
||||
/// playback device; it drives decode+mix on a timer and delivers the final mix via
|
||||
/// `setMixedOutputSink`. Apply before the engine starts, or follow with `audioRestart()` to
|
||||
/// apply to a running engine. Mirrors `vc_set_external_playback`.
|
||||
@discardableResult
|
||||
public func setExternalPlayback(_ enabled: Bool) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_set_external_playback(handle, enabled ? 1 : 0))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func setInputMode(_ mode: VoiceCatInputMode) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_set_input_mode(handle, mode.cValue))
|
||||
}
|
||||
|
||||
/// VAD threshold: normalized RMS 0.0–1.0 (default ~0.025). Takes effect immediately.
|
||||
@discardableResult
|
||||
public func setVadThreshold(_ threshold: Float) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_set_vad_threshold(handle, threshold))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func setPushToTalk(_ active: Bool) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_set_push_to_talk(handle, active ? 1 : 0))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func setSelfMute(micMuted: Bool, deafened: Bool) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_set_self_mute(handle, micMuted ? 1 : 0, deafened ? 1 : 0))
|
||||
}
|
||||
|
||||
/// Global playback volume applied after mixing all remote streams. gain 0.0 = silent,
|
||||
/// 1.0 = unity (default), >1.0 amplifies. Always LOCAL — no protocol traffic. Mirrors the
|
||||
/// Windows client's `SetOutputVolume` and the C ABI `vc_set_output_volume`.
|
||||
@discardableResult
|
||||
public func setOutputVolume(_ gain: Float) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_set_output_volume(handle, gain < 0 ? 0 : gain))
|
||||
}
|
||||
|
||||
/// Send-side microphone input gain. Applied to captured MIC PCM before the VAD/PTT gate and
|
||||
/// Opus encode (so boosting a quiet mic also helps it cross the VAD threshold). gain 0.0 =
|
||||
/// silent, 1.0 = unity (default), >1.0 amplifies (clamped to int16). Always LOCAL.
|
||||
@discardableResult
|
||||
public func setInputGain(_ gain: Float) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_set_input_gain(handle, gain < 0 ? 0 : gain))
|
||||
}
|
||||
|
||||
/// Send-side microphone noise suppression (RNNoise). Denoises captured MIC PCM before the
|
||||
/// input gain and VAD/PTT gate, so everyone hears the cleaned signal (one pass for all
|
||||
/// listeners). MIC stream only, mono only; always LOCAL — no protocol traffic. Independent
|
||||
/// of the per-listener receive-side NR in `setRemoteStream` (docs/voice.md §10).
|
||||
@discardableResult
|
||||
public func setInputNoiseReduction(_ enable: Bool) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_set_input_noise_reduction(handle, enable ? 1 : 0))
|
||||
}
|
||||
|
||||
// MARK: - AVAudioSession interruption hooks (iOS)
|
||||
|
||||
/// Pause miniaudio device I/O. Call when AVAudioSession interruption begins.
|
||||
@discardableResult
|
||||
public func audioSuspend() -> VoiceCatResult {
|
||||
VoiceCatResult(vc_audio_suspend(handle))
|
||||
}
|
||||
|
||||
/// Resume miniaudio device I/O. Call after re-activating AVAudioSession.
|
||||
@discardableResult
|
||||
public func audioResume() -> VoiceCatResult {
|
||||
VoiceCatResult(vc_audio_resume(handle))
|
||||
}
|
||||
|
||||
/// Full audio engine restart — uninitialize and re-initialize the capture and playback
|
||||
/// devices so they pick up a new AVAudioSession route. Call this AFTER reconfiguring
|
||||
/// AVAudioSession (setCategory, setPreferredInput, setPreferredPolarPattern, etc.) so the
|
||||
/// core's devices reopen against the new route. Unlike `audioSuspend()`/`audioResume()`
|
||||
/// (which only stop/start the existing devices, leaving them bound to the route that was
|
||||
/// active when they were opened), this fully re-initializes them. Safe to call when the
|
||||
/// engine is not running (it will just start it).
|
||||
@discardableResult
|
||||
public func audioRestart() -> VoiceCatResult {
|
||||
VoiceCatResult(vc_audio_restart(handle))
|
||||
}
|
||||
|
||||
// MARK: - Receive-side, per remote stream (LOCAL — no protocol traffic; docs/voice.md §10)
|
||||
|
||||
@discardableResult
|
||||
public func setRemoteStream(userId: UInt32, streamId: UInt32, gain: Float,
|
||||
muted: Bool, noiseReduction: Bool) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_set_remote_stream(handle, userId, streamId, gain,
|
||||
muted ? 1 : 0, noiseReduction ? 1 : 0))
|
||||
}
|
||||
|
||||
public func getRemoteStream(userId: UInt32, streamId: UInt32) -> (VoiceCatResult, RemoteStreamState?) {
|
||||
var state = vc_remote_stream_state()
|
||||
let r = vc_get_remote_stream(handle, userId, streamId, &state)
|
||||
guard r == VC_OK else { return (VoiceCatResult(r), nil) }
|
||||
return (VoiceCatResult(r), Marshaling.remoteStreamState(state))
|
||||
}
|
||||
|
||||
public func getStreamAudioConfig(userId: UInt32, streamId: UInt32) -> (VoiceCatResult, AudioConfig?) {
|
||||
var cfg = vc_audio_config()
|
||||
let r = vc_get_stream_audio_config(handle, userId, streamId, &cfg)
|
||||
guard r == VC_OK else { return (VoiceCatResult(r), nil) }
|
||||
return (VoiceCatResult(r), Marshaling.audioConfig(cfg))
|
||||
}
|
||||
|
||||
// MARK: - Text
|
||||
|
||||
@discardableResult
|
||||
public func sendText(scope: VoiceCatTextScope, targetId: UInt32, text: String) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_send_text(handle, scope.cValue, targetId, text))
|
||||
}
|
||||
|
||||
// MARK: - Device enumeration (works pre-connect)
|
||||
|
||||
public func listDevices(_ kind: VoiceCatDeviceKind) -> [Device] {
|
||||
var native = vc_device_list()
|
||||
_ = vc_list_devices(handle, kind.cValue, &native)
|
||||
return Marshaling.devices(&native)
|
||||
}
|
||||
|
||||
// MARK: - Moderation
|
||||
|
||||
@discardableResult
|
||||
public func kickUser(_ userId: UInt32, reason: String? = nil) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_kick_user(handle, userId, reason))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func banUser(_ userId: UInt32, reason: String? = nil,
|
||||
expiresUnixMs: UInt64 = 0) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_ban_user(handle, userId, reason, expiresUnixMs))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func setPermission(_ userId: UInt32, perms: Permissions) -> VoiceCatResult {
|
||||
var native = vc_permissions()
|
||||
native.can_create_temp_channel = perms.canCreateTempChannel ? 1 : 0
|
||||
native.can_kick = perms.canKick ? 1 : 0
|
||||
native.can_ban = perms.canBan ? 1 : 0
|
||||
native.can_move_users = perms.canMoveUsers ? 1 : 0
|
||||
native.can_admin_accounts = perms.canAdminAccounts ? 1 : 0
|
||||
native.is_admin = perms.isAdmin ? 1 : 0
|
||||
return VoiceCatResult(vc_set_permission(handle, userId, &native))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func setServerMute(_ userId: UInt32, muted: Bool, deafened: Bool) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_set_server_mute(handle, userId, muted ? 1 : 0, deafened ? 1 : 0))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func moveUser(_ userId: UInt32, toChannel channelId: UInt32) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_move_user(handle, userId, channelId))
|
||||
}
|
||||
|
||||
// MARK: - Channel admin
|
||||
|
||||
@discardableResult
|
||||
public func createChannel(_ info: ChannelEdit) -> VoiceCatResult {
|
||||
var native = vc_channel_info()
|
||||
Self.populateChannelInfo(&native, from: info)
|
||||
defer { Self.freeChannelInfoStrings(&native) }
|
||||
return VoiceCatResult(vc_create_channel(handle, &native))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func editChannel(_ info: ChannelEdit) -> VoiceCatResult {
|
||||
var native = vc_channel_info()
|
||||
Self.populateChannelInfo(&native, from: info)
|
||||
defer { Self.freeChannelInfoStrings(&native) }
|
||||
return VoiceCatResult(vc_edit_channel(handle, &native))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func deleteChannel(_ channelId: UInt32) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_delete_channel(handle, channelId))
|
||||
}
|
||||
|
||||
// MARK: - Account admin
|
||||
|
||||
@discardableResult
|
||||
public func createAccount(_ username: String, password: String) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_create_account(handle, username, password))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func resetPassword(_ username: String, newPassword: String) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_reset_password(handle, username, newPassword))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func deleteAccount(_ username: String) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_delete_account(handle, username))
|
||||
}
|
||||
|
||||
/// Request the account list — result arrives as a `.accountList` event, then call
|
||||
/// `listAccounts()` to pull the cached list.
|
||||
@discardableResult
|
||||
public func requestAccountList() -> VoiceCatResult {
|
||||
VoiceCatResult(vc_list_accounts(handle))
|
||||
}
|
||||
|
||||
public func listAccounts() -> [Account] {
|
||||
var native = vc_account_list()
|
||||
_ = vc_get_account_list(handle, &native)
|
||||
return Marshaling.accounts(&native)
|
||||
}
|
||||
|
||||
public func getPermissions() -> Permissions {
|
||||
var native = vc_permissions()
|
||||
_ = vc_get_permissions(handle, &native)
|
||||
return Marshaling.permissions(native)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers for vc_channel_info / vc_audio_config construction
|
||||
|
||||
extension VoiceCatClient {
|
||||
/// Populate a `vc_channel_info` from a Swift `ChannelEdit`. The string fields
|
||||
/// (`name`/`topic`/`password`) are strdup'd — the caller MUST call
|
||||
/// `freeChannelInfoStrings(_:)` after the C call returns (the core copies what it needs
|
||||
/// during the call, so the temporary buffers can be freed via `defer`).
|
||||
internal static func populateChannelInfo(_ native: inout vc_channel_info, from info: ChannelEdit) {
|
||||
native.id = info.id
|
||||
native.parent_id = info.parentId
|
||||
native.name = UnsafePointer(strdup(info.name))
|
||||
native.topic = UnsafePointer(strdup(info.topic))
|
||||
native.password_protected = info.passwordProtected ? 1 : 0
|
||||
native.password = (info.passwordProtected && !(info.password?.isEmpty ?? true))
|
||||
? UnsafePointer(strdup(info.password!)) : nil
|
||||
native.max_users = info.maxUsers
|
||||
native.sort_order = info.sortOrder
|
||||
native.audio = info.audio.toNative()
|
||||
}
|
||||
|
||||
/// Free the strdup'd string fields of a `vc_channel_info` populated by
|
||||
/// `populateChannelInfo`. Call this in a `defer` after the C call.
|
||||
internal static func freeChannelInfoStrings(_ native: inout vc_channel_info) {
|
||||
if let p = native.name { free(UnsafeMutablePointer(mutating: p)); native.name = nil }
|
||||
if let p = native.topic { free(UnsafeMutablePointer(mutating: p)); native.topic = nil }
|
||||
if let p = native.password { free(UnsafeMutablePointer(mutating: p)); native.password = nil }
|
||||
}
|
||||
}
|
||||
|
||||
extension AudioConfig {
|
||||
/// Convert to a native `vc_audio_config`.
|
||||
internal func toNative() -> vc_audio_config {
|
||||
var n = vc_audio_config()
|
||||
n.codec = codec
|
||||
n.mode = stereo ? 1 : 0
|
||||
n.sample_rate = sampleRate
|
||||
n.bitrate_bps = bitrateBps
|
||||
n.frame_ms = frameMs
|
||||
n.application = application
|
||||
n.fec = fec ? 1 : 0
|
||||
n.expected_packet_loss = expectedPacketLoss
|
||||
n.dtx = dtx ? 1 : 0
|
||||
n.complexity = complexity
|
||||
n.dred = dred ? 1 : 0
|
||||
return n
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
// ExternalPcmTests — Swift wrapper smoke tests for vc_stream_feed_pcm / vc_set_pcm_sink.
|
||||
//
|
||||
// These tests verify that the Swift API surface compiles, is callable, and returns expected
|
||||
// results at the C-ABI boundary — without requiring a live server or audio hardware.
|
||||
// Full end-to-end relay / decode verification is covered by tests/test_external_pcm.cpp
|
||||
// (C++ ctest), which runs headlessly on all platforms.
|
||||
|
||||
import XCTest
|
||||
@testable import VoiceCatCore
|
||||
|
||||
final class ExternalPcmTests: XCTestCase {
|
||||
|
||||
// MARK: - feedPcm: API surface smoke
|
||||
|
||||
/// Calling feedPcm without a connected client or active stream must return .invalidArg
|
||||
/// (not crash). Proves the Swift→C bridge compiles and handles the error path.
|
||||
func testFeedPcm_noActiveStream_returnsInvalidArg() {
|
||||
let client = VoiceCatClient(config: VoiceCatConfig(
|
||||
clientName: "ext-pcm-test",
|
||||
clientVersion: "0.1",
|
||||
logLevel: .off
|
||||
))
|
||||
let sine = [Int16](repeating: 0, count: 960)
|
||||
// Stream 0 doesn't exist — the core must return invalidArg, not crash.
|
||||
let result = client.feedPcm(streamId: 0, pcm: sine, samplesPerChannel: 960, channels: 1)
|
||||
XCTAssertEqual(result, .invalidArg)
|
||||
}
|
||||
|
||||
/// Calling feedPcm with channels=3 (invalid) must return .invalidArg.
|
||||
func testFeedPcm_invalidChannels_returnsInvalidArg() {
|
||||
let client = VoiceCatClient(config: VoiceCatConfig(
|
||||
clientName: "ext-pcm-test",
|
||||
clientVersion: "0.1",
|
||||
logLevel: .off
|
||||
))
|
||||
let pcm = [Int16](repeating: 0, count: 960 * 3)
|
||||
let result = client.feedPcm(streamId: 0, pcm: pcm, samplesPerChannel: 960, channels: 3)
|
||||
XCTAssertEqual(result, .invalidArg)
|
||||
}
|
||||
|
||||
// MARK: - setPcmSink: API surface smoke
|
||||
|
||||
/// setPcmSink(nil) on a freshly-created client must succeed (nil = disable, which is the
|
||||
/// default state — a no-op that must still return .ok).
|
||||
func testSetPcmSink_nil_returnsOk() {
|
||||
let client = VoiceCatClient(config: VoiceCatConfig(
|
||||
clientName: "ext-pcm-test",
|
||||
clientVersion: "0.1",
|
||||
logLevel: .off
|
||||
))
|
||||
let result = client.setPcmSink(nil, user: nil)
|
||||
XCTAssertEqual(result, .ok)
|
||||
}
|
||||
|
||||
/// Calling setPcmSink with a @convention(c) function and then immediately disabling it
|
||||
/// with nil must both succeed. Verifies the C-ABI function-pointer round-trip.
|
||||
func testSetPcmSink_enableThenDisable_bothSucceed() {
|
||||
let client = VoiceCatClient(config: VoiceCatConfig(
|
||||
clientName: "ext-pcm-test",
|
||||
clientVersion: "0.1",
|
||||
logLevel: .off
|
||||
))
|
||||
|
||||
let mySink: VoiceCatPcmSinkCallback = { _, _, _, _, _, _, _ in }
|
||||
XCTAssertEqual(client.setPcmSink(mySink, user: nil), .ok)
|
||||
XCTAssertEqual(client.setPcmSink(nil, user: nil), .ok)
|
||||
}
|
||||
}
|
||||
@@ -1,505 +0,0 @@
|
||||
// VoiceCatClientSmokeTests — exercises the full connect → TOFU → auth → channels →
|
||||
// moderation flow purely through the Swift wrapper layer (VoiceCatClient), against a real
|
||||
// `voicecat-server` (the same binary the C++ ctest suite uses, built by `cmake --preset dev`).
|
||||
// This is the Swift analog of clients/windows/VoiceCat.Interop.Tests/VoiceCatClientSmokeTests.cs.
|
||||
//
|
||||
// Why this exists (same rationale as the C# tests): the C++ ctest suite proves the protocol
|
||||
// works at the C++ level, but Swift-specific interop bugs — @convention(c) callback lifetime,
|
||||
// Unmanaged pointer resolution, CString memory management, enum raw-value bridging, struct
|
||||
// field layout — can only be caught by exercising the exact Swift→C boundary. These tests
|
||||
// catch the same class of bugs the C# P/Invoke tests catch, for Swift.
|
||||
//
|
||||
// Prerequisites: `cmake --preset dev && cmake --build --preset dev` (builds voicecat-server
|
||||
// and voicecat-admin into build/dev/bin/), AND `scripts/build-xcframework.sh` (builds the
|
||||
// VoiceCatCore.xcframework that the Swift Package links).
|
||||
|
||||
import XCTest
|
||||
import Foundation
|
||||
@testable import VoiceCatCore
|
||||
|
||||
/// Manages a real `voicecat-server` process for the test suite's lifetime. Starts the server
|
||||
/// on an ephemeral port (--port 0), parses the bound port from stdout, and provisions a known
|
||||
/// admin account via `voicecat-admin`. Killed + cleaned up in deinit.
|
||||
private final class ServerHarness {
|
||||
let port: UInt16
|
||||
private let process: Process
|
||||
let tempDir: String
|
||||
|
||||
init() throws {
|
||||
let repoRoot = Self.findRepoRoot()
|
||||
let serverURL = URL(fileURLWithPath: repoRoot)
|
||||
.appendingPathComponent("build/dev/bin/voicecat-server")
|
||||
|
||||
guard FileManager.default.isExecutableFile(atPath: serverURL.path) else {
|
||||
throw NSError(domain: "VoiceCatTest", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "voicecat-server not found at \(serverURL.path) — "
|
||||
+ "build the dev preset first: cmake --preset dev && cmake --build --preset dev",
|
||||
])
|
||||
}
|
||||
|
||||
let tempDir = NSTemporaryDirectory() + "vc_swift_smoke_" + UUID().uuidString
|
||||
try FileManager.default.createDirectory(atPath: tempDir, withIntermediateDirectories: true)
|
||||
self.tempDir = tempDir
|
||||
|
||||
let p = Process()
|
||||
p.executableURL = serverURL
|
||||
p.arguments = ["--port", "0", "--data-dir", tempDir, "--name", "SwiftSmokeTest"]
|
||||
|
||||
// Pipe stdout to read the bound port; stderr to /dev/null.
|
||||
let stdoutPipe = Pipe()
|
||||
p.standardOutput = stdoutPipe
|
||||
p.standardError = FileHandle(forWritingAtPath: "/dev/null")
|
||||
try p.run()
|
||||
self.process = p
|
||||
|
||||
// Parse "[voicecat-server] ... — TCP :<port> UDP :<port>" from stdout. The server
|
||||
// prints several lines before the port line (version, first-run admin box, etc.), so
|
||||
// we keep reading until we find a line matching "TCP :<port>". Read with a 10s timeout
|
||||
// so a crashed/hung server can't hang the test forever.
|
||||
guard let port = Self.readPortWithTimeout(stdoutPipe, timeout: 10) else {
|
||||
p.terminate()
|
||||
throw NSError(domain: "VoiceCatTest", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "voicecat-server did not report a bound TCP port within 10s",
|
||||
])
|
||||
}
|
||||
self.port = port
|
||||
|
||||
// Provision a known admin account for moderation/admin tests.
|
||||
let adminURL = URL(fileURLWithPath: repoRoot)
|
||||
.appendingPathComponent("build/dev/bin/voicecat-admin")
|
||||
guard FileManager.default.isExecutableFile(atPath: adminURL.path) else {
|
||||
throw NSError(domain: "VoiceCatTest", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "voicecat-admin not found at \(adminURL.path)",
|
||||
])
|
||||
}
|
||||
let adminProc = Process()
|
||||
adminProc.executableURL = adminURL
|
||||
adminProc.arguments = ["--data-dir", tempDir, "account", "add", "admin2",
|
||||
"--admin", "--password", "testpassword123"]
|
||||
adminProc.standardOutput = FileHandle(forWritingAtPath: "/dev/null")
|
||||
adminProc.standardError = FileHandle(forWritingAtPath: "/dev/null")
|
||||
try adminProc.run()
|
||||
adminProc.waitUntilExit()
|
||||
guard adminProc.terminationStatus == 0 else {
|
||||
throw NSError(domain: "VoiceCatTest", code: 4, userInfo: [
|
||||
NSLocalizedDescriptionKey: "voicecat-admin failed to provision admin2 (exit \(adminProc.terminationStatus))",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
if process.isRunning { process.terminate() }
|
||||
try? FileManager.default.removeItem(atPath: tempDir)
|
||||
}
|
||||
|
||||
private static func findRepoRoot() -> String {
|
||||
var url = URL(fileURLWithPath: #file)
|
||||
while url.path != "/" && !FileManager.default.fileExists(atPath: url.appendingPathComponent("CMakePresets.json").path) {
|
||||
url = url.deletingLastPathComponent()
|
||||
}
|
||||
guard FileManager.default.fileExists(atPath: url.appendingPathComponent("CMakePresets.json").path) else {
|
||||
fatalError("Could not find repo root (CMakePresets.json) above \(#file)")
|
||||
}
|
||||
return url.path
|
||||
}
|
||||
|
||||
/// Read from the server's stdout until a line matching "TCP :<port>" is found, or the
|
||||
/// timeout expires. The server prints several lines (version banner, first-run admin box,
|
||||
/// etc.) before the port line — see server/src/server.cpp.
|
||||
private static func readPortWithTimeout(_ pipe: Pipe, timeout: TimeInterval) -> UInt16? {
|
||||
let handle = pipe.fileHandleForReading
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
var buffer = Data()
|
||||
while Date() < deadline {
|
||||
let data = handle.availableData
|
||||
if !data.isEmpty {
|
||||
buffer.append(data)
|
||||
// Check each complete line in the buffer for "TCP :<port>".
|
||||
while let newlineIdx = buffer.firstIndex(of: 0x0A) {
|
||||
let lineData = buffer.prefix(newlineIdx)
|
||||
buffer = buffer.suffix(from: buffer.index(after: newlineIdx))
|
||||
if let line = String(data: lineData, encoding: .utf8),
|
||||
let port = parsePort(from: line) {
|
||||
return port
|
||||
}
|
||||
}
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 0.05)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func parsePort(from line: String) -> UInt16? {
|
||||
// Match "TCP :<port>" — see server/src/server.cpp.
|
||||
guard let range = line.range(of: #"TCP :(\d+)"#, options: .regularExpression) else { return nil }
|
||||
let digits = line[range].split(separator: ":").last ?? ""
|
||||
return UInt16(digits.trimmingCharacters(in: .whitespaces))
|
||||
}
|
||||
}
|
||||
|
||||
/// XCTest smoke tests against a real voicecat-server, through the Swift VoiceCatClient wrapper.
|
||||
final class VoiceCatClientSmokeTests: XCTestCase {
|
||||
private static var harness: ServerHarness?
|
||||
|
||||
override class func setUp() {
|
||||
do {
|
||||
harness = try ServerHarness()
|
||||
} catch {
|
||||
// Store the error so each test fails with a clear message rather than a crash.
|
||||
NSLog("ServerHarness setup failed: \(error.localizedDescription)")
|
||||
harness = nil
|
||||
}
|
||||
}
|
||||
|
||||
override class func tearDown() {
|
||||
harness = nil
|
||||
}
|
||||
|
||||
private var port: UInt16 {
|
||||
guard let p = Self.harness?.port else {
|
||||
XCTFail("ServerHarness not started — see setUp error in log")
|
||||
return 0
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
private var tempDir: String {
|
||||
Self.harness?.tempDir ?? NSTemporaryDirectory()
|
||||
}
|
||||
|
||||
/// Helper: wait until the predicate is satisfied, running the main runloop to process
|
||||
/// dispatched events. The Swift analog of the C# `PumpUntil` helper. Our events are
|
||||
/// delivered via DispatchQueue.main.async, which the main runloop processes during
|
||||
/// `RunLoop.current.run(until:)`.
|
||||
///
|
||||
/// Uses RunLoop polling (not XCTestExpectation) so that the "assert something does NOT
|
||||
/// happen within N seconds" pattern works without generating spurious "Asynchronous wait
|
||||
/// failed" errors — `wait(for:timeout:)` logs an error when an expectation isn't
|
||||
/// fulfilled, which is wrong for negative checks.
|
||||
private func waitFor(timeout: TimeInterval = 5, _ predicate: @escaping () -> Bool) -> Bool {
|
||||
if predicate() { return true }
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
// Run the main runloop for ~20ms — processes DispatchQueue.main.async blocks
|
||||
// (where our events/levels are drained) and timer sources.
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.02))
|
||||
if predicate() { return true }
|
||||
}
|
||||
return predicate()
|
||||
}
|
||||
|
||||
private func requireHarness() -> Bool {
|
||||
guard Self.harness != nil else {
|
||||
XCTFail("ServerHarness not started — see setUp error in log")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
func testVersionStringIsNonEmpty() {
|
||||
XCTAssertFalse(VoiceCatClient.versionString.isEmpty)
|
||||
}
|
||||
|
||||
func testResultStringRoundTrips() {
|
||||
XCTAssertFalse(VoiceCatClient.resultString(.ok).isEmpty)
|
||||
XCTAssertFalse(VoiceCatClient.resultString(.permissionDenied).isEmpty)
|
||||
}
|
||||
|
||||
/// Full connect → TOFU → confirm → guest auth → list channels → permissions → guest
|
||||
/// ListAccounts rejected. Mirrors the C# `Connect_Tofu_Auth_ListChannels_RoundTrips`.
|
||||
func testConnectTofuAuthListChannelsRoundTrips() throws {
|
||||
guard requireHarness() else { return }
|
||||
|
||||
var events: [VoiceCatEvent] = []
|
||||
let client = VoiceCatClient(config: VoiceCatConfig(
|
||||
clientName: "vc-swift-smoke",
|
||||
clientVersion: "0.1",
|
||||
logLevel: .off,
|
||||
tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins.txt")
|
||||
))
|
||||
client.onEvent = { events.append($0) }
|
||||
|
||||
XCTAssertEqual(client.connect(host: "127.0.0.1", port: port), .ok)
|
||||
XCTAssertEqual(client.authenticateGuest("SwiftSmoke"), .ok)
|
||||
|
||||
// Wait for VC_EVENT_SERVER_IDENTITY.
|
||||
XCTAssertTrue(waitFor { events.contains { $0.type == .serverIdentity } },
|
||||
"did not receive .serverIdentity")
|
||||
let identityEvent = try XCTUnwrap(events.first { $0.type == .serverIdentity })
|
||||
XCTAssertEqual(identityEvent.tofuStatus, .firstConnect)
|
||||
XCTAssertNotNil(identityEvent.text)
|
||||
XCTAssertEqual(identityEvent.text?.count, 64, "SHA-256 hex, no separators")
|
||||
|
||||
// Auth must NOT complete before identity is confirmed (800ms, like the C# test).
|
||||
XCTAssertFalse(waitFor(timeout: 0.8) { events.contains { $0.type == .authResult } },
|
||||
"auth completed before identity confirmation (should be held open)")
|
||||
|
||||
XCTAssertEqual(client.confirmServerIdentity(accept: true), .ok)
|
||||
|
||||
// Wait for VC_EVENT_AUTH_RESULT.
|
||||
XCTAssertTrue(waitFor { events.contains { $0.type == .authResult } },
|
||||
"did not receive .authResult after confirming identity")
|
||||
let authEvent = try XCTUnwrap(events.first { $0.type == .authResult })
|
||||
XCTAssertEqual(authEvent.result, .ok)
|
||||
|
||||
// Wait for VC_EVENT_CHANNEL_LIST.
|
||||
XCTAssertTrue(waitFor { events.contains { $0.type == .channelList } },
|
||||
"did not receive .channelList")
|
||||
|
||||
let channels = client.listChannels()
|
||||
XCTAssertTrue(channels.contains { $0.id == 1 && $0.name == "Lobby" },
|
||||
"expected Lobby (channel 1) in \(channels.map { $0.name })")
|
||||
|
||||
// Permissions getter round-trip.
|
||||
let perms = client.getPermissions()
|
||||
XCTAssertFalse(perms.isAdmin)
|
||||
XCTAssertFalse(perms.canKick)
|
||||
|
||||
// Guest ListAccounts is rejected by the server with a GenericResult — proves the
|
||||
// moderation wrapper path works end-to-end through the Swift interop layer.
|
||||
events.removeAll()
|
||||
XCTAssertEqual(client.requestAccountList(), .ok)
|
||||
XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult } },
|
||||
"did not receive .genericResult for guest ListAccounts")
|
||||
let generic = try XCTUnwrap(events.first { $0.type == .genericResult })
|
||||
XCTAssertEqual(generic.result, .permissionDenied)
|
||||
|
||||
client.disconnect()
|
||||
}
|
||||
|
||||
/// Admin auth → channel CRUD → account CRUD. Mirrors C# `Admin_ChannelCrud_AccountCrud_RoundTrips`.
|
||||
func testAdminChannelCrudAccountCrudRoundTrips() throws {
|
||||
guard requireHarness() else { return }
|
||||
|
||||
var events: [VoiceCatEvent] = []
|
||||
let client = VoiceCatClient(config: VoiceCatConfig(
|
||||
clientName: "vc-swift-admin",
|
||||
clientVersion: "0.1",
|
||||
logLevel: .off,
|
||||
tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins_admin.txt")
|
||||
))
|
||||
client.onEvent = { events.append($0) }
|
||||
|
||||
XCTAssertEqual(client.connect(host: "127.0.0.1", port: port), .ok)
|
||||
XCTAssertEqual(client.authenticateUser("admin2", password: "testpassword123"), .ok)
|
||||
|
||||
XCTAssertTrue(waitFor { events.contains { $0.type == .serverIdentity } })
|
||||
XCTAssertEqual(client.confirmServerIdentity(accept: true), .ok)
|
||||
XCTAssertTrue(waitFor { events.contains { $0.type == .authResult } })
|
||||
XCTAssertEqual(try XCTUnwrap(events.first { $0.type == .authResult }).result, .ok)
|
||||
XCTAssertTrue(waitFor { events.contains { $0.type == .channelList } })
|
||||
|
||||
let perms = client.getPermissions()
|
||||
XCTAssertTrue(perms.isAdmin || perms.canAdminAccounts)
|
||||
|
||||
// Channel CRUD — create.
|
||||
let audioConfig = AudioConfig(stereo: true, bitrateBps: 64000, frameMs: 20,
|
||||
application: 1, fec: true, expectedPacketLoss: 5, complexity: 10)
|
||||
XCTAssertEqual(client.createChannel(ChannelEdit(
|
||||
id: 0, parentId: 0, name: "Swift Test Channel", topic: "Created by Swift smoke test",
|
||||
passwordProtected: false, password: nil, maxUsers: 42, sortOrder: 0, audio: audioConfig
|
||||
)), .ok)
|
||||
XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } },
|
||||
"CreateChannel did not succeed")
|
||||
|
||||
var channels = client.listChannels()
|
||||
let created = try XCTUnwrap(channels.first { $0.name == "Swift Test Channel" })
|
||||
XCTAssertEqual(created.topic, "Created by Swift smoke test")
|
||||
XCTAssertFalse(created.passwordProtected)
|
||||
|
||||
// Channel CRUD — edit.
|
||||
events.removeAll()
|
||||
XCTAssertEqual(client.editChannel(ChannelEdit(
|
||||
id: created.id, parentId: created.parentId, name: created.name,
|
||||
topic: "Updated topic", passwordProtected: false, password: nil,
|
||||
maxUsers: 100, sortOrder: 0, audio: audioConfig
|
||||
)), .ok)
|
||||
XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } },
|
||||
"EditChannel did not succeed")
|
||||
|
||||
// Channel CRUD — delete.
|
||||
events.removeAll()
|
||||
XCTAssertEqual(client.deleteChannel(created.id), .ok)
|
||||
XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } },
|
||||
"DeleteChannel did not succeed")
|
||||
|
||||
// Account CRUD — create.
|
||||
events.removeAll()
|
||||
XCTAssertEqual(client.createAccount("swift_smoke_user", password: "initialpw"), .ok)
|
||||
XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } },
|
||||
"CreateAccount did not succeed")
|
||||
|
||||
// Account CRUD — list.
|
||||
events.removeAll()
|
||||
XCTAssertEqual(client.requestAccountList(), .ok)
|
||||
XCTAssertTrue(waitFor { events.contains { $0.type == .accountList } },
|
||||
"did not receive .accountList")
|
||||
let accounts = client.listAccounts()
|
||||
XCTAssertTrue(accounts.contains { $0.username == "swift_smoke_user" })
|
||||
|
||||
// Account CRUD — reset password.
|
||||
events.removeAll()
|
||||
XCTAssertEqual(client.resetPassword("swift_smoke_user", newPassword: "newpw123"), .ok)
|
||||
XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } },
|
||||
"ResetPassword did not succeed")
|
||||
|
||||
// Account CRUD — delete.
|
||||
events.removeAll()
|
||||
XCTAssertEqual(client.deleteAccount("swift_smoke_user"), .ok)
|
||||
XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } },
|
||||
"DeleteAccount did not succeed")
|
||||
|
||||
client.disconnect()
|
||||
}
|
||||
|
||||
/// Screen-audio (SCREEN_AUDIO) stream start/stop through the Swift wrapper. The core's
|
||||
/// macOS CoreAudio path starts the StreamAnnounce; this exercises the full
|
||||
/// startStream → .streamStarted → stopStream → .streamStopped path through Swift interop.
|
||||
/// Mirrors C# `ScreenAudioStream_Starts_And_Stops`.
|
||||
func testScreenAudioStreamStartsAndStops() throws {
|
||||
guard requireHarness() else { return }
|
||||
|
||||
var events: [VoiceCatEvent] = []
|
||||
let client = VoiceCatClient(config: VoiceCatConfig(
|
||||
clientName: "vc-swift-screen",
|
||||
clientVersion: "0.1",
|
||||
logLevel: .off,
|
||||
tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins_screen.txt")
|
||||
))
|
||||
client.onEvent = { events.append($0) }
|
||||
|
||||
XCTAssertEqual(client.connect(host: "127.0.0.1", port: port), .ok)
|
||||
XCTAssertEqual(client.authenticateGuest("SwiftScreen"), .ok)
|
||||
|
||||
XCTAssertTrue(waitFor { events.contains { $0.type == .serverIdentity } })
|
||||
XCTAssertEqual(client.confirmServerIdentity(accept: true), .ok)
|
||||
XCTAssertTrue(waitFor { events.contains { $0.type == .authResult } })
|
||||
XCTAssertEqual(try XCTUnwrap(events.first { $0.type == .authResult }).result, .ok)
|
||||
XCTAssertTrue(waitFor { events.contains { $0.type == .channelList } })
|
||||
|
||||
// Give the async UDP binding handshake a moment to land (mirrors vccli's 500ms sleep).
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
|
||||
let (startResult, streamId) = client.startStream(
|
||||
StreamDescriptor(kind: .screenAudio, label: "Desktop audio")
|
||||
)
|
||||
XCTAssertEqual(startResult, .ok)
|
||||
XCTAssertNotEqual(streamId, 0, "streamId should be non-zero on success")
|
||||
|
||||
// The core emits .streamStarted for the local client too.
|
||||
XCTAssertTrue(waitFor(timeout: 5) {
|
||||
events.contains { $0.type == .streamStarted && $0.streamId == streamId }
|
||||
}, "did not receive .streamStarted for screen-audio stream")
|
||||
|
||||
XCTAssertEqual(client.stopStream(streamId), .ok)
|
||||
XCTAssertTrue(waitFor(timeout: 5) {
|
||||
events.contains { $0.type == .streamStopped && $0.streamId == streamId }
|
||||
}, "did not receive .streamStopped for screen-audio stream")
|
||||
|
||||
client.disconnect()
|
||||
}
|
||||
|
||||
/// Per-stream receive-side controls (gain/mute/NR) round-trip through Swift: two clients
|
||||
/// in a channel, one publishes a MIC stream, the other setRemoteStream's it then
|
||||
/// getRemoteStream's it back. Catches Swift-specific marshaling bugs (field order,
|
||||
/// bool-from-int, float precision) that the C++ ctest can't. Mirrors C#
|
||||
/// `PerStream_RecvControls_Round_Trip_Through_PInvoke`.
|
||||
func testPerStreamRecvControlsRoundTrip() throws {
|
||||
guard requireHarness() else { return }
|
||||
|
||||
var eventsA: [VoiceCatEvent] = []
|
||||
var eventsB: [VoiceCatEvent] = []
|
||||
|
||||
let a = VoiceCatClient(config: VoiceCatConfig(
|
||||
clientName: "vc-swift-mix-a", clientVersion: "0.1", logLevel: .off,
|
||||
tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins_mix_a.txt")
|
||||
))
|
||||
let b = VoiceCatClient(config: VoiceCatConfig(
|
||||
clientName: "vc-swift-mix-b", clientVersion: "0.1", logLevel: .off,
|
||||
tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins_mix_b.txt")
|
||||
))
|
||||
a.onEvent = { eventsA.append($0) }
|
||||
b.onEvent = { eventsB.append($0) }
|
||||
|
||||
// Connect + auth A first, then B (staggering avoids concurrent TLS handshakes).
|
||||
XCTAssertEqual(a.connect(host: "127.0.0.1", port: port), .ok)
|
||||
XCTAssertEqual(a.authenticateGuest("SwiftMixA"), .ok)
|
||||
XCTAssertTrue(waitFor { eventsA.contains { $0.type == .serverIdentity } })
|
||||
XCTAssertEqual(a.confirmServerIdentity(accept: true), .ok)
|
||||
XCTAssertTrue(waitFor { eventsA.contains { $0.type == .authResult } })
|
||||
XCTAssertEqual(try XCTUnwrap(eventsA.first { $0.type == .authResult }).result, .ok)
|
||||
XCTAssertTrue(waitFor { eventsA.contains { $0.type == .channelList } })
|
||||
|
||||
XCTAssertEqual(b.connect(host: "127.0.0.1", port: port), .ok)
|
||||
XCTAssertEqual(b.authenticateGuest("SwiftMixB"), .ok)
|
||||
XCTAssertTrue(waitFor { eventsB.contains { $0.type == .serverIdentity } })
|
||||
XCTAssertEqual(b.confirmServerIdentity(accept: true), .ok)
|
||||
XCTAssertTrue(waitFor { eventsB.contains { $0.type == .authResult } })
|
||||
XCTAssertEqual(try XCTUnwrap(eventsB.first { $0.type == .authResult }).result, .ok)
|
||||
XCTAssertTrue(waitFor { eventsB.contains { $0.type == .channelList } })
|
||||
|
||||
// Both join Lobby (channel 1) so voice relays between them.
|
||||
XCTAssertEqual(a.joinChannel(1), .ok)
|
||||
XCTAssertTrue(waitFor { eventsA.contains { $0.type == .joinResult } },
|
||||
"A did not receive .joinResult")
|
||||
XCTAssertEqual(b.joinChannel(1), .ok)
|
||||
XCTAssertTrue(waitFor { eventsB.contains { $0.type == .joinResult } },
|
||||
"B did not receive .joinResult")
|
||||
|
||||
// UDP binding handshake is async; give it a moment.
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
|
||||
// A publishes a MIC stream.
|
||||
let (startResult, streamId) = a.startStream(StreamDescriptor(kind: .mic, label: "mix-test-mic"))
|
||||
XCTAssertEqual(startResult, .ok)
|
||||
XCTAssertNotEqual(streamId, 0)
|
||||
|
||||
// B sees A's stream.
|
||||
XCTAssertTrue(waitFor(timeout: 5) {
|
||||
eventsB.contains { $0.type == .streamStarted && $0.streamId == streamId }
|
||||
}, "B did not see A's .streamStarted")
|
||||
|
||||
// Resolve A's user id from B's user list.
|
||||
var aUid: UInt32 = 0
|
||||
XCTAssertTrue(waitFor(timeout: 3) {
|
||||
aUid = b.listUsers().first { $0.nickname == "SwiftMixA" }?.id ?? 0
|
||||
return aUid != 0
|
||||
}, "could not resolve A's user id on B")
|
||||
XCTAssertNotEqual(aUid, 0)
|
||||
|
||||
// B can enumerate A's stream.
|
||||
XCTAssertTrue(waitFor(timeout: 3) {
|
||||
b.listUserStreams(aUid).contains { $0.id == streamId }
|
||||
}, "B could not enumerate A's stream")
|
||||
let bStreams = b.listUserStreams(aUid)
|
||||
XCTAssertTrue(bStreams.contains { $0.id == streamId && $0.kind == .mic })
|
||||
|
||||
// Before B ever sets anything, defaults read back (gain 1.0, unmuted, NR off).
|
||||
let (r0, st0) = b.getRemoteStream(userId: aUid, streamId: streamId)
|
||||
XCTAssertEqual(r0, .ok)
|
||||
XCTAssertNotNil(st0)
|
||||
XCTAssertEqual(st0?.gain, 1.0)
|
||||
XCTAssertFalse(st0?.muted ?? true)
|
||||
XCTAssertFalse(st0?.noiseReduction ?? true)
|
||||
|
||||
// B turns A down to 0.5×, mutes, enables NR — then reads it back.
|
||||
XCTAssertEqual(b.setRemoteStream(userId: aUid, streamId: streamId,
|
||||
gain: 0.5, muted: true, noiseReduction: true), .ok)
|
||||
let (r1, st1) = b.getRemoteStream(userId: aUid, streamId: streamId)
|
||||
XCTAssertEqual(r1, .ok)
|
||||
XCTAssertNotNil(st1)
|
||||
XCTAssertEqual(st1?.gain, 0.5)
|
||||
XCTAssertTrue(st1?.muted ?? false)
|
||||
XCTAssertTrue(st1?.noiseReduction ?? false)
|
||||
|
||||
// Unknown stream id on a known user → .invalidArg.
|
||||
let (rBad, stBad) = b.getRemoteStream(userId: aUid, streamId: 0xDEADBEEF)
|
||||
XCTAssertEqual(rBad, .invalidArg)
|
||||
XCTAssertNil(stBad)
|
||||
|
||||
a.disconnect()
|
||||
b.disconnect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Solution>
|
||||
<Folder Name="/apps/">
|
||||
<Project Path="VoiceCat.Mac/VoiceCat.Mac.csproj" />
|
||||
<Project Path="VoiceCat.iOS/VoiceCat.iOS.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/managed/">
|
||||
<Project Path="../../src/VoiceCat.Protocol/VoiceCat.Protocol.csproj" />
|
||||
<Project Path="../../src/VoiceCat.Crypto/VoiceCat.Crypto.csproj" />
|
||||
<Project Path="../../src/VoiceCat.Codec/VoiceCat.Codec.csproj" />
|
||||
<Project Path="../../src/VoiceCat.Dsp/VoiceCat.Dsp.csproj" />
|
||||
<Project Path="../../src/VoiceCat.Audio/VoiceCat.Audio.csproj" />
|
||||
<Project Path="../../src/VoiceCat.Core/VoiceCat.Core.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
+4
-4
@@ -14,13 +14,13 @@
|
||||
<ApplicationManifest>Info.plist</ApplicationManifest>
|
||||
<CodesignEntitlements>VoiceCat.Mac.entitlements</CodesignEntitlements>
|
||||
<NoWarn>$(NoWarn);XCODE_27_0_PREVIEW</NoWarn>
|
||||
<VoiceCatNativeMediaPath>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../../../../dotnet/artifacts/native/runtimes/osx-arm64/native/libvoicecat_media.dylib'))</VoiceCatNativeMediaPath>
|
||||
<VoiceCatNativeLicenseDirectory>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../../../../dotnet/artifacts/native/licenses'))</VoiceCatNativeLicenseDirectory>
|
||||
<VoiceCatNativeMediaPath>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../../../artifacts/native/runtimes/osx-arm64/native/libvoicecat_media.dylib'))</VoiceCatNativeMediaPath>
|
||||
<VoiceCatNativeLicenseDirectory>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../../../artifacts/native/licenses'))</VoiceCatNativeLicenseDirectory>
|
||||
<_ComputePublishLocationDependsOn>VoiceCatPrepareNativeAssets;$(_ComputePublishLocationDependsOn)</_ComputePublishLocationDependsOn>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../../dotnet/src/VoiceCat.Core/VoiceCat.Core.csproj" />
|
||||
<BundleResource Include="../../../../assets/sounds/*.wav" Link="Sounds/%(Filename)%(Extension)" />
|
||||
<ProjectReference Include="../../../src/VoiceCat.Core/VoiceCat.Core.csproj" />
|
||||
<BundleResource Include="../../../assets/sounds/*.wav" Link="Sounds/%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Every managed media project stages the same dylib for ordinary .NET consumers.
|
||||
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
+3
-3
@@ -18,7 +18,7 @@
|
||||
<VoiceCatIosStatic>true</VoiceCatIosStatic>
|
||||
<VoiceCatNativeRid Condition="$([System.String]::Copy('$(RuntimeIdentifier)').StartsWith('iossimulator'))">iossimulator-arm64</VoiceCatNativeRid>
|
||||
<VoiceCatNativeRid Condition="'$(VoiceCatNativeRid)' == ''">ios-arm64</VoiceCatNativeRid>
|
||||
<VoiceCatNativeMediaPath>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../../../../dotnet/artifacts/native/runtimes/$(VoiceCatNativeRid)/native/libvoicecat_media.a'))</VoiceCatNativeMediaPath>
|
||||
<VoiceCatNativeMediaPath>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../../../artifacts/native/runtimes/$(VoiceCatNativeRid)/native/libvoicecat_media.a'))</VoiceCatNativeMediaPath>
|
||||
<VoiceCatBroadcastSdk Condition="$([System.String]::Copy('$(RuntimeIdentifier)').StartsWith('iossimulator'))">iphonesimulator</VoiceCatBroadcastSdk>
|
||||
<VoiceCatBroadcastSdk Condition="'$(VoiceCatBroadcastSdk)' == ''">iphoneos</VoiceCatBroadcastSdk>
|
||||
<VoiceCatBroadcastArch>arm64</VoiceCatBroadcastArch>
|
||||
@@ -27,7 +27,7 @@
|
||||
<_ComputePublishLocationDependsOn>VoiceCatPrepareIosNativeAssets;$(_ComputePublishLocationDependsOn)</_ComputePublishLocationDependsOn>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../../dotnet/src/VoiceCat.Core/VoiceCat.Core.csproj" AdditionalProperties="VoiceCatIosStatic=true" />
|
||||
<ProjectReference Include="../../../src/VoiceCat.Core/VoiceCat.Core.csproj" AdditionalProperties="VoiceCatIosStatic=true" />
|
||||
<NativeReference Include="$(VoiceCatNativeMediaPath)" Condition="Exists('$(VoiceCatNativeMediaPath)')">
|
||||
<Kind>Static</Kind>
|
||||
<ForceLoad>true</ForceLoad>
|
||||
@@ -46,7 +46,7 @@
|
||||
<BuildOutput>.</BuildOutput>
|
||||
<CodesignEntitlements>$(VoiceCatBroadcastOutput)/VoiceCatBroadcast.xcent</CodesignEntitlements>
|
||||
</AdditionalAppExtensions>
|
||||
<BundleResource Include="../../../../assets/sounds/*.wav" Link="Sounds/%(Filename)%(Extension)" />
|
||||
<BundleResource Include="../../../assets/sounds/*.wav" Link="Sounds/%(Filename)%(Extension)" />
|
||||
<ImageAsset Include="Assets.xcassets/**" Link="Assets.xcassets/%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
<Target Name="VoiceCatBuildBroadcastExtension" BeforeTargets="_ResolveAppExtensionReferences">
|
||||
+2
-2
@@ -6,7 +6,7 @@ sdk="${2:?sdk is required}"
|
||||
architecture="${3:?architecture is required}"
|
||||
output="${4:?output directory is required}"
|
||||
script_dir="${0:A:h}"
|
||||
project="$script_dir/../../../../native/apple/broadcast/VoiceCatBroadcast.xcodeproj"
|
||||
project="$script_dir/../../../native/apple/broadcast/VoiceCatBroadcast.xcodeproj"
|
||||
|
||||
mkdir -p "$output"
|
||||
signing=()
|
||||
@@ -33,5 +33,5 @@ xcent=$(find "$output/derived" -name 'VoiceCatBroadcast.appex.xcent' -print -qui
|
||||
if [[ -f "$xcent" ]]; then
|
||||
cp "$xcent" "$output/VoiceCatBroadcast.xcent"
|
||||
else
|
||||
cp "$script_dir/../../../../native/apple/broadcast/VoiceCatBroadcast.entitlements" "$output/VoiceCatBroadcast.xcent"
|
||||
cp "$script_dir/../../../native/apple/broadcast/VoiceCatBroadcast.entitlements" "$output/VoiceCatBroadcast.xcent"
|
||||
fi
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
root="$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd)"
|
||||
root="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||
configuration="${CONFIGURATION:-Debug}"
|
||||
output="$root/dist/ios-managed-device"
|
||||
dotnet_host="${VOICECAT_DOTNET:-/usr/local/share/dotnet/dotnet}"
|
||||
@@ -18,8 +18,8 @@ while [ "$#" -gt 0 ]; do
|
||||
esac
|
||||
done
|
||||
|
||||
project="$root/clients/apple/dotnet/VoiceCat.iOS/VoiceCat.iOS.csproj"
|
||||
"$root/dotnet/build-native-ios.sh"
|
||||
project="$root/clients/apple/VoiceCat.iOS/VoiceCat.iOS.csproj"
|
||||
"$root/scripts/build-native-ios.sh"
|
||||
"$dotnet_host" restore "$project" --locked-mode -p:VoiceCatIosStatic=true
|
||||
"$dotnet_host" restore "$project" --locked-mode -r ios-arm64 --no-dependencies
|
||||
|
||||
@@ -28,7 +28,7 @@ if [ -n "${VOICECAT_CODESIGN_KEY:-}" ]; then set -- "$@" -p:CodesignKey="$VOICEC
|
||||
if [ -n "${VOICECAT_CODESIGN_PROVISION:-}" ]; then set -- "$@" -p:CodesignProvision="$VOICECAT_CODESIGN_PROVISION"; fi
|
||||
"$dotnet_host" "$@"
|
||||
|
||||
app="$root/clients/apple/dotnet/VoiceCat.iOS/bin/$configuration/net10.0-ios27.0/ios-arm64/VoiceCat.iOS.app"
|
||||
app="$root/clients/apple/VoiceCat.iOS/bin/$configuration/net10.0-ios27.0/ios-arm64/VoiceCat.iOS.app"
|
||||
[ -d "$app" ] || { echo "device app was not produced at $app" >&2; exit 1; }
|
||||
/usr/bin/strings "$app/VoiceCat.Codec.dll" | /usr/bin/grep -q GetMainProgramHandle || {
|
||||
echo "device codec does not contain the iOS static-library resolver" >&2
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
root="$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd)"
|
||||
root="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||
device="${VOICECAT_IOS_DEVICE:-}"
|
||||
configuration="${CONFIGURATION:-Debug}"
|
||||
build=true
|
||||
@@ -24,7 +24,7 @@ done
|
||||
|
||||
[ -n "$device" ] || { echo "pass --device NAME-OR-UDID or set VOICECAT_IOS_DEVICE" >&2; exit 2; }
|
||||
app="$root/dist/ios-managed-device/VoiceCat.iOS.app"
|
||||
if $build; then "$root/clients/apple/dotnet/build-ios-device.sh" --configuration "$configuration"; fi
|
||||
if $build; then "$root/clients/apple/build-ios-device.sh" --configuration "$configuration"; fi
|
||||
[ -d "$app" ] || { echo "managed device app not found at $app; run build-ios-device.sh first" >&2; exit 1; }
|
||||
|
||||
xcrun devicectl device install app --device "$device" "$app"
|
||||
@@ -1,59 +0,0 @@
|
||||
# Managed Apple clients
|
||||
|
||||
`VoiceCat.Mac` and `VoiceCat.iOS` are the native AppKit and UIKit C# clients. They target the .NET 10 Apple workloads and reference the same `VoiceCat.Core` and `VoiceCat.Audio` assemblies used by Windows and the managed CLI. UIKit was selected over MAUI to retain direct AVAudioSession/AVAudioEngine lifecycle control and native VoiceOver behavior without another UI abstraction.
|
||||
|
||||
The managed client now implements the Swift client's functional surface: profiles and Keychain authentication, TOFU, protected channels, hierarchical channel presentation and roster state, channel and modeless private text, microphone/auxiliary/screen-audio streams, selectable Core Audio devices, VAD/PTT/always-on input, stereo microphone, RNNoise, per-stream receive tuning, self/server mute and deafen, full channel configuration, moderation, permissions, account administration, event sounds and speech. It imports the legacy Swift profile, TOFU and Keychain state during cutover. Capture is converted to interleaved 48 kHz int16 PCM through `AVAudioConverter`; playback converts the shared bounded `PcmRing` into Core Audio's native planar Float32 layout inside an allocation-free, non-blocking `AVAudioSourceNode` callback.
|
||||
|
||||
The managed clients are the supported implementation. Manual VoiceOver and real multi-human call validation remain release gates. Produce and validate an ad-hoc Release bundle with `zsh clients/apple/dotnet/publish-macos.sh --dry-run`. The script prefers `/usr/local/share/dotnet/dotnet`, where the pinned Apple workload is installed; set `VOICECAT_DOTNET` to override that host. Signing is performed on `bin/Release/distribution/VoiceCat.app`, leaving MSBuild's incremental app bundle untouched. For distribution, set `VOICECAT_CODESIGN_IDENTITY`; setting `APPLE_ID`, `APPLE_TEAM_ID`, and `APPLE_APP_PASSWORD` additionally submits, staples, and Gatekeeper-validates the notarized bundle.
|
||||
|
||||
Build on Apple Silicon macOS 27 with Xcode 27, .NET SDK 10.0.401 and workload set 10.0.401. Homebrew `protobuf` supplies a native arm64 `protoc`; the current `Grpc.Tools` package contains only an x64 macOS compiler.
|
||||
|
||||
```bash
|
||||
sudo dotnet workload install macos ios --version 10.0.401
|
||||
brew install protobuf # if /opt/homebrew/bin/protoc is not already present
|
||||
cmake -S native/media -B dotnet/artifacts/native-build -DCMAKE_BUILD_TYPE=Release -DVOICECAT_DOTNET_RID=osx-arm64
|
||||
cmake --build dotnet/artifacts/native-build --config Release --target voicecat_media --parallel 2
|
||||
cmake --install dotnet/artifacts/native-build --config Release --component DotnetMedia --prefix dotnet/artifacts/native
|
||||
dotnet restore clients/apple/dotnet/VoiceCat.Apple.slnx
|
||||
dotnet build clients/apple/dotnet/VoiceCat.Apple.slnx -c Debug
|
||||
open clients/apple/dotnet/VoiceCat.Mac/bin/Debug/net10.0-macos27.0/osx-arm64/VoiceCat.app
|
||||
```
|
||||
|
||||
The iOS build first stages static device and simulator Opus/RNNoise archives, then builds the UIKit host. MSBuild also builds and embeds the existing Swift ReplayKit upload extension; that deliberately remains Swift because of the extension's tight memory budget and unsupported managed extension runtime.
|
||||
|
||||
```bash
|
||||
./dotnet/build-native-ios.sh
|
||||
dotnet build clients/apple/dotnet/VoiceCat.iOS/VoiceCat.iOS.csproj -c Debug -r iossimulator-arm64
|
||||
```
|
||||
|
||||
For a physical device, use the checked-in build and deployment wrappers. The iPhone must be
|
||||
paired and trusted, and Xcode must have an Apple Development identity and provisioning profile
|
||||
for both `me.iamtalon.voicecat` and `me.iamtalon.voicecat.broadcast`. Let automatic signing
|
||||
select them, or set `VOICECAT_CODESIGN_KEY`, `VOICECAT_CODESIGN_PROVISION`, and
|
||||
`VOICECAT_DEVELOPMENT_TEAM` before building. The development team is required by the retained
|
||||
ReplayKit extension's Xcode build. Set `VOICECAT_ALLOW_PROVISIONING_UPDATES=1` only when Xcode
|
||||
needs to create or download a profile.
|
||||
|
||||
```bash
|
||||
clients/apple/dotnet/build-ios-device.sh --configuration Debug
|
||||
clients/apple/dotnet/deploy-ios-device.sh --list
|
||||
clients/apple/dotnet/deploy-ios-device.sh --device "My iPhone" --configuration Debug --console
|
||||
```
|
||||
|
||||
The build stages the verified app at `dist/ios-managed-device/VoiceCat.iOS.app`. Pass
|
||||
`--no-build` to the deployment script for quick reinstall cycles. On iOS 18–26, screen audio
|
||||
uses the retained ReplayKit extension. On iOS 27 and newer, the host uses a small dynamically
|
||||
loaded ScreenCaptureKit bridge and writes the same versioned ring; the Settings row toggles
|
||||
that capture on and off. This keeps one managed consumer and allows the app to remain launchable
|
||||
on older supported systems.
|
||||
|
||||
While joined to voice, the active `PlayAndRecord` session and audio engine remain running when
|
||||
the scene backgrounds or the device locks, which keeps microphone capture, peer playback and the
|
||||
screen-audio pump eligible for the declared `audio` background mode. Foreground activation,
|
||||
hardware route changes, audio interruptions and media-service resets revalidate or rebuild the
|
||||
audio graph. Validate this behavior on hardware: iOS simulator lifecycle transitions do not prove
|
||||
background execution, lock-screen routing or Bluetooth recovery.
|
||||
|
||||
Profiles, TOFU state, passwords and the ReplayKit ring use the signed App Group `group.me.iamtalon.voicecat`; the managed client migrates the old app-private profile files on first use. The shared ring ABI is frozen in [`docs/broadcast-ring-format.md`](../../../docs/broadcast-ring-format.md).
|
||||
|
||||
The native build stages an `osx-arm64` `libvoicecat_media.dylib`; that shim contains only Opus/RNNoise. Debug builds deliberately omit hardened runtime so an ad-hoc-signed local app can load the separately ad-hoc-signed .NET runtime libraries without an Apple Development identity. Release builds retain hardened runtime for Developer ID signing and notarization. Only a macOS host can link, launch, grant microphone access and verify live devices. Final audio quality is validated with real multi-human calls after the feature surface is complete; a synthetic ten-minute sine-wave listen is deliberately not a release gate.
|
||||
@@ -1,14 +0,0 @@
|
||||
<Solution>
|
||||
<Folder Name="/apps/">
|
||||
<Project Path="VoiceCat.Mac/VoiceCat.Mac.csproj" />
|
||||
<Project Path="VoiceCat.iOS/VoiceCat.iOS.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/managed/">
|
||||
<Project Path="../../../dotnet/src/VoiceCat.Protocol/VoiceCat.Protocol.csproj" />
|
||||
<Project Path="../../../dotnet/src/VoiceCat.Crypto/VoiceCat.Crypto.csproj" />
|
||||
<Project Path="../../../dotnet/src/VoiceCat.Codec/VoiceCat.Codec.csproj" />
|
||||
<Project Path="../../../dotnet/src/VoiceCat.Dsp/VoiceCat.Dsp.csproj" />
|
||||
<Project Path="../../../dotnet/src/VoiceCat.Audio/VoiceCat.Audio.csproj" />
|
||||
<Project Path="../../../dotnet/src/VoiceCat.Core/VoiceCat.Core.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
@@ -1,616 +0,0 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 60;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
AAAA00000000000000000002 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000001 /* Assets.xcassets */; };
|
||||
BBBB00000000000000000030 /* VoiceCatiOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000017 /* VoiceCatiOSApp.swift */; };
|
||||
BBBB00000000000000000031 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000018 /* AppState.swift */; };
|
||||
BBBB00000000000000000032 /* SessionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000019 /* SessionState.swift */; };
|
||||
BBBB00000000000000000033 /* AudioSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000001A /* AudioSessionManager.swift */; };
|
||||
BBBB00000000000000000034 /* ServerListStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000001B /* ServerListStore.swift */; };
|
||||
BBBB00000000000000000035 /* SavedServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000001C /* SavedServer.swift */; };
|
||||
BBBB00000000000000000037 /* ServerListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000001E /* ServerListView.swift */; };
|
||||
BBBB00000000000000000038 /* AddServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000001F /* AddServerView.swift */; };
|
||||
BBBB00000000000000000039 /* ServerIdentityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000020 /* ServerIdentityView.swift */; };
|
||||
BBBB0000000000000000003A /* PasswordPromptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000021 /* PasswordPromptView.swift */; };
|
||||
BBBB0000000000000000003B /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000022 /* MainView.swift */; };
|
||||
BBBB0000000000000000003C /* ChannelTreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000023 /* ChannelTreeView.swift */; };
|
||||
BBBB0000000000000000003D /* UserListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000024 /* UserListView.swift */; };
|
||||
BBBB0000000000000000003E /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000025 /* ChatView.swift */; };
|
||||
BBBB00000000000000000070 /* ChannelBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000060 /* ChannelBrowserView.swift */; };
|
||||
BBBB00000000000000000071 /* ChannelDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000061 /* ChannelDetailView.swift */; };
|
||||
BBBB00000000000000000072 /* UserRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000062 /* UserRow.swift */; };
|
||||
BBBB00000000000000000040 /* VoiceControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000027 /* VoiceControlsView.swift */; };
|
||||
BBBB00000000000000000041 /* PerUserTuningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000028 /* PerUserTuningView.swift */; };
|
||||
BBBB00000000000000000042 /* ChannelEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000029 /* ChannelEditView.swift */; };
|
||||
BBBB00000000000000000043 /* BanUserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002A /* BanUserView.swift */; };
|
||||
BBBB00000000000000000044 /* MoveUserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002B /* MoveUserView.swift */; };
|
||||
BBBB00000000000000000045 /* PermissionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002C /* PermissionsView.swift */; };
|
||||
BBBB00000000000000000046 /* AccountsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002D /* AccountsView.swift */; };
|
||||
BBBB00000000000000000047 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002E /* SettingsView.swift */; };
|
||||
BBBB0000000000000000004B /* IOSAudioRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002F /* IOSAudioRouter.swift */; };
|
||||
BBBB00000000000000000F02 /* IOSVoiceProcessingEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000F01 /* IOSVoiceProcessingEngine.swift */; };
|
||||
BBBB00000000000000000048 /* VoiceCatCore in Frameworks */ = {isa = PBXBuildFile; productRef = BBBB0000000000000000004A /* VoiceCatCore */; };
|
||||
CCCC00000000000000000010 /* BroadcastAudioPump.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCCC00000000000000000002 /* BroadcastAudioPump.swift */; };
|
||||
CCCC00000000000000000011 /* BroadcastAudioRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCCC00000000000000000001 /* BroadcastAudioRing.swift */; };
|
||||
CCCC00000000000000000012 /* SampleHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCCC00000000000000000003 /* SampleHandler.swift */; };
|
||||
CCCC00000000000000000013 /* BroadcastAudioRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCCC00000000000000000001 /* BroadcastAudioRing.swift */; };
|
||||
CCCC00000000000000000014 /* VoiceCatBroadcast.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = CCCC00000000000000000006 /* VoiceCatBroadcast.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
CCCC00000000000000000036 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = BBBB00000000000000000001 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = CCCC00000000000000000030 /* VoiceCatBroadcast */;
|
||||
remoteInfo = VoiceCatBroadcast;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
CCCC00000000000000000035 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = CCCC00000000000000000030 /* VoiceCatBroadcast */;
|
||||
targetProxy = CCCC00000000000000000036 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
AAAA00000000000000000001 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000012 /* VoiceCatiOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VoiceCatiOS.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
BBBB00000000000000000015 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000016 /* VoiceCatiOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = VoiceCatiOS.entitlements; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000017 /* VoiceCatiOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceCatiOSApp.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000018 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000019 /* SessionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionState.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000001A /* AudioSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionManager.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000001B /* ServerListStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListStore.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000001C /* SavedServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedServer.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000001E /* ServerListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListView.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000001F /* AddServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddServerView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000020 /* ServerIdentityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerIdentityView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000021 /* PasswordPromptView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordPromptView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000022 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000023 /* ChannelTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelTreeView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000024 /* UserListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000025 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000060 /* ChannelBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelBrowserView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000061 /* ChannelDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelDetailView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000062 /* UserRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRow.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000027 /* VoiceControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceControlsView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000028 /* PerUserTuningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerUserTuningView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000029 /* ChannelEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelEditView.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000002A /* BanUserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BanUserView.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000002B /* MoveUserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveUserView.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000002C /* PermissionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsView.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000002D /* AccountsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsView.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000002E /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000002F /* IOSAudioRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSAudioRouter.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000F01 /* IOSVoiceProcessingEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSVoiceProcessingEngine.swift; sourceTree = "<group>"; };
|
||||
CCCC00000000000000000001 /* BroadcastAudioRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastAudioRing.swift; sourceTree = "<group>"; };
|
||||
CCCC00000000000000000002 /* BroadcastAudioPump.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastAudioPump.swift; sourceTree = "<group>"; };
|
||||
CCCC00000000000000000003 /* SampleHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleHandler.swift; sourceTree = "<group>"; };
|
||||
CCCC00000000000000000004 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
CCCC00000000000000000005 /* VoiceCatBroadcast.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = VoiceCatBroadcast.entitlements; sourceTree = "<group>"; };
|
||||
CCCC00000000000000000006 /* VoiceCatBroadcast.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = VoiceCatBroadcast.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
CCCC00000000000000000037 /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
CCCC00000000000000000014 /* VoiceCatBroadcast.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
BBBB00000000000000000011 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
BBBB00000000000000000048 /* VoiceCatCore in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
BBBB00000000000000000002 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BBBB00000000000000000003 /* VoiceCatiOS */,
|
||||
CCCC00000000000000000021 /* VoiceCatBroadcast */,
|
||||
CCCC00000000000000000020 /* Shared */,
|
||||
BBBB00000000000000000007 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
BBBB00000000000000000003 /* VoiceCatiOS */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AAAA00000000000000000001 /* Assets.xcassets */,
|
||||
BBBB00000000000000000015 /* Info.plist */,
|
||||
BBBB00000000000000000016 /* VoiceCatiOS.entitlements */,
|
||||
BBBB00000000000000000017 /* VoiceCatiOSApp.swift */,
|
||||
BBBB00000000000000000018 /* AppState.swift */,
|
||||
BBBB00000000000000000019 /* SessionState.swift */,
|
||||
BBBB0000000000000000001A /* AudioSessionManager.swift */,
|
||||
BBBB0000000000000000002F /* IOSAudioRouter.swift */,
|
||||
BBBB00000000000000000F01 /* IOSVoiceProcessingEngine.swift */,
|
||||
BBBB0000000000000000001B /* ServerListStore.swift */,
|
||||
BBBB0000000000000000001C /* SavedServer.swift */,
|
||||
CCCC00000000000000000002 /* BroadcastAudioPump.swift */,
|
||||
BBBB00000000000000000006 /* Views */,
|
||||
);
|
||||
path = VoiceCatiOS;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CCCC00000000000000000020 /* Shared */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CCCC00000000000000000001 /* BroadcastAudioRing.swift */,
|
||||
);
|
||||
path = Shared;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CCCC00000000000000000021 /* VoiceCatBroadcast */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CCCC00000000000000000003 /* SampleHandler.swift */,
|
||||
CCCC00000000000000000004 /* Info.plist */,
|
||||
CCCC00000000000000000005 /* VoiceCatBroadcast.entitlements */,
|
||||
);
|
||||
path = VoiceCatBroadcast;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
BBBB00000000000000000006 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BBBB0000000000000000001E /* ServerListView.swift */,
|
||||
BBBB0000000000000000001F /* AddServerView.swift */,
|
||||
BBBB00000000000000000020 /* ServerIdentityView.swift */,
|
||||
BBBB00000000000000000021 /* PasswordPromptView.swift */,
|
||||
BBBB00000000000000000022 /* MainView.swift */,
|
||||
BBBB00000000000000000023 /* ChannelTreeView.swift */,
|
||||
BBBB00000000000000000024 /* UserListView.swift */,
|
||||
BBBB00000000000000000025 /* ChatView.swift */,
|
||||
BBBB00000000000000000060 /* ChannelBrowserView.swift */,
|
||||
BBBB00000000000000000061 /* ChannelDetailView.swift */,
|
||||
BBBB00000000000000000062 /* UserRow.swift */,
|
||||
BBBB00000000000000000027 /* VoiceControlsView.swift */,
|
||||
BBBB00000000000000000028 /* PerUserTuningView.swift */,
|
||||
BBBB00000000000000000029 /* ChannelEditView.swift */,
|
||||
BBBB0000000000000000002A /* BanUserView.swift */,
|
||||
BBBB0000000000000000002B /* MoveUserView.swift */,
|
||||
BBBB0000000000000000002C /* PermissionsView.swift */,
|
||||
BBBB0000000000000000002D /* AccountsView.swift */,
|
||||
BBBB0000000000000000002E /* SettingsView.swift */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
BBBB00000000000000000007 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BBBB00000000000000000012 /* VoiceCatiOS.app */,
|
||||
CCCC00000000000000000006 /* VoiceCatBroadcast.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
BBBB00000000000000000008 /* VoiceCatiOS */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = BBBB0000000000000000000A /* Build configuration list for PBXNativeTarget "VoiceCatiOS" */;
|
||||
buildPhases = (
|
||||
BBBB0000000000000000000F /* Sources */,
|
||||
BBBB00000000000000000010 /* Resources */,
|
||||
BBBB00000000000000000011 /* Frameworks */,
|
||||
CCCC00000000000000000037 /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
CCCC00000000000000000035 /* PBXTargetDependency */,
|
||||
);
|
||||
name = VoiceCatiOS;
|
||||
packageProductDependencies = (
|
||||
BBBB0000000000000000004A /* VoiceCatCore */,
|
||||
);
|
||||
productName = VoiceCatiOS;
|
||||
productReference = BBBB00000000000000000012 /* VoiceCatiOS.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
CCCC00000000000000000030 /* VoiceCatBroadcast */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = CCCC00000000000000000032 /* Build configuration list for PBXNativeTarget "VoiceCatBroadcast" */;
|
||||
buildPhases = (
|
||||
CCCC00000000000000000031 /* Sources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = VoiceCatBroadcast;
|
||||
productName = VoiceCatBroadcast;
|
||||
productReference = CCCC00000000000000000006 /* VoiceCatBroadcast.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
BBBB00000000000000000001 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1500;
|
||||
LastUpgradeCheck = 1500;
|
||||
};
|
||||
buildConfigurationList = BBBB00000000000000000009 /* Build configuration list for PBXProject "VoiceCatiOS" */;
|
||||
compatibilityVersion = "Xcode 14.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = BBBB00000000000000000002;
|
||||
packageReferences = (
|
||||
BBBB00000000000000000049 /* XCLocalSwiftPackageReference "../" */,
|
||||
);
|
||||
productRefGroup = BBBB00000000000000000007 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
BBBB00000000000000000008 /* VoiceCatiOS */,
|
||||
CCCC00000000000000000030 /* VoiceCatBroadcast */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
BBBB00000000000000000010 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
AAAA00000000000000000002 /* Assets.xcassets in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
BBBB0000000000000000000F /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
BBBB00000000000000000030 /* VoiceCatiOSApp.swift in Sources */,
|
||||
BBBB00000000000000000031 /* AppState.swift in Sources */,
|
||||
BBBB00000000000000000032 /* SessionState.swift in Sources */,
|
||||
BBBB00000000000000000033 /* AudioSessionManager.swift in Sources */,
|
||||
BBBB0000000000000000004B /* IOSAudioRouter.swift in Sources */,
|
||||
BBBB00000000000000000F02 /* IOSVoiceProcessingEngine.swift in Sources */,
|
||||
BBBB00000000000000000034 /* ServerListStore.swift in Sources */,
|
||||
BBBB00000000000000000035 /* SavedServer.swift in Sources */,
|
||||
CCCC00000000000000000010 /* BroadcastAudioPump.swift in Sources */,
|
||||
CCCC00000000000000000011 /* BroadcastAudioRing.swift in Sources */,
|
||||
BBBB00000000000000000037 /* ServerListView.swift in Sources */,
|
||||
BBBB00000000000000000038 /* AddServerView.swift in Sources */,
|
||||
BBBB00000000000000000039 /* ServerIdentityView.swift in Sources */,
|
||||
BBBB0000000000000000003A /* PasswordPromptView.swift in Sources */,
|
||||
BBBB0000000000000000003B /* MainView.swift in Sources */,
|
||||
BBBB0000000000000000003C /* ChannelTreeView.swift in Sources */,
|
||||
BBBB0000000000000000003D /* UserListView.swift in Sources */,
|
||||
BBBB0000000000000000003E /* ChatView.swift in Sources */,
|
||||
BBBB00000000000000000070 /* ChannelBrowserView.swift in Sources */,
|
||||
BBBB00000000000000000071 /* ChannelDetailView.swift in Sources */,
|
||||
BBBB00000000000000000072 /* UserRow.swift in Sources */,
|
||||
BBBB00000000000000000040 /* VoiceControlsView.swift in Sources */,
|
||||
BBBB00000000000000000041 /* PerUserTuningView.swift in Sources */,
|
||||
BBBB00000000000000000042 /* ChannelEditView.swift in Sources */,
|
||||
BBBB00000000000000000043 /* BanUserView.swift in Sources */,
|
||||
BBBB00000000000000000044 /* MoveUserView.swift in Sources */,
|
||||
BBBB00000000000000000045 /* PermissionsView.swift in Sources */,
|
||||
BBBB00000000000000000046 /* AccountsView.swift in Sources */,
|
||||
BBBB00000000000000000047 /* SettingsView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
CCCC00000000000000000031 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
CCCC00000000000000000012 /* SampleHandler.swift in Sources */,
|
||||
CCCC00000000000000000013 /* BroadcastAudioRing.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
BBBB0000000000000000000B /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
BBBB0000000000000000000C /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
BBBB0000000000000000000D /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = VoiceCatiOS/VoiceCatiOS.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = FJV8L966W4;
|
||||
INFOPLIST_FILE = VoiceCatiOS/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = VoiceCat;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.0.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-lc++",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = me.iamtalon.voicecat;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.9;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
BBBB0000000000000000000E /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = VoiceCatiOS/VoiceCatiOS.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = FJV8L966W4;
|
||||
INFOPLIST_FILE = VoiceCatiOS/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = VoiceCat;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.0.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-lc++",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = me.iamtalon.voicecat;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.9;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
CCCC00000000000000000033 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = VoiceCatBroadcast/VoiceCatBroadcast.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = FJV8L966W4;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = VoiceCatBroadcast/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = me.iamtalon.voicecat.broadcast;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.9;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
CCCC00000000000000000034 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = VoiceCatBroadcast/VoiceCatBroadcast.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = FJV8L966W4;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = VoiceCatBroadcast/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = me.iamtalon.voicecat.broadcast;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_VERSION = 5.9;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
BBBB00000000000000000009 /* Build configuration list for PBXProject "VoiceCatiOS" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
BBBB0000000000000000000B /* Debug */,
|
||||
BBBB0000000000000000000C /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
BBBB0000000000000000000A /* Build configuration list for PBXNativeTarget "VoiceCatiOS" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
BBBB0000000000000000000D /* Debug */,
|
||||
BBBB0000000000000000000E /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
CCCC00000000000000000032 /* Build configuration list for PBXNativeTarget "VoiceCatBroadcast" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
CCCC00000000000000000033 /* Debug */,
|
||||
CCCC00000000000000000034 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
BBBB00000000000000000049 /* XCLocalSwiftPackageReference "../" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = ../;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
BBBB0000000000000000004A /* VoiceCatCore */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = BBBB00000000000000000049 /* XCLocalSwiftPackageReference "../" */;
|
||||
productName = VoiceCatCore;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = BBBB00000000000000000001 /* Project object */;
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1500"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "BBBB00000000000000000008"
|
||||
BuildableName = "VoiceCatiOS.app"
|
||||
BlueprintName = "VoiceCatiOS"
|
||||
ReferencedContainer = "container:VoiceCatiOS.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "BBBB00000000000000000008"
|
||||
BuildableName = "VoiceCatiOS.app"
|
||||
BlueprintName = "VoiceCatiOS"
|
||||
ReferencedContainer = "container:VoiceCatiOS.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "BBBB00000000000000000008"
|
||||
BuildableName = "VoiceCatiOS.app"
|
||||
BlueprintName = "VoiceCatiOS"
|
||||
ReferencedContainer = "container:VoiceCatiOS.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -1,402 +0,0 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import VoiceCatCore
|
||||
|
||||
struct PendingIdentity: Identifiable {
|
||||
let id = UUID()
|
||||
let displayText: String
|
||||
let tofuStatus: VoiceCatTofuStatus
|
||||
}
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class AppState {
|
||||
var servers: [SavedServer] = ServerListStore.shared.load()
|
||||
var session: SessionState?
|
||||
|
||||
// Connect-flow state
|
||||
var isConnecting = false
|
||||
var connectStatus = ""
|
||||
var showAddServer = false
|
||||
var editingServer: SavedServer?
|
||||
var showPasswordPrompt = false
|
||||
var pendingIdentity: PendingIdentity?
|
||||
|
||||
private var connectingClient: VoiceCatClient?
|
||||
private(set) var connectingServer: SavedServer?
|
||||
private var identityHandled = false
|
||||
|
||||
/// Retained after authentication so an interrupted session can be restored.
|
||||
private var connectedServer: SavedServer?
|
||||
|
||||
// MARK: - Reconnect state
|
||||
|
||||
/// Distinguishes an explicit disconnect from a transport failure.
|
||||
private var userInitiatedDisconnect = false
|
||||
|
||||
private struct LastSession {
|
||||
let server: SavedServer
|
||||
let channelId: UInt32
|
||||
let voiceSubscribed: Bool
|
||||
let micMuted: Bool
|
||||
let deafened: Bool
|
||||
}
|
||||
private var lastSession: LastSession?
|
||||
|
||||
private var reconnectAttempt = 0
|
||||
|
||||
private var reconnectTask: Task<Void, Never>?
|
||||
|
||||
/// Detects interface changes before TCP keepalive notices a dead path.
|
||||
private var pathMonitor: NWPathMonitor?
|
||||
private let pathQueue = DispatchQueue(label: "cat.voice.network.path")
|
||||
|
||||
private var lastPathSignature: String?
|
||||
|
||||
// MARK: - Server list management
|
||||
|
||||
func addServer(_ server: SavedServer, password: String?) {
|
||||
if let pw = password, !pw.isEmpty {
|
||||
ServerListStore.shared.savePassword(pw, tag: server.keychainTag)
|
||||
}
|
||||
servers.append(server)
|
||||
ServerListStore.shared.save(servers)
|
||||
}
|
||||
|
||||
func updateServer(_ server: SavedServer, password: String?) {
|
||||
if let pw = password, !pw.isEmpty {
|
||||
ServerListStore.shared.savePassword(pw, tag: server.keychainTag)
|
||||
}
|
||||
if let idx = servers.firstIndex(where: { $0.id == server.id }) {
|
||||
servers[idx] = server
|
||||
}
|
||||
ServerListStore.shared.save(servers)
|
||||
}
|
||||
|
||||
func removeServer(_ server: SavedServer) {
|
||||
ServerListStore.shared.deletePassword(tag: server.keychainTag)
|
||||
servers.removeAll(where: { $0.id == server.id })
|
||||
ServerListStore.shared.save(servers)
|
||||
}
|
||||
|
||||
// MARK: - Connect flow
|
||||
|
||||
func connectTo(_ server: SavedServer) {
|
||||
connectTo(server, restoring: nil)
|
||||
}
|
||||
|
||||
private func connectTo(_ server: SavedServer, restoring: LastSession?) {
|
||||
guard !isConnecting else { return }
|
||||
isConnecting = true
|
||||
connectStatus = (restoring != nil) ? "Reconnecting…" : "Connecting…"
|
||||
connectingServer = server
|
||||
identityHandled = false
|
||||
userInitiatedDisconnect = false
|
||||
|
||||
// Releasing the wrapper joins the core's I/O thread before freeing native strings.
|
||||
connectingClient = nil
|
||||
|
||||
let config = VoiceCatConfig(
|
||||
clientName: "VoiceCat-iOS",
|
||||
clientVersion: "0.0.1",
|
||||
logLevel: .info,
|
||||
tofuStorePath: ServerListStore.shared.tofuStorePath)
|
||||
let client = VoiceCatClient(config: config)
|
||||
connectingClient = client
|
||||
|
||||
client.onEvent = { [weak self] ev in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.handleConnectEvent(ev, server: server, restoring: restoring)
|
||||
}
|
||||
}
|
||||
// Authentication can start audio, so select the external path before connecting.
|
||||
client.setExternalPlayback(true)
|
||||
client.connect(host: server.host, port: server.port)
|
||||
|
||||
switch server.authMode {
|
||||
case .guest:
|
||||
let nick = (server.nickname?.isEmpty == false) ? server.nickname! : "iOS User"
|
||||
client.authenticateGuest(nick)
|
||||
case .password:
|
||||
let savedPw = ServerListStore.shared.loadPassword(tag: server.keychainTag)
|
||||
if let pw = savedPw, !pw.isEmpty {
|
||||
client.authenticateUser(server.savedUsername, password: pw)
|
||||
} else {
|
||||
showPasswordPrompt = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
// Set before disconnect so its event cannot arm reconnect.
|
||||
userInitiatedDisconnect = true
|
||||
cancelReconnect()
|
||||
lastSession = nil
|
||||
session?.leaveVoice()
|
||||
session?.client.disconnect()
|
||||
IOSAudioEngine.shared.stop()
|
||||
AudioSessionManager.shared.deactivateSession()
|
||||
session = nil
|
||||
connectingClient?.disconnect()
|
||||
connectingClient = nil
|
||||
connectingServer = nil
|
||||
connectedServer = nil
|
||||
isConnecting = false
|
||||
connectStatus = ""
|
||||
showPasswordPrompt = false
|
||||
pendingIdentity = nil
|
||||
}
|
||||
|
||||
// MARK: - Auth actions (called from prompt sheets)
|
||||
|
||||
func authenticateUser(username: String, password: String) {
|
||||
connectingClient?.authenticateUser(username, password: password)
|
||||
showPasswordPrompt = false
|
||||
}
|
||||
|
||||
func confirmServerIdentity(accept: Bool) {
|
||||
connectingClient?.confirmServerIdentity(accept: accept)
|
||||
pendingIdentity = nil
|
||||
if !accept { cancelConnect() }
|
||||
}
|
||||
|
||||
func cancelConnect() {
|
||||
// User explicitly cancelled — no reconnect for the resulting .disconnected event.
|
||||
userInitiatedDisconnect = true
|
||||
cancelReconnect()
|
||||
lastSession = nil
|
||||
connectingClient?.disconnect()
|
||||
connectingClient = nil
|
||||
connectingServer = nil
|
||||
connectedServer = nil
|
||||
isConnecting = false
|
||||
connectStatus = ""
|
||||
showPasswordPrompt = false
|
||||
pendingIdentity = nil
|
||||
}
|
||||
|
||||
// MARK: - Reconnect orchestration
|
||||
|
||||
private func cancelReconnect() {
|
||||
reconnectTask?.cancel()
|
||||
reconnectTask = nil
|
||||
stopPathMonitor()
|
||||
}
|
||||
|
||||
/// Schedules the next reconnect with exponential backoff capped at 30 seconds.
|
||||
private func scheduleReconnect() {
|
||||
guard !userInitiatedDisconnect, let last = lastSession else { return }
|
||||
reconnectTask?.cancel()
|
||||
reconnectAttempt = max(1, reconnectAttempt + 1)
|
||||
let delaySec = min(pow(2.0, Double(reconnectAttempt - 1)), 30.0)
|
||||
connectStatus = "Reconnecting (attempt \(reconnectAttempt))…"
|
||||
|
||||
startPathMonitor()
|
||||
|
||||
let task = Task { [weak self, last] in
|
||||
guard let self else { return }
|
||||
try? await Task.sleep(nanoseconds: UInt64(delaySec * 1_000_000_000))
|
||||
if Task.isCancelled { return }
|
||||
guard !self.userInitiatedDisconnect else { return }
|
||||
guard self.lastSession != nil else { return }
|
||||
guard self.session == nil else { return }
|
||||
self.connectTo(last.server, restoring: last)
|
||||
}
|
||||
reconnectTask = task
|
||||
}
|
||||
|
||||
private func startPathMonitor() {
|
||||
guard pathMonitor == nil else { return }
|
||||
let monitor = NWPathMonitor()
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
guard !self.userInitiatedDisconnect else { return }
|
||||
let sig = Self.pathSignature(path)
|
||||
let prevSig = self.lastPathSignature
|
||||
self.lastPathSignature = sig
|
||||
if prevSig == nil { return }
|
||||
|
||||
if self.session != nil {
|
||||
if path.status != .satisfied || sig != prevSig {
|
||||
self.proactiveReconnect()
|
||||
}
|
||||
} else if self.lastSession != nil {
|
||||
if path.status == .satisfied {
|
||||
self.reconnectAttempt = 0
|
||||
self.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
monitor.start(queue: pathQueue)
|
||||
pathMonitor = monitor
|
||||
}
|
||||
|
||||
private func stopPathMonitor() {
|
||||
pathMonitor?.cancel()
|
||||
pathMonitor = nil
|
||||
lastPathSignature = nil
|
||||
}
|
||||
|
||||
private static func pathSignature(_ path: NWPath) -> String {
|
||||
guard path.status == .satisfied else { return "unsatisfied" }
|
||||
var parts: [String] = []
|
||||
if path.usesInterfaceType(.wifi) { parts.append("wifi") }
|
||||
if path.usesInterfaceType(.cellular) { parts.append("cellular") }
|
||||
if path.usesInterfaceType(.wiredEthernet) { parts.append("wired") }
|
||||
if path.usesInterfaceType(.other) { parts.append("other") }
|
||||
return parts.isEmpty ? "none" : parts.sorted().joined(separator: "+")
|
||||
}
|
||||
|
||||
// MARK: - Live-session disconnect (called by SessionState)
|
||||
|
||||
/// Receives disconnects after `SessionState` takes ownership of authenticated events.
|
||||
func onLiveSessionDisconnected() {
|
||||
guard !userInitiatedDisconnect else { return }
|
||||
teardownLiveSessionAndReconnect(sound: false)
|
||||
}
|
||||
|
||||
private func proactiveReconnect() {
|
||||
guard !userInitiatedDisconnect else { return }
|
||||
guard session != nil else { return }
|
||||
teardownLiveSessionAndReconnect(sound: true)
|
||||
}
|
||||
|
||||
private func teardownLiveSessionAndReconnect(sound: Bool) {
|
||||
if let s = session, let srv = connectedServer {
|
||||
lastSession = LastSession(
|
||||
server: srv,
|
||||
channelId: s.currentChannelId,
|
||||
voiceSubscribed: s.voiceState.voiceSubscribed,
|
||||
micMuted: s.voiceState.selfMuted,
|
||||
deafened: s.voiceState.selfDeafened)
|
||||
}
|
||||
IOSAudioEngine.shared.stop()
|
||||
AudioSessionManager.shared.deactivateSession()
|
||||
session = nil
|
||||
isConnecting = false
|
||||
connectingClient = nil
|
||||
connectedServer = nil
|
||||
if sound {
|
||||
EventFeedback.shared.play(.connectionLost)
|
||||
EventFeedback.shared.speak("Network changed — reconnecting")
|
||||
}
|
||||
reconnectAttempt = 0
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
// MARK: - Connect event handler
|
||||
|
||||
private func handleConnectEvent(_ ev: VoiceCatEvent, server: SavedServer,
|
||||
restoring: LastSession?) {
|
||||
switch ev.type {
|
||||
case .connectionState:
|
||||
switch ev.connectionState {
|
||||
case .connecting: connectStatus = (restoring != nil) ? "Reconnecting…" : "Connecting…"
|
||||
case .tlsHandshake: connectStatus = "TLS handshake…"
|
||||
case .authenticating: connectStatus = "Authenticating…"
|
||||
case .verifyingIdentity: connectStatus = "Verifying server identity…"
|
||||
case .connected: connectStatus = "Connected"
|
||||
default: break
|
||||
}
|
||||
case .serverIdentity:
|
||||
guard !identityHandled else { break }
|
||||
let tofuStatus = ev.tofuStatus ?? .firstConnect
|
||||
if tofuStatus == .matched {
|
||||
connectingClient?.confirmServerIdentity(accept: true)
|
||||
} else {
|
||||
identityHandled = true
|
||||
let displayText = connectingClient?.getServerIdentityDisplay() ?? ""
|
||||
pendingIdentity = PendingIdentity(displayText: displayText, tofuStatus: tofuStatus)
|
||||
}
|
||||
case .authResult:
|
||||
if ev.result == .ok {
|
||||
guard let client = connectingClient else { break }
|
||||
let perms = client.getPermissions()
|
||||
let newSession = SessionState(client: client, selfUserId: ev.userId, permissions: perms)
|
||||
newSession.appState = self
|
||||
connectingClient = nil
|
||||
isConnecting = false
|
||||
connectStatus = ""
|
||||
showPasswordPrompt = false
|
||||
connectedServer = server
|
||||
self.session = newSession
|
||||
EventFeedback.shared.play(.login)
|
||||
EventFeedback.shared.speak(restoring != nil ? "Reconnected" : "Connected")
|
||||
// External-playback mode was enabled before connect() so the core never opens a
|
||||
// miniaudio device on iOS (the single ordering rule of the unified audio path).
|
||||
// Now activate the session and start the engine in listening mode so remote audio
|
||||
// plays the moment someone talks, even before we join voice (no "can't hear anyone").
|
||||
do {
|
||||
try AudioSessionManager.shared.ensureSessionActive()
|
||||
} catch {
|
||||
print("Audio session activate on connect failed: \(error)")
|
||||
}
|
||||
IOSAudioEngine.shared.startListening(client: client)
|
||||
// The path monitor runs the whole time we're connected so a network change fires
|
||||
// proactiveReconnect immediately instead of waiting for the C core's TCP keepalive
|
||||
// timeout (~30-60 s on a hard Wi-Fi drop). It stays armed across reconnects and is
|
||||
// stopped only on user-initiated disconnect.
|
||||
startPathMonitor()
|
||||
|
||||
// Reconnect restore: rejoin the prior channel and re-enable voice/mic if they
|
||||
// were on. The session is fresh (server auto-places us in Lobby), so the restore
|
||||
// is driven through SessionState.requestRestore, which issues a JoinChannel then
|
||||
// (on the resulting .joinResult) re-arms voice + mute/deafen. A successful auth
|
||||
// means the server is reachable, so the backoff counter resets and `lastSession`
|
||||
// clears; the path monitor keeps watching for the next change.
|
||||
if let restoring {
|
||||
newSession.requestRestore(channelId: restoring.channelId,
|
||||
voiceSubscribed: restoring.voiceSubscribed,
|
||||
micMuted: restoring.micMuted,
|
||||
deafened: restoring.deafened)
|
||||
reconnectAttempt = 0
|
||||
lastSession = nil
|
||||
}
|
||||
} else {
|
||||
connectStatus = "Auth failed: \(ev.result.description)"
|
||||
showPasswordPrompt = true
|
||||
}
|
||||
case .disconnected:
|
||||
// This handler runs ONLY during the connecting phase — after auth success
|
||||
// `SessionState.init` overwrites `client.onEvent`, so a live-session disconnect
|
||||
// reaches `SessionState.handleEvent` and comes back via
|
||||
// `onLiveSessionDisconnected`, not here. Two outcomes for this branch:
|
||||
// - A reconnect's connecting phase failed (`lastSession != nil`, set by a prior
|
||||
// teardown) → re-arm `scheduleReconnect` so the backoff loop continues.
|
||||
// - A fresh connect failed before auth (`lastSession == nil`) → show the error, do
|
||||
// not auto-reconnect (the user should retry manually once the server is reachable).
|
||||
connectingClient = nil
|
||||
isConnecting = false
|
||||
IOSAudioEngine.shared.stop()
|
||||
AudioSessionManager.shared.deactivateSession()
|
||||
|
||||
if userInitiatedDisconnect {
|
||||
connectStatus = ""
|
||||
showPasswordPrompt = false
|
||||
pendingIdentity = nil
|
||||
lastSession = nil
|
||||
connectedServer = nil
|
||||
cancelReconnect()
|
||||
} else if lastSession != nil {
|
||||
// Mid-reconnect drop — keep the backoff loop going.
|
||||
EventFeedback.shared.play(.connectionLost)
|
||||
EventFeedback.shared.speak("Connection lost — reconnecting")
|
||||
scheduleReconnect()
|
||||
} else {
|
||||
// Fresh connect failed before auth. Surface the reason; no auto-reconnect.
|
||||
connectStatus = ev.text ?? "Disconnected"
|
||||
showPasswordPrompt = false
|
||||
pendingIdentity = nil
|
||||
connectedServer = nil
|
||||
cancelReconnect()
|
||||
}
|
||||
case .error:
|
||||
connectStatus = ev.text ?? "Unknown error"
|
||||
// Errors don't disconnect us; the .disconnected event handles teardown/reconnect.
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import AVFoundation
|
||||
import os
|
||||
import VoiceCatCore
|
||||
|
||||
private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "AudioSessionManager")
|
||||
|
||||
@MainActor
|
||||
final class AudioSessionManager {
|
||||
static let shared = AudioSessionManager()
|
||||
|
||||
/// Tracks whether WE activated the session. The session must be active whenever the
|
||||
/// AudioEngine is running (for capture OR playback), so it is activated when any audio
|
||||
/// needs to play (a remote stream started OR the user joins voice) and only deactivated
|
||||
/// when disconnecting from the server — not when leaving voice, since the user may still
|
||||
/// want to hear remote audio.
|
||||
private var isSessionActive = false
|
||||
|
||||
/// Whether the AVAudioSession is currently active (we activated it). Read by `IOSAudioRouter`
|
||||
/// to decide whether the post-activation A2DP speaker fallback can be applied.
|
||||
var isActive: Bool { isSessionActive }
|
||||
|
||||
func configure() {
|
||||
// Load stored audio routing preferences and apply them before any audio session
|
||||
// activation. IOSAudioRouter drives all iOS audio route selection via AVAudioSession;
|
||||
// miniaudio (the core) does NOT touch AVAudioSession on iOS.
|
||||
IOSAudioRouter.shared.loadStoredPreferences()
|
||||
IOSAudioRouter.shared.applyConfiguration()
|
||||
IOSAudioRouter.shared.refreshRoutes()
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(handleInterruption),
|
||||
name: AVAudioSession.interruptionNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(handleRouteChange),
|
||||
name: AVAudioSession.routeChangeNotification, object: nil)
|
||||
}
|
||||
|
||||
/// Idempotently restores audio after an interruption or external route change.
|
||||
func recoverAudio() {
|
||||
guard IOSAudioEngine.shared.isConnected else { return }
|
||||
do {
|
||||
try ensureSessionActive()
|
||||
} catch {
|
||||
logger.error("recoverAudio — session activate failed: \(error.localizedDescription)")
|
||||
}
|
||||
IOSAudioRouter.shared.applyConfiguration()
|
||||
if isSessionActive { IOSAudioRouter.shared.applyA2dpSpeakerFallback() }
|
||||
IOSAudioEngine.shared.reconfigure()
|
||||
logSessionState("after recoverAudio")
|
||||
}
|
||||
|
||||
/// Activate the AVAudioSession if not already active. Call before any audio I/O:
|
||||
/// when the user joins voice, or when a remote stream starts (so playback works even
|
||||
/// before the user has joined voice). Idempotent — safe to call multiple times.
|
||||
func ensureSessionActive() throws {
|
||||
guard !isSessionActive else {
|
||||
logger.debug("ensureSessionActive — already active, skipping")
|
||||
return
|
||||
}
|
||||
IOSAudioRouter.shared.applyConfiguration()
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setActive(true, options: [])
|
||||
isSessionActive = true
|
||||
// For the A2DP output presets, pick the right output once the session is live: defer to
|
||||
// a connected A2DP/wired/AirPlay route, but fall back to the loud built-in speaker (not
|
||||
// the quiet earpiece) when nothing external is connected. See applyA2dpSpeakerFallback().
|
||||
IOSAudioRouter.shared.applyA2dpSpeakerFallback()
|
||||
let route = AVAudioSession.sharedInstance().currentRoute
|
||||
let outputNames = route.outputs.map { $0.portName }.joined(separator: ", ")
|
||||
let inputNames = route.inputs.map { $0.portName }.joined(separator: ", ")
|
||||
logger.info("session activated — outputs: [\(outputNames)], inputs: [\(inputNames)]")
|
||||
logSessionState("after activate")
|
||||
}
|
||||
|
||||
/// Deactivate the AVAudioSession. Call ONLY when disconnecting from the server — not
|
||||
/// when leaving voice (the user may still want to hear remote audio).
|
||||
func deactivateSession() {
|
||||
guard isSessionActive else {
|
||||
logger.debug("deactivateSession — not active, skipping")
|
||||
return
|
||||
}
|
||||
try? AVAudioSession.sharedInstance().setActive(false,
|
||||
options: .notifyOthersOnDeactivation)
|
||||
isSessionActive = false
|
||||
logger.info("session deactivated")
|
||||
}
|
||||
|
||||
/// Log the full AVAudioSession state — category, mode, options, and active route.
|
||||
/// Useful for diagnosing routing issues, e.g. confirming the session stays
|
||||
/// `PlayAndRecord` with `allowBluetoothA2DP` and keeps the A2DP output route even
|
||||
/// after the mic engine starts.
|
||||
func logSessionState(_ when: String) {
|
||||
let s = AVAudioSession.sharedInstance()
|
||||
var opts: [String] = []
|
||||
let o = s.categoryOptions
|
||||
if o.contains(.mixWithOthers) { opts.append("mixWithOthers") }
|
||||
if o.contains(.duckOthers) { opts.append("duckOthers") }
|
||||
if o.contains(.allowBluetoothHFP) { opts.append("allowBluetoothHFP") }
|
||||
if o.contains(.allowBluetoothA2DP) { opts.append("allowBluetoothA2DP") }
|
||||
if o.contains(.allowAirPlay) { opts.append("allowAirPlay") }
|
||||
if o.contains(.defaultToSpeaker) { opts.append("defaultToSpeaker") }
|
||||
let outs = s.currentRoute.outputs.map { "\($0.portName)[\($0.portType.rawValue)]" }
|
||||
.joined(separator: ", ")
|
||||
let ins = s.currentRoute.inputs.map { "\($0.portName)[\($0.portType.rawValue)]" }
|
||||
.joined(separator: ", ")
|
||||
logger.info("""
|
||||
[SESSION @ \(when, privacy: .public)] category=\(s.category.rawValue, privacy: .public) \
|
||||
mode=\(s.mode.rawValue, privacy: .public) options=[\(opts.joined(separator: ","), privacy: .public)] \
|
||||
inputs=[\(ins, privacy: .public)] outputs=[\(outs, privacy: .public)] \
|
||||
inputCh=\(s.inputNumberOfChannels) outputCh=\(s.outputNumberOfChannels)
|
||||
""")
|
||||
}
|
||||
|
||||
@objc private func handleInterruption(_ notification: Notification) {
|
||||
guard let info = notification.userInfo,
|
||||
let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||
let type = AVAudioSession.InterruptionType(rawValue: typeValue)
|
||||
else { return }
|
||||
|
||||
switch type {
|
||||
case .began:
|
||||
// The system stops our AVAudioEngine and deactivates the session. Nothing to tear
|
||||
// down — `IOSAudioEngine` rebuilds on resume.
|
||||
logger.info("interruption began — session suspended by system")
|
||||
isSessionActive = false
|
||||
case .ended:
|
||||
// Always attempt recovery when we have a live session. iOS sometimes ends an
|
||||
// interruption without the `.shouldResume` hint (e.g. Siri), and the previous
|
||||
// behavior of only reactivating when `.shouldResume` was set left the session
|
||||
// permanently dead — audio never came back. `recoverAudio()` is intent-gated on
|
||||
// `IOSAudioEngine.isConnected` and idempotent, so speculatively calling it is safe.
|
||||
logger.info("interruption ended — recovery requested")
|
||||
recoverAudio()
|
||||
@unknown default: break
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleRouteChange(_ notification: Notification) {
|
||||
guard let info = notification.userInfo,
|
||||
let reasonValue = info[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
||||
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue)
|
||||
else {
|
||||
logger.warning("routeChange — unknown reason, refreshing + recovery")
|
||||
IOSAudioRouter.shared.refreshRoutes()
|
||||
NotificationCenter.default.post(name: .voiceCatDeviceListChanged, object: nil)
|
||||
recoverAudio()
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("routeChange reason=\(self.reasonLabel(reason))")
|
||||
IOSAudioRouter.shared.refreshRoutes()
|
||||
NotificationCenter.default.post(name: .voiceCatDeviceListChanged, object: nil)
|
||||
|
||||
// Ignore notifications caused by our own configuration calls; rebuilding for them
|
||||
// recursively emits more route changes. Engine-configuration notifications remain
|
||||
// the recovery path if a self-initiated change actually stops AVAudioEngine.
|
||||
if reason != .categoryChange && reason != .routeConfigurationChange && reason != .override {
|
||||
recoverAudio()
|
||||
}
|
||||
logSessionState("route change (\(reasonLabel(reason)))")
|
||||
}
|
||||
|
||||
private func reasonLabel(_ reason: AVAudioSession.RouteChangeReason) -> String {
|
||||
switch reason {
|
||||
case .oldDeviceUnavailable: return "oldDeviceUnavailable"
|
||||
case .newDeviceAvailable: return "newDeviceAvailable"
|
||||
case .categoryChange: return "categoryChange"
|
||||
case .override: return "override"
|
||||
case .wakeFromSleep: return "wakeFromSleep"
|
||||
case .noSuitableRouteForCategory: return "noSuitableRouteForCategory"
|
||||
case .routeConfigurationChange: return "routeConfigurationChange"
|
||||
case .unknown: return "unknown"
|
||||
@unknown default: return "unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let voiceCatDeviceListChanged = Notification.Name("cat.voice.deviceListChanged")
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
// BroadcastAudioPump — host side of iOS screen-audio sharing (docs/voice.md §9, iOS detail).
|
||||
//
|
||||
// Drains the App Group shared-memory ring written by the broadcast upload extension and hands
|
||||
// whole 20 ms frames to a feed closure (which calls VoiceCatClient.feedPcm). The host app owns
|
||||
// the SCREEN_AUDIO stream, so screen audio appears as a second stream of the SAME user — exactly
|
||||
// like the Windows/macOS desktop-audio share, and a single session (no separate connection).
|
||||
//
|
||||
// It reacts to the extension's Darwin notifications for prompt start/stop, and the drain timer
|
||||
// also watches the ring's active flag as a safety net if a notification is missed. The ring
|
||||
// always carries stereo; we downmix to mono when the stream's effective config is mono.
|
||||
//
|
||||
// Not @MainActor: the drain runs on a background queue (feedPcm is thread-safe). The start/stop
|
||||
// callbacks are dispatched to main so they can drive @MainActor SessionState.
|
||||
final class BroadcastAudioPump {
|
||||
|
||||
/// The host should start the SCREEN_AUDIO stream (a broadcast became active).
|
||||
var onBroadcastStarted: (() -> Void)?
|
||||
/// The host should stop the SCREEN_AUDIO stream (the broadcast ended).
|
||||
var onBroadcastFinished: (() -> Void)?
|
||||
|
||||
private static let frameSamplesPerChannel = 960 // 20 ms @ 48 kHz
|
||||
|
||||
private let queue = DispatchQueue(label: "cat.voice.broadcast.pump")
|
||||
private var ring: BroadcastAudioRing?
|
||||
private var timer: DispatchSourceTimer?
|
||||
private var feed: ((UnsafePointer<Int16>, Int, UInt32) -> Void)?
|
||||
private var streamChannels = 1
|
||||
private var ringChannels = 2
|
||||
private var pending: [Int16] = [] // interleaved at ringChannels width
|
||||
private var scratch = [Int16](repeating: 0, count: 8192)
|
||||
private var started = false
|
||||
|
||||
// MARK: - Lifecycle (host connect/disconnect)
|
||||
|
||||
func start() {
|
||||
guard !started else { return }
|
||||
ring = try? BroadcastAudioRing()
|
||||
started = true
|
||||
registerDarwin()
|
||||
// Host reconnected while a broadcast is still running — pick it up.
|
||||
if ring?.isActive == true { onBroadcastStarted?() }
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard started else { return }
|
||||
started = false
|
||||
unregisterDarwin()
|
||||
endFeeding()
|
||||
ring = nil
|
||||
}
|
||||
|
||||
// MARK: - Feeding (driven by the host once the stream is live)
|
||||
|
||||
/// Begin draining the ring into `feed`. Called after the SCREEN_AUDIO stream's
|
||||
/// `.streamStarted` event, when its effective channel count is known.
|
||||
func beginFeeding(streamChannels: UInt32,
|
||||
feed: @escaping (UnsafePointer<Int16>, Int, UInt32) -> Void) {
|
||||
queue.async {
|
||||
self.streamChannels = max(1, min(2, Int(streamChannels)))
|
||||
self.ringChannels = max(1, Int(self.ring?.channels ?? 2))
|
||||
self.feed = feed
|
||||
self.pending.removeAll(keepingCapacity: true)
|
||||
self.ring?.drainStale() // discard pre-roll buffered before we were ready
|
||||
self.startTimer()
|
||||
}
|
||||
}
|
||||
|
||||
func endFeeding() {
|
||||
queue.async {
|
||||
self.timer?.cancel()
|
||||
self.timer = nil
|
||||
self.feed = nil
|
||||
self.pending.removeAll(keepingCapacity: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Drain
|
||||
|
||||
private func startTimer() {
|
||||
let t = DispatchSource.makeTimerSource(queue: queue)
|
||||
t.schedule(deadline: .now(), repeating: .milliseconds(10), leeway: .milliseconds(2))
|
||||
t.setEventHandler { [weak self] in self?.drain() }
|
||||
timer = t
|
||||
t.resume()
|
||||
}
|
||||
|
||||
private func drain() {
|
||||
guard let ring, let feed else { return }
|
||||
// Safety net: broadcast ended but we missed the Darwin note.
|
||||
if !ring.isActive {
|
||||
DispatchQueue.main.async { [weak self] in self?.onBroadcastFinished?() }
|
||||
return
|
||||
}
|
||||
while true {
|
||||
let got = scratch.withUnsafeMutableBufferPointer { ring.read(into: $0) }
|
||||
if got == 0 { break }
|
||||
pending.append(contentsOf: scratch[0..<got])
|
||||
if got < scratch.count { break }
|
||||
}
|
||||
emitFrames(feed)
|
||||
}
|
||||
|
||||
private func emitFrames(_ feed: (UnsafePointer<Int16>, Int, UInt32) -> Void) {
|
||||
let n = Self.frameSamplesPerChannel
|
||||
let rc = ringChannels
|
||||
let sc = streamChannels
|
||||
let inFrame = n * rc
|
||||
while pending.count >= inFrame {
|
||||
if sc == rc {
|
||||
pending.withUnsafeBufferPointer { feed($0.baseAddress!, n, UInt32(sc)) }
|
||||
} else if sc == 1 && rc == 2 {
|
||||
var mono = [Int16](repeating: 0, count: n)
|
||||
pending.withUnsafeBufferPointer { buf in
|
||||
let p = buf.baseAddress!
|
||||
for i in 0..<n { mono[i] = Int16((Int(p[i * 2]) + Int(p[i * 2 + 1])) / 2) }
|
||||
}
|
||||
mono.withUnsafeBufferPointer { feed($0.baseAddress!, n, 1) }
|
||||
} else if sc == 2 && rc == 1 {
|
||||
var stereo = [Int16](repeating: 0, count: n * 2)
|
||||
pending.withUnsafeBufferPointer { buf in
|
||||
let p = buf.baseAddress!
|
||||
for i in 0..<n { stereo[i * 2] = p[i]; stereo[i * 2 + 1] = p[i] }
|
||||
}
|
||||
stereo.withUnsafeBufferPointer { feed($0.baseAddress!, n, 2) }
|
||||
}
|
||||
pending.removeFirst(inFrame)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Darwin notifications
|
||||
|
||||
private func registerDarwin() {
|
||||
let observer = Unmanaged.passUnretained(self).toOpaque()
|
||||
let center = CFNotificationCenterGetDarwinNotifyCenter()
|
||||
let callback: CFNotificationCallback = { _, observer, name, _, _ in
|
||||
guard let observer, let name else { return }
|
||||
let pump = Unmanaged<BroadcastAudioPump>.fromOpaque(observer).takeUnretainedValue()
|
||||
let raw = name.rawValue as String
|
||||
DispatchQueue.main.async {
|
||||
if raw == BroadcastNotification.started { pump.onBroadcastStarted?() }
|
||||
else if raw == BroadcastNotification.finished { pump.onBroadcastFinished?() }
|
||||
}
|
||||
}
|
||||
CFNotificationCenterAddObserver(center, observer, callback,
|
||||
BroadcastNotification.started as CFString, nil, .deliverImmediately)
|
||||
CFNotificationCenterAddObserver(center, observer, callback,
|
||||
BroadcastNotification.finished as CFString, nil, .deliverImmediately)
|
||||
}
|
||||
|
||||
private func unregisterDarwin() {
|
||||
CFNotificationCenterRemoveEveryObserver(
|
||||
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||
Unmanaged.passUnretained(self).toOpaque())
|
||||
}
|
||||
|
||||
deinit { if started { unregisterDarwin() } }
|
||||
}
|
||||
@@ -1,650 +0,0 @@
|
||||
import AVFoundation
|
||||
import os
|
||||
import VoiceCatCore
|
||||
|
||||
private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "IOSAudioRouter")
|
||||
|
||||
/// Owns `AVAudioSession` routing for the iOS external-audio path.
|
||||
/// Route configuration and ordering constraints are documented in `docs/voice.md`.
|
||||
@MainActor
|
||||
final class IOSAudioRouter: ObservableObject {
|
||||
|
||||
static let shared = IOSAudioRouter()
|
||||
|
||||
// MARK: - Published state (drives SettingsView)
|
||||
|
||||
@Published var inputPorts: [IOSAudioInputPort] = []
|
||||
@Published var outputRoutes: [IOSAudioOutputRoute] = []
|
||||
@Published var bluetoothMode: BluetoothMode = .btHfpVoice
|
||||
/// User-requested speaker fallback: when on, route to the built-in speaker instead of the
|
||||
/// earpiece (receiver) when no headphones/Bluetooth are connected. Orthogonal to the
|
||||
/// bluetooth mode and presets. Default off — current behavior is unchanged for existing users.
|
||||
@Published var forceSpeaker: Bool = false
|
||||
@Published var micMode: MicMode = .standard
|
||||
@Published var captureChannels: CaptureChannels = .mono
|
||||
@Published var selectedInputPortId: String?
|
||||
@Published var selectedDataSourceId: String?
|
||||
@Published var selectedPolarPattern: String?
|
||||
/// Master voice-processing switch (Apple VPIO: AEC + noise suppression bundled together).
|
||||
/// iOS exposes no per-stage toggle, so this is the finest "echo cancellation / noise
|
||||
/// reduction" control available. Only takes effect on a VPIO-capable config (mono + standard
|
||||
/// + not A2DP); stereo / A2DP configs can't use VPIO regardless. Default on.
|
||||
@Published var voiceProcessingEnabled: Bool = true
|
||||
/// VPIO automatic gain control — the one VPIO sub-stage iOS lets us toggle independently.
|
||||
/// Only meaningful when voice processing is active. Default on.
|
||||
@Published var agcEnabled: Bool = true
|
||||
@Published var showsRawModeSpeakerWarning: Bool = false
|
||||
@Published var showsA2dpNoAecWarning: Bool = false
|
||||
@Published var hasBluetoothDevice: Bool = false
|
||||
@Published var hasWiredHeadset: Bool = false
|
||||
|
||||
/// Audio presets — the four scenarios from the product spec. Pick a preset for a quick start,
|
||||
/// then fine-tune individual settings under "Advanced". HFP / wired headsets are not separate
|
||||
/// presets: Voice Chat lets the system route to them, and Advanced exposes manual selection.
|
||||
enum AudioPreset: String, CaseIterable, Identifiable {
|
||||
/// Voice chat: Apple VPIO does real AEC + noise suppression + AGC. Mono. The system picks
|
||||
/// the best route (Bluetooth HFP / wired / speaker / earpiece). Always available.
|
||||
case voiceChat = "Voice Chat"
|
||||
/// Internal **stereo** built-in mic regardless of the output route. A2DP output when a
|
||||
/// Bluetooth headset is connected, else built-in speaker / wired. No VPIO (stereo can't
|
||||
/// use it). Always available.
|
||||
case stereoMic = "Stereo Mic"
|
||||
/// Internal **mono** built-in mic regardless of the output route. A2DP output when a
|
||||
/// Bluetooth headset is connected, else built-in speaker / wired. No VPIO. Always available.
|
||||
case monoMic = "Mono Mic"
|
||||
/// Everything manual — input port, mic orientation / polar pattern, mono/stereo, Bluetooth
|
||||
/// mode, raw vs standard, and the VPIO / AGC toggles. Also the display state when the
|
||||
/// individual settings don't match a named preset.
|
||||
case advanced = "Advanced"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var bluetoothMode: BluetoothMode {
|
||||
switch self {
|
||||
case .voiceChat: return .btHfpVoice
|
||||
// Internal-mic presets: A2DP output when BT is connected; speaker/wired when not.
|
||||
case .stereoMic, .monoMic: return .builtInMicBtA2dp
|
||||
case .advanced: return .builtInMicSpeaker // placeholder; Advanced sets it manually
|
||||
}
|
||||
}
|
||||
|
||||
var captureChannels: CaptureChannels {
|
||||
self == .stereoMic ? .stereo : .mono
|
||||
}
|
||||
|
||||
var micMode: MicMode { .standard }
|
||||
|
||||
/// Whether this preset explicitly pins the built-in mic port (the internal-mic presets).
|
||||
var usesBuiltInMic: Bool {
|
||||
switch self {
|
||||
case .stereoMic, .monoMic: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum BluetoothMode: String, CaseIterable, Identifiable {
|
||||
case btHfpVoice = "BT HFP Voice"
|
||||
case builtInMicBtA2dp = "Built-in Mic + BT A2DP"
|
||||
case builtInMicSpeaker = "Built-in Mic + Speaker"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
enum MicMode: String, CaseIterable, Identifiable {
|
||||
case standard = "Standard"
|
||||
case raw = "Raw / Studio"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
enum CaptureChannels: String, CaseIterable, Identifiable {
|
||||
case mono = "Mono"
|
||||
case stereo = "Stereo"
|
||||
var id: String { rawValue }
|
||||
var channelCount: UInt32 { self == .stereo ? 2 : 1 }
|
||||
}
|
||||
|
||||
// MARK: - UserDefaults keys
|
||||
|
||||
private let kBluetoothMode = "cat.voice.audio.bluetoothMode"
|
||||
private let kMicMode = "cat.voice.audio.micMode"
|
||||
private let kCaptureChannels = "cat.voice.audio.captureChannels"
|
||||
private let kInputPortId = "cat.voice.audio.inputPortId"
|
||||
private let kDataSourceId = "cat.voice.audio.dataSourceId"
|
||||
private let kPolarPattern = "cat.voice.audio.polarPattern"
|
||||
private let kPreset = "cat.voice.audio.preset"
|
||||
private let kForceSpeaker = "cat.voice.audio.forceSpeaker"
|
||||
private let kVoiceProcessing = "cat.voice.audio.voiceProcessing"
|
||||
private let kAgc = "cat.voice.audio.agc"
|
||||
|
||||
/// AVAudioSession setters can synchronously emit route-change notifications.
|
||||
private var isApplyingConfiguration = false
|
||||
|
||||
/// Prevents redundant overrides; `setCategory` invalidates the cached value.
|
||||
private var lastAppliedOutputOverride: AVAudioSession.PortOverride?
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Load / refresh from AVAudioSession
|
||||
|
||||
/// Refresh the published input port list and output route list from the current
|
||||
/// AVAudioSession state. Call after any route change or when the settings view appears.
|
||||
func refreshRoutes() {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
let currentInput = session.preferredInput
|
||||
let currentDataSource = currentInput?.preferredDataSource?.dataSourceID ?? nil
|
||||
let currentPolarPattern = currentInput?.preferredDataSource?.preferredPolarPattern?.rawValue
|
||||
|
||||
inputPorts = (session.availableInputs ?? []).map { port in
|
||||
let dataSources = port.dataSources?.map { ds in
|
||||
IOSAudioDataSource(
|
||||
id: String(describing: ds.dataSourceID),
|
||||
name: ds.dataSourceName,
|
||||
polarPatterns: ds.supportedPolarPatterns?.map { $0.rawValue },
|
||||
isSelected: currentDataSource == ds.dataSourceID,
|
||||
selectedPolarPattern: currentPolarPattern
|
||||
)
|
||||
}
|
||||
return IOSAudioInputPort(
|
||||
id: port.uid,
|
||||
name: port.portName,
|
||||
portType: port.portType.rawValue,
|
||||
dataSources: dataSources,
|
||||
isSelected: currentInput?.uid == port.uid
|
||||
)
|
||||
}
|
||||
|
||||
outputRoutes = session.currentRoute.outputs.map { port in
|
||||
IOSAudioOutputRoute(
|
||||
id: port.uid,
|
||||
name: port.portName,
|
||||
portType: port.portType.rawValue
|
||||
)
|
||||
}
|
||||
|
||||
if selectedInputPortId == nil {
|
||||
selectedInputPortId = currentInput?.uid ?? inputPorts.first?.id
|
||||
}
|
||||
if selectedDataSourceId == nil {
|
||||
selectedDataSourceId = currentDataSource.map { String(describing: $0) }
|
||||
}
|
||||
if selectedPolarPattern == nil {
|
||||
selectedPolarPattern = currentPolarPattern
|
||||
}
|
||||
|
||||
updateWarnings()
|
||||
detectAudioDevices()
|
||||
}
|
||||
|
||||
/// Detect connected audio devices — Bluetooth (A2DP/HFP) and wired (headphones,
|
||||
/// headset mic, USB audio). Drives which presets are shown: BT presets only appear
|
||||
/// when a BT device is connected, wired presets only when a wired device is connected.
|
||||
/// This avoids confusing users with irrelevant options.
|
||||
private func detectAudioDevices() {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
let route = session.currentRoute
|
||||
let inputs = session.availableInputs ?? []
|
||||
|
||||
// Bluetooth: check current route + available inputs
|
||||
let hasBTOutput = route.outputs.contains {
|
||||
$0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP
|
||||
}
|
||||
let hasBTInput = route.inputs.contains { $0.portType == .bluetoothHFP }
|
||||
let hasBTAvailable = inputs.contains {
|
||||
$0.portType == .bluetoothHFP || $0.portType == .bluetoothA2DP
|
||||
}
|
||||
let wasBT = hasBluetoothDevice
|
||||
hasBluetoothDevice = hasBTOutput || hasBTInput || hasBTAvailable
|
||||
if hasBluetoothDevice != wasBT {
|
||||
logger.info("bluetooth device \(self.hasBluetoothDevice ? "connected" : "disconnected")")
|
||||
}
|
||||
|
||||
// Wired: headphones, headset mic, USB audio (earpods, Lightning/USB-C headsets)
|
||||
let hasWiredOutput = route.outputs.contains {
|
||||
$0.portType == .headphones || $0.portType == .usbAudio
|
||||
}
|
||||
let hasWiredInput = route.inputs.contains {
|
||||
$0.portType == .headsetMic || $0.portType == .usbAudio
|
||||
}
|
||||
let hasWiredAvailable = inputs.contains {
|
||||
$0.portType == .headphones || $0.portType == .headsetMic || $0.portType == .usbAudio
|
||||
}
|
||||
let wasWired = hasWiredHeadset
|
||||
hasWiredHeadset = hasWiredOutput || hasWiredInput || hasWiredAvailable
|
||||
if hasWiredHeadset != wasWired {
|
||||
logger.info("wired headset \(self.hasWiredHeadset ? "connected" : "disconnected")")
|
||||
}
|
||||
}
|
||||
|
||||
/// The presets the user can pick. All four are always available — the named presets simply
|
||||
/// describe what to do "regardless of the output route", and Advanced is always offered.
|
||||
var availablePresets: [AudioPreset] { AudioPreset.allCases }
|
||||
|
||||
/// Which named preset matches the current settings, or `.advanced` if nothing matches.
|
||||
var activePreset: AudioPreset {
|
||||
for preset in [AudioPreset.voiceChat, .stereoMic, .monoMic] {
|
||||
if bluetoothMode == preset.bluetoothMode
|
||||
&& captureChannels == preset.captureChannels
|
||||
&& micMode == preset.micMode {
|
||||
return preset
|
||||
}
|
||||
}
|
||||
return .advanced
|
||||
}
|
||||
|
||||
/// Whether the current configuration should engage Apple's Voice-Processing I/O unit (VPIO:
|
||||
/// real AEC + noise suppression + AGC, driven by `IOSAudioEngine`). VPIO forces mono and
|
||||
/// can't run on an A2DP route, so it is available only for a mono + standard + non-A2DP
|
||||
/// config, and then only when the user hasn't disabled it via the Advanced master toggle.
|
||||
var currentConfigUsesVoiceProcessing: Bool {
|
||||
voiceProcessingEnabled && voiceProcessingAvailable
|
||||
}
|
||||
|
||||
/// Whether the current config *could* use VPIO (mono + standard + non-A2DP), independent of
|
||||
/// the user's master toggle. Drives whether the Advanced "Voice Processing" switch is shown.
|
||||
var voiceProcessingAvailable: Bool {
|
||||
captureChannels == .mono && micMode == .standard && bluetoothMode != .builtInMicBtA2dp
|
||||
}
|
||||
|
||||
// MARK: - Apply configuration
|
||||
|
||||
/// Apply the full audio configuration to AVAudioSession. Call this before (re)building the
|
||||
/// `IOSAudioEngine` graph so the engine binds to the intended route (`applyAndReconfigure`
|
||||
/// does both). Re-entrant-safe: if a route-change notification fires synchronously during a
|
||||
/// `setCategory`/`setPreferredInput` call, the guard prevents re-entry.
|
||||
func applyConfiguration() {
|
||||
guard !isApplyingConfiguration else {
|
||||
logger.debug("applyConfiguration skipped — already applying (re-entrancy guard)")
|
||||
return
|
||||
}
|
||||
isApplyingConfiguration = true
|
||||
// setCategory below can reset the override out from under us, so drop our cached
|
||||
// value — applyA2dpSpeakerFallback will re-derive and re-apply it from scratch.
|
||||
lastAppliedOutputOverride = nil
|
||||
defer { isApplyingConfiguration = false }
|
||||
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
|
||||
// 1. Build category options from bluetooth mode.
|
||||
// .mixWithOthers is ALWAYS set — it keeps other audio (notably VoiceOver, which a
|
||||
// blind user needs to operate the phone) audible while our session is active. Never
|
||||
// drop it.
|
||||
// .defaultToSpeaker is set for the speaker preset and, when the user enables the
|
||||
// `forceSpeaker` toggle, for the HFP preset too — it forces output to the built-in
|
||||
// speaker instead of the receiver while still yielding to connected BT/wired output.
|
||||
// It also actively breaks A2DP routing in .playAndRecord, so it must NEVER be set for
|
||||
// the A2DP preset (forceSpeaker is intentionally ignored there).
|
||||
// .allowAirPlay is added to the Bluetooth presets so AirPlay output also works.
|
||||
var options: AVAudioSession.CategoryOptions = [.mixWithOthers]
|
||||
switch bluetoothMode {
|
||||
case .btHfpVoice:
|
||||
// Voice Chat: allow BOTH HFP and A2DP, let iOS pick the right profile for the
|
||||
// connected device. HFP and A2DP must NOT be made mutually exclusive (HFP-only)
|
||||
// — that blocks A2DP headphones from receiving audio. HFP is preferred (the system
|
||||
// uses it when a two-way mic path is needed); A2DP stays available for output-only.
|
||||
options.insert(.allowBluetoothHFP)
|
||||
options.insert(.allowBluetoothA2DP)
|
||||
options.insert(.allowAirPlay)
|
||||
case .builtInMicBtA2dp:
|
||||
// A2DP output only (no HFP). With HFP disabled the Bluetooth device can only be
|
||||
// an OUTPUT (A2DP), so the system routes the mic to the built-in mic — exactly
|
||||
// what we want for "built-in mic + A2DP output", in either mono OR stereo.
|
||||
options.insert(.allowBluetoothA2DP)
|
||||
options.insert(.allowAirPlay)
|
||||
case .builtInMicSpeaker:
|
||||
// Built-in mic + speaker/wired output only. Prefer speaker over the receiver.
|
||||
options.insert(.defaultToSpeaker)
|
||||
}
|
||||
|
||||
// User-requested speaker fallback: route to the built-in speaker instead of the
|
||||
// receiver when no headphones/BT are connected. Skipped for the A2DP mode because
|
||||
// .defaultToSpeaker breaks A2DP routing (see note above). Redundant for
|
||||
// builtInMicSpeaker, which already sets it.
|
||||
if forceSpeaker && bluetoothMode != .builtInMicBtA2dp {
|
||||
options.insert(.defaultToSpeaker)
|
||||
}
|
||||
|
||||
// 2. Set category + mode, chosen per scenario:
|
||||
// - Stereo capture: .default — .voiceChat (the AEC/VPIO path) forces MONO, so stereo
|
||||
// is only possible in a non-VPIO mode. .default supports multi-capsule stereo AND
|
||||
// keeps the A2DP output route alive.
|
||||
// - Mono raw/studio: .measurement — all system processing off.
|
||||
// - Mono + A2DP output: .videoRecording — keeps A2DP output without VPIO (no AEC).
|
||||
// - Mono standard (HFP or speaker): .voiceChat — hardware AEC/AGC/HPF.
|
||||
let mode: AVAudioSession.Mode
|
||||
if captureChannels == .stereo {
|
||||
mode = .default
|
||||
} else if micMode == .raw {
|
||||
mode = .measurement
|
||||
} else if bluetoothMode == .builtInMicBtA2dp {
|
||||
mode = .videoRecording
|
||||
} else {
|
||||
mode = .voiceChat
|
||||
}
|
||||
|
||||
do {
|
||||
try session.setCategory(.playAndRecord, mode: mode, options: options)
|
||||
logger.info("setCategory ok — mode=\(self.modeLabel(mode)), bt=\(self.bluetoothMode.rawValue), ch=\(self.captureChannels.rawValue), options=\(self.optionsLabel(options))")
|
||||
} catch {
|
||||
logger.error("setCategory failed: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
// 3. Input & mic-capsule configuration.
|
||||
if captureChannels == .stereo {
|
||||
// See configureStereoCapture's doc comment for the full stereo-capture recipe
|
||||
// and why each step is necessary.
|
||||
configureStereoCapture(session: session)
|
||||
} else if let portId = selectedInputPortId, !portId.isEmpty,
|
||||
let port = session.availableInputs?.first(where: { $0.uid == portId }) {
|
||||
// Mono with an explicit input-port selection (advanced settings).
|
||||
do {
|
||||
try session.setPreferredInput(port)
|
||||
logger.info("setPreferredInput ok — \(port.portName)")
|
||||
} catch {
|
||||
logger.error("setPreferredInput failed: \(error.localizedDescription)")
|
||||
}
|
||||
configureMonoCapture(session: session, port: port)
|
||||
} else {
|
||||
// Mono, system-default input. Still clear any leftover .stereo capsule from a
|
||||
// prior stereo session so we actually return to mono.
|
||||
clearStereoPolarPattern(session: session)
|
||||
}
|
||||
|
||||
updateWarnings()
|
||||
}
|
||||
|
||||
/// Anchors the built-in stereo data source without using
|
||||
/// `setPreferredInputNumberOfChannels`, which disrupts A2DP routing.
|
||||
private func configureStereoCapture(session: AVAudioSession) {
|
||||
guard let builtIn = session.availableInputs?.first(where: { $0.portType == .builtInMic })
|
||||
else {
|
||||
logger.warning("stereo requested but no built-in mic available — staying mono")
|
||||
return
|
||||
}
|
||||
guard let stereoSource = builtIn.dataSources?.first(where: {
|
||||
$0.supportedPolarPatterns?.contains(.stereo) == true
|
||||
}) else {
|
||||
logger.warning("stereo requested but built-in mic has no .stereo data source — staying mono")
|
||||
return
|
||||
}
|
||||
do {
|
||||
try builtIn.setPreferredDataSource(stereoSource)
|
||||
try stereoSource.setPreferredPolarPattern(.stereo)
|
||||
try session.setPreferredInput(builtIn)
|
||||
// Commit the data source at the session level. setPreferredDataSource alone only
|
||||
// sets the port-level preference; setInputDataSource makes it the active source.
|
||||
try session.setInputDataSource(stereoSource)
|
||||
logger.info("stereo capsule enabled — source=\(stereoSource.dataSourceName), pattern=.stereo, input anchored")
|
||||
} catch {
|
||||
logger.error("stereo capsule setup failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure mono capture on an explicitly selected port: apply the user's chosen data source
|
||||
/// (orientation) and polar pattern, resetting any prior `.stereo` pattern back to default.
|
||||
private func configureMonoCapture(session: AVAudioSession, port: AVAudioSessionPortDescription) {
|
||||
guard let dataSourceId = selectedDataSourceId, !dataSourceId.isEmpty,
|
||||
let dataSource = port.dataSources?.first(where: {
|
||||
String(describing: $0.dataSourceID) == dataSourceId
|
||||
}) else {
|
||||
// No explicit capsule choice — make sure we're not stuck on a prior .stereo pattern.
|
||||
clearStereoPolarPattern(session: session)
|
||||
return
|
||||
}
|
||||
do {
|
||||
try port.setPreferredDataSource(dataSource)
|
||||
logger.info("setPreferredDataSource ok — \(dataSource.dataSourceName)")
|
||||
} catch {
|
||||
logger.error("setPreferredDataSource failed: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
if let polarPattern = selectedPolarPattern, !polarPattern.isEmpty {
|
||||
let pattern = AVAudioSession.PolarPattern(rawValue: polarPattern)
|
||||
try? dataSource.setPreferredPolarPattern(pattern)
|
||||
logger.info("setPreferredPolarPattern ok — \(polarPattern)")
|
||||
} else {
|
||||
// Clear any prior .stereo selection so mono capture returns to a mono capsule.
|
||||
try? dataSource.setPreferredPolarPattern(nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset any built-in-mic data source that's currently on the `.stereo` polar pattern back to
|
||||
/// the default (mono) pattern. Used when switching from a stereo session back to mono with no
|
||||
/// explicit capsule selection, so the prior stereo capsule doesn't linger.
|
||||
private func clearStereoPolarPattern(session: AVAudioSession) {
|
||||
guard let builtIn = session.availableInputs?.first(where: { $0.portType == .builtInMic })
|
||||
else { return }
|
||||
for ds in builtIn.dataSources ?? [] where ds.selectedPolarPattern == .stereo {
|
||||
try? ds.setPreferredPolarPattern(nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func modeLabel(_ mode: AVAudioSession.Mode) -> String {
|
||||
switch mode {
|
||||
case .voiceChat: return "voiceChat"
|
||||
case .measurement: return "measurement"
|
||||
case .videoRecording: return "videoRecording"
|
||||
case .default: return "default"
|
||||
default: return "other"
|
||||
}
|
||||
}
|
||||
|
||||
private func optionsLabel(_ opts: AVAudioSession.CategoryOptions) -> String {
|
||||
var parts: [String] = []
|
||||
if opts.contains(.defaultToSpeaker) { parts.append("defaultToSpeaker") }
|
||||
if opts.contains(.mixWithOthers) { parts.append("mixWithOthers") }
|
||||
if opts.contains(.allowBluetoothHFP) { parts.append("allowBluetoothHFP") }
|
||||
if opts.contains(.allowBluetoothA2DP) { parts.append("allowBluetoothA2DP") }
|
||||
return parts.joined(separator: ",")
|
||||
}
|
||||
|
||||
/// Apply stored preferences from UserDefaults. Called at app launch (before any
|
||||
/// audio session activation).
|
||||
func loadStoredPreferences() {
|
||||
if let raw = UserDefaults.standard.string(forKey: kBluetoothMode),
|
||||
let mode = BluetoothMode(rawValue: raw) {
|
||||
bluetoothMode = mode
|
||||
}
|
||||
if let raw = UserDefaults.standard.string(forKey: kMicMode),
|
||||
let mode = MicMode(rawValue: raw) {
|
||||
micMode = mode
|
||||
}
|
||||
if let raw = UserDefaults.standard.string(forKey: kCaptureChannels),
|
||||
let ch = CaptureChannels(rawValue: raw) {
|
||||
captureChannels = ch
|
||||
}
|
||||
selectedInputPortId = UserDefaults.standard.string(forKey: kInputPortId)
|
||||
selectedDataSourceId = UserDefaults.standard.string(forKey: kDataSourceId)
|
||||
selectedPolarPattern = UserDefaults.standard.string(forKey: kPolarPattern)
|
||||
forceSpeaker = UserDefaults.standard.bool(forKey: kForceSpeaker)
|
||||
// VPIO toggles default ON when never set (object(forKey:) is nil → use true).
|
||||
voiceProcessingEnabled = (UserDefaults.standard.object(forKey: kVoiceProcessing) as? Bool) ?? true
|
||||
agcEnabled = (UserDefaults.standard.object(forKey: kAgc) as? Bool) ?? true
|
||||
}
|
||||
/// Persist current selections to UserDefaults.
|
||||
func savePreferences() {
|
||||
UserDefaults.standard.set(bluetoothMode.rawValue, forKey: kBluetoothMode)
|
||||
UserDefaults.standard.set(micMode.rawValue, forKey: kMicMode)
|
||||
UserDefaults.standard.set(captureChannels.rawValue, forKey: kCaptureChannels)
|
||||
UserDefaults.standard.set(selectedInputPortId, forKey: kInputPortId)
|
||||
UserDefaults.standard.set(selectedDataSourceId, forKey: kDataSourceId)
|
||||
UserDefaults.standard.set(selectedPolarPattern, forKey: kPolarPattern)
|
||||
UserDefaults.standard.set(forceSpeaker, forKey: kForceSpeaker)
|
||||
UserDefaults.standard.set(voiceProcessingEnabled, forKey: kVoiceProcessing)
|
||||
UserDefaults.standard.set(agcEnabled, forKey: kAgc)
|
||||
}
|
||||
|
||||
// MARK: - Selection setters (called from SettingsView pickers)
|
||||
|
||||
/// Persists the selection and rebuilds the engine against the resulting route.
|
||||
private func applyAndReconfigure() {
|
||||
savePreferences()
|
||||
applyConfiguration()
|
||||
if AudioSessionManager.shared.isActive { applyA2dpSpeakerFallback() }
|
||||
refreshRoutes()
|
||||
IOSAudioEngine.shared.reconfigure()
|
||||
}
|
||||
|
||||
func selectInputPort(_ portId: String) {
|
||||
selectedInputPortId = portId
|
||||
selectedDataSourceId = nil
|
||||
selectedPolarPattern = nil
|
||||
applyAndReconfigure()
|
||||
}
|
||||
|
||||
func selectDataSource(_ dataSourceId: String) {
|
||||
selectedDataSourceId = dataSourceId
|
||||
selectedPolarPattern = nil
|
||||
applyAndReconfigure()
|
||||
}
|
||||
|
||||
func selectPolarPattern(_ pattern: String) {
|
||||
selectedPolarPattern = pattern
|
||||
applyAndReconfigure()
|
||||
}
|
||||
|
||||
func selectBluetoothMode(_ mode: BluetoothMode) {
|
||||
bluetoothMode = mode
|
||||
applyAndReconfigure()
|
||||
}
|
||||
|
||||
func setForceSpeaker(_ on: Bool) {
|
||||
forceSpeaker = on
|
||||
applyAndReconfigure()
|
||||
}
|
||||
|
||||
func selectMicMode(_ mode: MicMode) {
|
||||
micMode = mode
|
||||
applyAndReconfigure()
|
||||
}
|
||||
|
||||
func setVoiceProcessingEnabled(_ on: Bool) {
|
||||
voiceProcessingEnabled = on
|
||||
applyAndReconfigure()
|
||||
}
|
||||
|
||||
func setAgcEnabled(_ on: Bool) {
|
||||
agcEnabled = on
|
||||
// No session reconfigure needed — just rebuild the engine so VPIO picks up the AGC flag.
|
||||
savePreferences()
|
||||
IOSAudioEngine.shared.reconfigure()
|
||||
}
|
||||
|
||||
func selectCaptureChannels(_ channels: CaptureChannels) {
|
||||
captureChannels = channels
|
||||
savePreferences()
|
||||
applyConfiguration()
|
||||
if AudioSessionManager.shared.isActive { applyA2dpSpeakerFallback() }
|
||||
refreshRoutes()
|
||||
// Push the channel count into the core's MIC stream, then rebuild the engine graph so the
|
||||
// mic tap captures the right number of channels. The engine owns the route now, so there's
|
||||
// no stereo-vs-A2DP race to sequence around.
|
||||
IOSAudioEngine.shared.setCaptureChannels(channels.channelCount)
|
||||
}
|
||||
|
||||
// MARK: - Presets
|
||||
|
||||
/// Apply a named preset — set all individual settings to the preset's values, then re-apply
|
||||
/// the configuration and rebind the engine. The internal-mic presets pin the built-in mic.
|
||||
func applyPreset(_ preset: AudioPreset) {
|
||||
guard preset != .advanced else { return } // Advanced is a display state, not "applied"
|
||||
|
||||
bluetoothMode = preset.bluetoothMode
|
||||
micMode = preset.micMode
|
||||
captureChannels = preset.captureChannels
|
||||
|
||||
// Voice Chat is a phone-call experience — default to the loud speaker so output doesn't
|
||||
// land on the quiet earpiece (receiver). Still yields to connected BT/wired output.
|
||||
if preset == .voiceChat { forceSpeaker = true }
|
||||
|
||||
if preset.usesBuiltInMic {
|
||||
// Pin the built-in mic. In stereo, iOS uses multiple capsules automatically; in mono
|
||||
// the default orientation is fine — so don't force a specific data source / pattern.
|
||||
if let builtInMic = (AVAudioSession.sharedInstance().availableInputs ?? []).first(where: {
|
||||
$0.portType == .builtInMic
|
||||
}) {
|
||||
selectedInputPortId = builtInMic.uid
|
||||
}
|
||||
selectedDataSourceId = nil
|
||||
selectedPolarPattern = nil
|
||||
} else {
|
||||
// Voice Chat: let the system pick the input (Bluetooth HFP / wired / built-in).
|
||||
selectedInputPortId = nil
|
||||
selectedDataSourceId = nil
|
||||
selectedPolarPattern = nil
|
||||
}
|
||||
|
||||
UserDefaults.standard.set(preset.rawValue, forKey: kPreset)
|
||||
savePreferences()
|
||||
applyConfiguration()
|
||||
if AudioSessionManager.shared.isActive { applyA2dpSpeakerFallback() }
|
||||
refreshRoutes()
|
||||
// Push the channel count to the core, then rebuild the engine graph (VPIO on/off + tap).
|
||||
IOSAudioEngine.shared.setCaptureChannels(preset.captureChannels.channelCount)
|
||||
IOSAudioEngine.shared.reconfigure()
|
||||
logger.info("applyPreset — \(preset.rawValue)")
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Update warning indicators for the Settings UI.
|
||||
private func updateWarnings() {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
let outputIsSpeaker = session.currentRoute.outputs.contains { $0.portType == .builtInSpeaker }
|
||||
// Raw/Studio mode + speaker = echo risk (no AEC in .measurement mode)
|
||||
showsRawModeSpeakerWarning = (micMode == .raw && outputIsSpeaker)
|
||||
// A2DP output runs without hardware AEC (the .voiceChat AEC path isn't available on an
|
||||
// A2DP route). Applies to both mono and stereo A2DP. Stereo also has no AEC (it can't
|
||||
// use .voiceChat at all), but the message is the same and the warning already shows when
|
||||
// the bluetooth mode is A2DP.
|
||||
showsA2dpNoAecWarning = (bluetoothMode == .builtInMicBtA2dp)
|
||||
}
|
||||
|
||||
/// Uses the speaker only when an A2DP-capable preset has no external output.
|
||||
/// The cached override avoids recursively generated route-change notifications.
|
||||
func applyA2dpSpeakerFallback() {
|
||||
guard bluetoothMode == .builtInMicBtA2dp else { return }
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
// Treat the built-in receiver and speaker as "internal"; anything else (A2DP, headphones,
|
||||
// USB, AirPlay) is an external output we should defer to.
|
||||
let hasExternalOutput = session.currentRoute.outputs.contains {
|
||||
$0.portType != .builtInReceiver && $0.portType != .builtInSpeaker
|
||||
}
|
||||
let desired: AVAudioSession.PortOverride = hasExternalOutput ? .none : .speaker
|
||||
if desired == lastAppliedOutputOverride {
|
||||
logger.debug("A2DP fallback — desired=\(self.overrideLabel(desired)) already applied, skipping")
|
||||
return
|
||||
}
|
||||
do {
|
||||
try session.overrideOutputAudioPort(desired)
|
||||
lastAppliedOutputOverride = desired
|
||||
logger.info("A2DP mode — override applied: \(self.overrideLabel(desired))")
|
||||
} catch {
|
||||
// Drop the cache so the next call re-derives from the live session state.
|
||||
lastAppliedOutputOverride = nil
|
||||
logger.error("A2DP speaker fallback failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func overrideLabel(_ o: AVAudioSession.PortOverride) -> String {
|
||||
switch o {
|
||||
case .none: return "none"
|
||||
case .speaker: return "speaker"
|
||||
@unknown default: return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected input port object, if any.
|
||||
var selectedPort: IOSAudioInputPort? {
|
||||
inputPorts.first(where: { $0.id == selectedInputPortId })
|
||||
}
|
||||
|
||||
/// The data sources of the selected input port, if it's the built-in mic.
|
||||
var selectedPortDataSources: [IOSAudioDataSource]? {
|
||||
selectedPort?.dataSources
|
||||
}
|
||||
|
||||
/// Whether the selected input port is the built-in mic (has data sources / orientation).
|
||||
var selectedPortIsBuiltInMic: Bool {
|
||||
selectedPort?.portType == AVAudioSession.Port.builtInMic.rawValue
|
||||
}
|
||||
}
|
||||
@@ -1,475 +0,0 @@
|
||||
import AVFoundation
|
||||
import Darwin
|
||||
import os
|
||||
import VoiceCatCore
|
||||
|
||||
private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "IOSAudioEngine")
|
||||
|
||||
/// In-process single-producer/single-consumer int16 PCM ring for the playback path.
|
||||
///
|
||||
/// producer = the core's mixer-timer thread (the `vc_set_mixed_output_sink` callback)
|
||||
/// consumer = the `AVAudioSourceNode` render thread
|
||||
///
|
||||
/// Heap-backed (not shared memory like `BroadcastAudioRing`), but the same discipline: aligned
|
||||
/// 64-bit monotonic indices with `OSMemoryBarrier` for acquire/release ordering. Both the C
|
||||
/// callback and the render block are real-time — they only do index math + a memcpy here, never
|
||||
/// lock or allocate.
|
||||
final class PCMRing {
|
||||
private let data: UnsafeMutablePointer<Int16>
|
||||
private let capacity: Int
|
||||
private var writeIdx: UInt64 = 0
|
||||
private var readIdx: UInt64 = 0
|
||||
|
||||
init(capacitySamples: Int) {
|
||||
capacity = capacitySamples
|
||||
data = UnsafeMutablePointer<Int16>.allocate(capacity: capacitySamples)
|
||||
data.initialize(repeating: 0, count: capacitySamples)
|
||||
}
|
||||
deinit { data.deallocate() }
|
||||
|
||||
/// Producer: append `count` interleaved int16 samples. Drops the chunk if it doesn't fit
|
||||
/// (better to skip than tear). Single producer only (the core mixer-timer thread).
|
||||
func write(_ src: UnsafePointer<Int16>, count: Int) {
|
||||
guard count > 0, count <= capacity else { return }
|
||||
let w = writeIdx
|
||||
OSMemoryBarrier()
|
||||
let r = readIdx
|
||||
if capacity - Int(w &- r) < count { return } // full: drop
|
||||
var idx = Int(w % UInt64(capacity))
|
||||
var off = 0
|
||||
var rem = count
|
||||
while rem > 0 {
|
||||
let chunk = min(rem, capacity - idx)
|
||||
(data + idx).update(from: src + off, count: chunk)
|
||||
idx = (idx + chunk) % capacity
|
||||
off += chunk
|
||||
rem -= chunk
|
||||
}
|
||||
OSMemoryBarrier()
|
||||
writeIdx = w &+ UInt64(count)
|
||||
}
|
||||
|
||||
/// Consumer: read up to `count` interleaved int16 samples into `dst`; returns the number
|
||||
/// read (the rest is the caller's to silence-fill). Single consumer only (render thread).
|
||||
func read(into dst: UnsafeMutablePointer<Int16>, count: Int) -> Int {
|
||||
let r = readIdx
|
||||
OSMemoryBarrier()
|
||||
let w = writeIdx
|
||||
let available = Int(w &- r)
|
||||
if available <= 0 { return 0 }
|
||||
let n = min(available, count)
|
||||
var idx = Int(r % UInt64(capacity))
|
||||
var off = 0
|
||||
var rem = n
|
||||
while rem > 0 {
|
||||
let chunk = min(rem, capacity - idx)
|
||||
(dst + off).update(from: data + idx, count: chunk)
|
||||
idx = (idx + chunk) % capacity
|
||||
off += chunk
|
||||
rem -= chunk
|
||||
}
|
||||
OSMemoryBarrier()
|
||||
readIdx = r &+ UInt64(n)
|
||||
return n
|
||||
}
|
||||
|
||||
/// Consumer-side snapshot of how many interleaved int16 samples are currently buffered. Lets a
|
||||
/// paced consumer check for a full frame *before* calling `read`, so it never reads (and thus
|
||||
/// discards) a partial frame. Single consumer only (same thread that calls `read`).
|
||||
var availableSamples: Int {
|
||||
let r = readIdx
|
||||
OSMemoryBarrier()
|
||||
let w = writeIdx
|
||||
return Int(w &- r)
|
||||
}
|
||||
|
||||
/// Discard everything buffered — call before (re)starting so stale pre-roll isn't played.
|
||||
func reset() { OSMemoryBarrier(); readIdx = writeIdx }
|
||||
|
||||
/// Diagnostics: monotonic total samples written / read since the ring was created. The
|
||||
/// indices are already cumulative, so these are free. Only read them when both threads are
|
||||
/// quiesced (e.g. at teardown after the engine + mixer sink are stopped) — they are not
|
||||
/// synchronized for live cross-thread reads. Lets us tell "core never delivered PCM" apart
|
||||
/// from "PCM arrived but produced no sound" (the AVAudioEngine output graph).
|
||||
var debugTotalWritten: UInt64 { writeIdx }
|
||||
var debugTotalRead: UInt64 { readIdx }
|
||||
}
|
||||
|
||||
/// The single iOS audio engine (docs/voice.md §8 "iOS audio engine").
|
||||
///
|
||||
/// **One path, always external.** On iOS the core never opens a miniaudio device: a MIC stream is
|
||||
/// always started with `external_feed=1`, `vc_set_external_playback(1)` is set once at connect, and
|
||||
/// this engine drives *both* directions through one `AVAudioEngine`:
|
||||
/// - **core → speaker:** the core's mixed-output sink fills `ring`; an `AVAudioSourceNode` pulls
|
||||
/// from it and renders through the engine output. This runs the whole time we're connected,
|
||||
/// so remote audio plays even before the user joins voice (no "can't hear anyone").
|
||||
/// - **mic → core:** when the mic is active a tap on the input node converts to 48 kHz int16 and
|
||||
/// writes to a pacing ring; a 20 ms timer releases steady 960-sample frames to
|
||||
/// `client.feedPcm(micStreamId)`. The core sends each captured frame synchronously, so this
|
||||
/// steady cadence is what keeps packets from bursting and fluttering the receiver's playout.
|
||||
///
|
||||
/// Echo cancellation / noise suppression / AGC come from Apple's Voice-Processing I/O unit (VPIO),
|
||||
/// which `inputNode.setVoiceProcessingEnabled(true)` enables. VPIO forces mono, so it is engaged
|
||||
/// only when the active preset wants it (`IOSAudioRouter.currentConfigUsesVoiceProcessing`) — the
|
||||
/// Stereo Mic / A2DP configs run the same engine with VPIO off.
|
||||
///
|
||||
/// Every preset / route / interruption change funnels through `reconfigure()`: a single
|
||||
/// deterministic stop → AVAudioSession reconfigure → rebuild graph → start. There is no second
|
||||
/// (miniaudio) audio path to hand off to, so a switch cannot leave one direction dropped.
|
||||
@MainActor
|
||||
final class IOSAudioEngine {
|
||||
static let shared = IOSAudioEngine()
|
||||
|
||||
/// True while connected (between `startListening` and `stop`) — the playback graph should run.
|
||||
private(set) var isConnected = false
|
||||
/// True while a local mic stream is active — the input tap should be installed.
|
||||
private(set) var micActive = false
|
||||
|
||||
private let engine = AVAudioEngine()
|
||||
private var sourceNode: AVAudioSourceNode?
|
||||
private weak var client: VoiceCatClient?
|
||||
private var micStreamId: UInt32 = 0
|
||||
private var captureChannels: UInt32 = 1
|
||||
|
||||
// AVAudioEngine may deliver several codec frames per callback. Pace complete 20 ms frames
|
||||
// through an SPSC ring; never consume partial frames, and recreate the timer when the channel
|
||||
// count changes.
|
||||
private let micRing = PCMRing(capacitySamples: 48000 * 2) // ~1 s stereo — ample elastic slack
|
||||
private var micTimer: DispatchSourceTimer?
|
||||
private let micQueue = DispatchQueue(label: "cat.voice.mic.feedPump")
|
||||
private let micDrainScratch: UnsafeMutablePointer<Int16>
|
||||
private static let micFrameSamplesPerChannel = 960 // 20 ms @ 48 kHz — core's frame size
|
||||
|
||||
/// Feed-pump state, touched only on `micQueue` (the pump's serial queue). A reference type so
|
||||
/// the timer closure mutates it without capturing `self` (which is @MainActor). `targetFrames`
|
||||
/// is the prebuffer depth: the pump fills this many frames before it starts releasing, so the
|
||||
/// tap's bursty delivery (~2 frames at once) can't drain it to empty between bursts. It persists
|
||||
/// across rebuilds and self-heals upward (capped) on an underrun, so it tunes to whatever IO
|
||||
/// buffer size the active route/VPIO actually uses without a hard-coded guess.
|
||||
private final class PumpState {
|
||||
var primed = false
|
||||
var targetFrames = 3 // ~60 ms initial cushion; grows on underrun up to maxTargetFrames
|
||||
static let maxTargetFrames = 6 // ~120 ms cap — bounds added latency
|
||||
}
|
||||
private let pumpState = PumpState()
|
||||
|
||||
// 48 kHz stereo Float32 (deinterleaved) — the format the source node renders. The core
|
||||
// delivers 48 kHz stereo int16 via the mixed-output sink; mainMixerNode adapts to the route.
|
||||
private let outFormat = AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32, sampleRate: 48000, channels: 2, interleaved: false)!
|
||||
|
||||
// Playback ring (mixed remote audio): ~0.5 s of 48 kHz stereo int16. Filled by the core's
|
||||
// mixer-timer thread, drained by the source-node render thread.
|
||||
private let ring = PCMRing(capacitySamples: 48000 * 2 / 2)
|
||||
// Render-thread scratch for deinterleaving — pre-allocated so the render block never allocates.
|
||||
private let renderScratchFrames = 8192
|
||||
private let renderScratch: UnsafeMutablePointer<Int16>
|
||||
|
||||
private init() {
|
||||
renderScratch = UnsafeMutablePointer<Int16>.allocate(capacity: renderScratchFrames * 2)
|
||||
renderScratch.initialize(repeating: 0, count: renderScratchFrames * 2)
|
||||
micDrainScratch = UnsafeMutablePointer<Int16>.allocate(capacity: 960 * 2)
|
||||
micDrainScratch.initialize(repeating: 0, count: 960 * 2)
|
||||
|
||||
// AVAudioEngine stops itself on a mid-session route/configuration change (it stops
|
||||
// if its I/O graph no longer matches the active route). Our route-change handler in
|
||||
// AudioSessionManager normally rebuilds us before the user notices, but if the engine
|
||||
// stops itself AFTER our recovery (because the route-change notification raced ahead
|
||||
// of the engine's own self-stop), nothing restarts it. Catch that case here.
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(handleEngineConfigurationChange),
|
||||
name: .AVAudioEngineConfigurationChange, object: engine)
|
||||
}
|
||||
|
||||
/// The engine stopped itself because its configuration no longer matches the active AVAudio
|
||||
/// route (this fires after a route change that the route-change handler can't always outrun).
|
||||
/// Dispatch to main and call the unified `recoverAudio()` — it's intent-gated on
|
||||
/// `isConnected`, idempotent, and no-ops if the engine is already running (the common case
|
||||
/// where our route-change handler got there first).
|
||||
@objc private func handleEngineConfigurationChange(_ notification: Notification) {
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
guard self.isConnected, !self.engine.isRunning else { return }
|
||||
logger.info("engine configuration-change — engine stopped itself, recovering")
|
||||
AudioSessionManager.shared.recoverAudio()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
/// Begin playback-only (listening) operation. Called once at connect, after
|
||||
/// `client.setExternalPlayback(true)` and `AudioSessionManager.ensureSessionActive()`. Attaches
|
||||
/// the source node, wires the core's mixed-output sink into the ring, and starts the engine so
|
||||
/// remote audio plays immediately.
|
||||
func startListening(client: VoiceCatClient) {
|
||||
self.client = client
|
||||
guard !isConnected else { return }
|
||||
isConnected = true
|
||||
ring.reset()
|
||||
|
||||
// Wire the core's mixed-output sink into the ring (C function pointer, no captures). Stays
|
||||
// registered for the whole connection; the ring is drained by the source-node render block.
|
||||
let ringPtr = Unmanaged.passUnretained(self.ring).toOpaque()
|
||||
client.setMixedOutputSink({ user, pcm, spc, ch, _ in
|
||||
guard let user, let pcm else { return }
|
||||
let ring = Unmanaged<PCMRing>.fromOpaque(user).takeUnretainedValue()
|
||||
ring.write(pcm, count: spc * Int(ch))
|
||||
}, user: ringPtr)
|
||||
|
||||
rebuild()
|
||||
}
|
||||
|
||||
/// Tear down the engine and unhook the core sink. Called on disconnect.
|
||||
func stop() {
|
||||
guard isConnected else { return }
|
||||
micActive = false
|
||||
stopMicTimer()
|
||||
isConnected = false
|
||||
client?.setMixedOutputSink(nil, user: nil)
|
||||
engine.inputNode.removeTap(onBus: 0)
|
||||
if engine.isRunning { engine.stop() }
|
||||
logger.info("audio engine stopped — ring written=\(self.ring.debugTotalWritten) read=\(self.ring.debugTotalRead) samples")
|
||||
try? engine.inputNode.setVoiceProcessingEnabled(false)
|
||||
if let src = sourceNode {
|
||||
engine.detach(src)
|
||||
sourceNode = nil
|
||||
}
|
||||
ring.reset()
|
||||
micRing.reset()
|
||||
client = nil
|
||||
}
|
||||
|
||||
// MARK: - Mic transitions
|
||||
|
||||
/// Engage the mic: install the input tap and (if the preset wants it) VPIO. Called when the
|
||||
/// user joins voice, after the MIC stream (external_feed) is started.
|
||||
func startMic(streamId: UInt32, channels: UInt32) {
|
||||
micStreamId = streamId
|
||||
captureChannels = channels
|
||||
micActive = true
|
||||
rebuild()
|
||||
}
|
||||
|
||||
/// Disengage the mic: remove the tap and VPIO, keep playback running for remaining remote audio.
|
||||
func stopMic() {
|
||||
guard micActive else { return }
|
||||
micActive = false
|
||||
rebuild()
|
||||
}
|
||||
|
||||
/// Update the capture channel count (mono↔stereo) for the active mic and rebuild.
|
||||
func setCaptureChannels(_ channels: UInt32) {
|
||||
captureChannels = channels
|
||||
if let client, micStreamId != 0 {
|
||||
client.setCaptureChannels(streamId: micStreamId, channels: channels)
|
||||
}
|
||||
if micActive { rebuild() }
|
||||
}
|
||||
|
||||
/// Re-apply the engine graph against the current AVAudioSession config (preset / route change).
|
||||
/// Safe to call when only listening — it just rebuilds the playback graph against the new route.
|
||||
func reconfigure() {
|
||||
guard isConnected else { return }
|
||||
rebuild()
|
||||
}
|
||||
|
||||
// MARK: - Graph (re)build
|
||||
|
||||
/// The single place that (re)builds and starts the engine graph. Deterministic: stop → set
|
||||
/// VPIO → (re)install the mic tap → start. The caller is responsible for having applied the
|
||||
/// AVAudioSession config (category/mode/route) first (`IOSAudioRouter.applyConfiguration`).
|
||||
private func rebuild() {
|
||||
guard isConnected else { return }
|
||||
// Stop the feed pump before touching the tap / ring so the timer (on micQueue) can't race
|
||||
// the ring reset in installMicTap. It is restarted at the end with the current channel count.
|
||||
stopMicTimer()
|
||||
if engine.isRunning { engine.stop() }
|
||||
engine.inputNode.removeTap(onBus: 0)
|
||||
|
||||
let useVPIO = micActive && IOSAudioRouter.shared.currentConfigUsesVoiceProcessing
|
||||
do {
|
||||
try engine.inputNode.setVoiceProcessingEnabled(useVPIO)
|
||||
} catch {
|
||||
logger.error("setVoiceProcessingEnabled(\(useVPIO)) failed: \(error.localizedDescription)")
|
||||
}
|
||||
if useVPIO {
|
||||
// AGC is the one VPIO sub-stage iOS exposes; AEC+NS are bundled into the master switch.
|
||||
engine.inputNode.isVoiceProcessingAGCEnabled = IOSAudioRouter.shared.agcEnabled
|
||||
}
|
||||
|
||||
// The source node must bind to the selected voice-processing output unit.
|
||||
rebuildSourceNode()
|
||||
if micActive { installMicTap() }
|
||||
|
||||
engine.prepare()
|
||||
do {
|
||||
try engine.start()
|
||||
let inFmt = engine.inputNode.outputFormat(forBus: 0)
|
||||
let outFmt = engine.outputNode.outputFormat(forBus: 0)
|
||||
let route = AVAudioSession.sharedInstance().currentRoute.outputs
|
||||
.map { "\($0.portName)[\($0.portType.rawValue)]" }.joined(separator: ", ")
|
||||
logger.info("""
|
||||
engine started — mic=\(self.micActive) vpio=\(useVPIO) captureCh=\(self.captureChannels) \
|
||||
inFormat=\(inFmt) outputNode=\(outFmt) outputRoute=[\(route)]
|
||||
""")
|
||||
} catch {
|
||||
// Route changes can leave AVAudioSession inactive; retry once after reactivation.
|
||||
logger.error("engine start failed: \(error.localizedDescription) — attempting one-shot recovery")
|
||||
do {
|
||||
try AudioSessionManager.shared.ensureSessionActive()
|
||||
} catch {
|
||||
logger.error("recovery — session re-activate failed: \(error.localizedDescription)")
|
||||
}
|
||||
IOSAudioRouter.shared.applyConfiguration()
|
||||
if AudioSessionManager.shared.isActive {
|
||||
IOSAudioRouter.shared.applyA2dpSpeakerFallback()
|
||||
}
|
||||
do {
|
||||
try engine.start()
|
||||
logger.info("engine start succeeded after one-shot recovery")
|
||||
} catch {
|
||||
logger.error("engine start failed after recovery: \(error.localizedDescription)")
|
||||
// Not fatal — a subsequent route-change or AVAudioEngine configuration-change
|
||||
// notification will trigger recoverAudio() and re-attempt the rebuild.
|
||||
}
|
||||
}
|
||||
|
||||
// Start the feed pump last, with the current channel count, so it never carries a stale
|
||||
// (frozen) channel count across a mono↔stereo switch.
|
||||
if micActive { startMicTimer() }
|
||||
}
|
||||
|
||||
/// Detach any previous source node and attach a fresh one pulling mixed PCM from the ring.
|
||||
/// Rebuilt on every graph rebuild so it always connects against the current output unit (the
|
||||
/// VPIO state can change the output between rebuilds). Its format is route-independent —
|
||||
/// `mainMixerNode` adapts 48 kHz stereo to whatever the output route is.
|
||||
private func rebuildSourceNode() {
|
||||
if let old = sourceNode {
|
||||
engine.detach(old)
|
||||
sourceNode = nil
|
||||
}
|
||||
let ring = self.ring
|
||||
let scratch = self.renderScratch
|
||||
let scratchFrames = self.renderScratchFrames
|
||||
let src = AVAudioSourceNode(format: outFormat) { _, _, frameCount, ablPtr in
|
||||
let frames = Int(frameCount)
|
||||
let abl = UnsafeMutableAudioBufferListPointer(ablPtr)
|
||||
let n = min(frames, scratchFrames)
|
||||
let got = ring.read(into: scratch, count: n * 2) / 2 // interleaved stereo → frames
|
||||
let scale: Float = 1.0 / 32768.0
|
||||
for ch in 0..<abl.count {
|
||||
guard let base = abl[ch].mData?.assumingMemoryBound(to: Float.self) else { continue }
|
||||
for i in 0..<frames {
|
||||
base[i] = i < got ? Float(scratch[i * 2 + min(ch, 1)]) * scale : 0
|
||||
}
|
||||
}
|
||||
return noErr
|
||||
}
|
||||
sourceNode = src
|
||||
engine.attach(src)
|
||||
engine.connect(src, to: engine.mainMixerNode, format: outFormat)
|
||||
}
|
||||
|
||||
/// Install the mic tap: convert the input node's native format to 48 kHz int16 (mono or
|
||||
/// stereo per `captureChannels`) and write it to the pacing ring. The 20 ms feed pump
|
||||
/// (`startMicTimer`) releases steady 960-sample frames to `feedPcm` — see the mic-feed comment
|
||||
/// above for why the tap must NOT call feedPcm directly (it bursts packets → receiver flutter).
|
||||
/// Rebuilds the converter each time because the input format depends on the VPIO state and the
|
||||
/// active route.
|
||||
private func installMicTap() {
|
||||
guard client != nil else { return }
|
||||
// Fresh ring on every (re)install — a rebuild must not feed stale pre-roll into the new tap.
|
||||
// Safe here: the feed pump was stopped at the top of rebuild(), so no consumer is running.
|
||||
micRing.reset()
|
||||
let ring = micRing // captured by the closure as a `let` — no self capture (see mic-feed comment)
|
||||
let inFormat = engine.inputNode.outputFormat(forBus: 0)
|
||||
guard inFormat.sampleRate > 0 else {
|
||||
logger.error("input format unavailable (\(inFormat)) — mic will not transmit")
|
||||
return
|
||||
}
|
||||
let targetCh = max(1, min(2, captureChannels))
|
||||
guard let target = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: 48000,
|
||||
channels: AVAudioChannelCount(targetCh), interleaved: true),
|
||||
let converter = AVAudioConverter(from: inFormat, to: target) else {
|
||||
logger.error("mic converter unavailable (in=\(inFormat), ch=\(targetCh)) — mic will not transmit")
|
||||
return
|
||||
}
|
||||
|
||||
let chInt = Int(targetCh)
|
||||
engine.inputNode.installTap(onBus: 0, bufferSize: 960, format: inFormat) { buffer, _ in
|
||||
// Convert this tap buffer to 48 kHz int16. Output capacity scaled for any upsample.
|
||||
let ratio = target.sampleRate / buffer.format.sampleRate
|
||||
let outCap = AVAudioFrameCount(Double(buffer.frameLength) * ratio + 16)
|
||||
guard let outBuf = AVAudioPCMBuffer(pcmFormat: target, frameCapacity: outCap) else { return }
|
||||
var fed = false
|
||||
let status = converter.convert(to: outBuf, error: nil) { _, outStatus in
|
||||
if fed { outStatus.pointee = .noDataNow; return nil }
|
||||
fed = true
|
||||
outStatus.pointee = .haveData
|
||||
return buffer
|
||||
}
|
||||
guard status != .error, outBuf.frameLength > 0,
|
||||
let chData = outBuf.int16ChannelData else { return }
|
||||
// int16 interleaved → channelData[0] is the interleaved buffer. Write the converter's
|
||||
// variable-length output to the pacing ring; the 20 ms feed pump releases steady
|
||||
// 960-sample frames to feedPcm so packets leave the core at a steady 20 ms cadence.
|
||||
ring.write(chData[0], count: Int(outBuf.frameLength) * chInt)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mic feed pump (paces feedPcm at a steady 20 ms cadence)
|
||||
|
||||
/// Start the 20 ms feed pump. After priming a small cushion (`pumpState.targetFrames`), it
|
||||
/// releases ONE 960-sample frame per tick from `micRing` to `feedPcm`, so the core (which sends
|
||||
/// synchronously per captured frame) emits packets at a steady 20 ms — the cadence its receivers
|
||||
/// expect. The cushion is essential: the receiver's playout deliberately keeps near-zero
|
||||
/// buffering (low latency), so it tolerates a steady stream but not bursts; the iOS tap delivers
|
||||
/// ~2 frames at once, and without the cushion the pump runs at ~0 depth and underruns on every
|
||||
/// tap/timer phase beat (crackle). Recreated on every `rebuild()` so `ch` always reflects the
|
||||
/// current `captureChannels` (mono↔stereo switches). Captures only locals + the reference-type
|
||||
/// ring/client/state (no `self`, which is @MainActor).
|
||||
private func startMicTimer() {
|
||||
stopMicTimer()
|
||||
guard let client else { return }
|
||||
let ring = micRing
|
||||
let scratch = micDrainScratch
|
||||
let state = pumpState
|
||||
let sid = micStreamId
|
||||
let ch = max(1, min(2, Int(captureChannels)))
|
||||
let frameSamples = Self.micFrameSamplesPerChannel
|
||||
let full = frameSamples * ch
|
||||
let chU32 = UInt32(ch)
|
||||
// The ring was just reset in installMicTap, so the cushion must be refilled before sending.
|
||||
state.primed = false
|
||||
let feed: () -> Void = {
|
||||
_ = ring.read(into: scratch, count: full) // caller guarantees a full frame is present
|
||||
_ = client.feedPcm(streamId: sid, pcm: scratch,
|
||||
samplesPerChannel: frameSamples, channels: chU32)
|
||||
}
|
||||
let t = DispatchSource.makeTimerSource(queue: micQueue)
|
||||
t.schedule(deadline: .now(), repeating: .milliseconds(20), leeway: .milliseconds(2))
|
||||
t.setEventHandler {
|
||||
let frames = ring.availableSamples / full // whole frames currently buffered
|
||||
if !state.primed {
|
||||
if frames < state.targetFrames { return } // still filling the cushion (into silence)
|
||||
state.primed = true
|
||||
} else if frames == 0 {
|
||||
// Re-prime with a larger cushion; consuming a partial frame would lose samples.
|
||||
if state.targetFrames < PumpState.maxTargetFrames { state.targetFrames += 1 }
|
||||
state.primed = false
|
||||
return
|
||||
}
|
||||
feed() // one steady frame per tick (frames >= 1 here)
|
||||
// Catch-up: if the backlog grew past the cushion (pump descheduled, or producer ran
|
||||
// ahead via a burst), release one extra frame to drain it and keep latency bounded.
|
||||
if frames - 1 > state.targetFrames + 1 { feed() }
|
||||
}
|
||||
t.resume()
|
||||
micTimer = t
|
||||
}
|
||||
|
||||
private func stopMicTimer() {
|
||||
micTimer?.cancel()
|
||||
micTimer = nil
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>VoiceCat</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.0.1</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © 2026 VoiceCat contributors. All rights reserved.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>VoiceCat needs microphone access to transmit your voice in channels.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UIRequiresFullScreen</key>
|
||||
<false/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,29 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct SavedServer: Codable, Identifiable, Equatable {
|
||||
let id: UUID
|
||||
var host: String
|
||||
var port: UInt16
|
||||
var authMode: AuthMode
|
||||
var savedUsername: String
|
||||
/// Free-form display name used when connecting as a guest. Distinct from the account
|
||||
/// `savedUsername`. Empty falls back to a default. Optional for backward-compatible decoding.
|
||||
var nickname: String?
|
||||
var keychainTag: String
|
||||
|
||||
enum AuthMode: String, Codable { case guest, password }
|
||||
|
||||
init(id: UUID = UUID(), host: String, port: UInt16,
|
||||
authMode: AuthMode = .guest, savedUsername: String = "",
|
||||
nickname: String? = nil, keychainTag: String = "") {
|
||||
self.id = id
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.authMode = authMode
|
||||
self.savedUsername = savedUsername
|
||||
self.nickname = nickname
|
||||
self.keychainTag = keychainTag.isEmpty ? id.uuidString : keychainTag
|
||||
}
|
||||
|
||||
var displayString: String { "\(host):\(port)" }
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
final class ServerListStore {
|
||||
static let shared = ServerListStore()
|
||||
|
||||
private let groupId = "group.me.iamtalon.voicecat"
|
||||
private let keychainService = "cat.voice.VoiceCatiOS"
|
||||
|
||||
// MARK: - App Group Container
|
||||
|
||||
var appGroupContainer: URL {
|
||||
guard let url = FileManager.default.containerURL(
|
||||
forSecurityApplicationGroupIdentifier: groupId)
|
||||
else {
|
||||
// Fall back to app-only support dir if App Groups are unavailable (e.g. simulator
|
||||
// without entitlements). TOFU pins won't be shared with the broadcast extension,
|
||||
// but connect + auth still work.
|
||||
return FileManager.default.urls(for: .applicationSupportDirectory,
|
||||
in: .userDomainMask).first!
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
private var voicecatDir: URL {
|
||||
let dir = appGroupContainer.appendingPathComponent("voicecat", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: dir,
|
||||
withIntermediateDirectories: true)
|
||||
return dir
|
||||
}
|
||||
|
||||
var tofuStorePath: String { voicecatDir.appendingPathComponent("tofu_pins.txt").path }
|
||||
|
||||
private var serversURL: URL { voicecatDir.appendingPathComponent("servers.json") }
|
||||
|
||||
// MARK: - Server list persistence
|
||||
|
||||
func load() -> [SavedServer] {
|
||||
guard let data = try? Data(contentsOf: serversURL),
|
||||
let list = try? JSONDecoder().decode([SavedServer].self, from: data)
|
||||
else { return [] }
|
||||
return list
|
||||
}
|
||||
|
||||
func save(_ servers: [SavedServer]) {
|
||||
guard let data = try? JSONEncoder().encode(servers) else { return }
|
||||
try? data.write(to: serversURL, options: .atomic)
|
||||
}
|
||||
|
||||
// MARK: - Keychain
|
||||
|
||||
func savePassword(_ password: String, tag: String) {
|
||||
let data = Data(password.utf8)
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: keychainService,
|
||||
kSecAttrAccount: tag,
|
||||
kSecAttrAccessGroup: groupId,
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
var add = query
|
||||
add[kSecValueData] = data
|
||||
SecItemAdd(add as CFDictionary, nil)
|
||||
}
|
||||
|
||||
func loadPassword(tag: String) -> String? {
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: keychainService,
|
||||
kSecAttrAccount: tag,
|
||||
kSecAttrAccessGroup: groupId,
|
||||
kSecReturnData: true,
|
||||
kSecMatchLimit: kSecMatchLimitOne,
|
||||
]
|
||||
var result: AnyObject?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
||||
let data = result as? Data
|
||||
else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
func deletePassword(tag: String) {
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: keychainService,
|
||||
kSecAttrAccount: tag,
|
||||
kSecAttrAccessGroup: groupId,
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,553 +0,0 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import VoiceCatCore
|
||||
|
||||
// MARK: - Helper types
|
||||
|
||||
struct ChatMessage: Identifiable {
|
||||
let id = UUID()
|
||||
let timestamp: Date
|
||||
let senderName: String
|
||||
let text: String
|
||||
let scope: VoiceCatTextScope
|
||||
}
|
||||
|
||||
struct ActivityEntry: Identifiable {
|
||||
let id = UUID()
|
||||
let timestamp: Date
|
||||
let text: String
|
||||
}
|
||||
|
||||
struct VoiceState {
|
||||
var micActive = false
|
||||
var voiceSubscribed = false
|
||||
var selfMuted = false
|
||||
var selfDeafened = false
|
||||
var serverMuted = false
|
||||
var serverDeafened = false
|
||||
var inputMode: VoiceCatInputMode = .voiceActivation
|
||||
var vadThreshold: Float = 0.025
|
||||
var inputGain: Float = 1.0
|
||||
var inputNoiseReduction: Bool = false
|
||||
var level: Float = 0.0
|
||||
var currentDeviceId: String?
|
||||
var localStreamId: UInt32 = 0
|
||||
var screenSharing = false
|
||||
var screenStreamId: UInt32 = 0
|
||||
}
|
||||
|
||||
// MARK: - SessionState
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class SessionState {
|
||||
let client: VoiceCatClient
|
||||
let selfUserId: UInt32
|
||||
|
||||
var channels: [Channel] = []
|
||||
var users: [User] = []
|
||||
var currentChannelId: UInt32 = 0
|
||||
var messages: [ChatMessage] = []
|
||||
var activityLog: [ActivityEntry] = []
|
||||
var voiceState = VoiceState()
|
||||
var permissions: Permissions
|
||||
var accounts: [Account] = []
|
||||
var devices: [Device] = []
|
||||
|
||||
/// Back-reference to the app state. Once `SessionState.init` overwrites `client.onEvent`,
|
||||
/// `AppState.handleConnectEvent` no longer receives per-session events — so the
|
||||
/// `.disconnected` event for a LIVE session arrives here in `handleEvent`, not in AppState.
|
||||
/// This weak ref lets us hand the disconnect back to AppState (which owns the reconnect
|
||||
/// state machine) so the auto-reconnect fires. Set by AppState on auth success.
|
||||
weak var appState: AppState?
|
||||
|
||||
// MARK: - Reconnect restore state
|
||||
//
|
||||
// When the iOS client auto-reconnects after a network drop, AppState captures the prior
|
||||
// session's channel + voice/mic state and asks the new SessionState (created on auth success)
|
||||
// to restore it. We rejoin the channel explicitly (the server auto-placed us in Lobby on
|
||||
// auth), and on the resulting `.joinResult` we re-arm voice subscription + mute/deafen. The
|
||||
// drive is here, not in AppState, because once SessionState is created it owns
|
||||
// `client.onEvent` and AppState no longer sees per-session events.
|
||||
private struct RestoreRequest {
|
||||
let channelId: UInt32
|
||||
let voiceSubscribed: Bool
|
||||
let micMuted: Bool
|
||||
let deafened: Bool
|
||||
}
|
||||
private var pendingRestore: RestoreRequest?
|
||||
private var didIssueRestoreJoin = false
|
||||
|
||||
/// Host side of iOS screen-audio sharing — drains the broadcast extension's App Group ring
|
||||
/// and feeds the SCREEN_AUDIO stream this session owns. See BroadcastAudioPump.
|
||||
private let broadcastPump = BroadcastAudioPump()
|
||||
|
||||
init(client: VoiceCatClient, selfUserId: UInt32, permissions: Permissions) {
|
||||
self.client = client
|
||||
self.selfUserId = selfUserId
|
||||
self.permissions = permissions
|
||||
loadAndApplyVoiceSettings()
|
||||
refreshChannels()
|
||||
refreshUsers()
|
||||
syncSelfChannel()
|
||||
refreshDevices()
|
||||
client.onEvent = { [weak self] ev in
|
||||
Task { @MainActor [weak self] in self?.handleEvent(ev) }
|
||||
}
|
||||
client.onLevel = { [weak self] _, rms in
|
||||
Task { @MainActor [weak self] in self?.voiceState.level = rms }
|
||||
}
|
||||
broadcastPump.onBroadcastStarted = { [weak self] in self?.startScreenShare() }
|
||||
broadcastPump.onBroadcastFinished = { [weak self] in self?.stopScreenShare() }
|
||||
broadcastPump.start()
|
||||
}
|
||||
|
||||
deinit {
|
||||
broadcastPump.stop()
|
||||
}
|
||||
|
||||
// MARK: - Event dispatch
|
||||
|
||||
func handleEvent(_ ev: VoiceCatEvent) {
|
||||
switch ev.type {
|
||||
case .channelList:
|
||||
refreshChannels()
|
||||
syncSelfChannel()
|
||||
case .userJoined:
|
||||
// ev.text = nickname, ev.channelId = the channel they joined (per voicecat.h).
|
||||
if ev.userId != selfUserId && ev.channelId == currentChannelId {
|
||||
EventFeedback.shared.play(.channelJoin)
|
||||
EventFeedback.shared.speak("\(ev.text ?? "Someone") joined")
|
||||
}
|
||||
refreshUsers()
|
||||
syncSelfChannel()
|
||||
case .userLeft:
|
||||
// Capture the leaving user's prior nickname/channel before refreshUsers() drops them.
|
||||
if ev.userId != selfUserId,
|
||||
let gone = users.first(where: { $0.id == ev.userId }),
|
||||
gone.channelId == currentChannelId {
|
||||
EventFeedback.shared.play(.channelLeave)
|
||||
EventFeedback.shared.speak("\(gone.nickname) left")
|
||||
}
|
||||
refreshUsers()
|
||||
syncSelfChannel()
|
||||
case .userUpdated:
|
||||
refreshUsers()
|
||||
syncSelfChannel()
|
||||
if let me = users.first(where: { $0.id == selfUserId }) {
|
||||
applyServerMuteState(muted: me.serverMuted, deafened: me.serverDeafened)
|
||||
}
|
||||
case .textMessage:
|
||||
let sender = users.first(where: { $0.id == ev.userId })?.nickname ?? "Unknown"
|
||||
let body = ev.text ?? ""
|
||||
let isSelf = ev.userId == selfUserId
|
||||
let isPrivate = ev.textScope == .private
|
||||
messages.append(ChatMessage(
|
||||
timestamp: Date(timeIntervalSince1970: Double(ev.timestampUnixMs) / 1000),
|
||||
senderName: sender,
|
||||
text: body,
|
||||
scope: ev.textScope))
|
||||
EventFeedback.shared.play(isPrivate
|
||||
? (isSelf ? .pmSent : .pmRecv)
|
||||
: (isSelf ? .channelSent : .channelRecv))
|
||||
if !isSelf {
|
||||
EventFeedback.shared.speak(isPrivate
|
||||
? "Private message from \(sender): \(body)"
|
||||
: "\(sender): \(body)")
|
||||
}
|
||||
case .talkState:
|
||||
let talking = ev.u32a != 0
|
||||
if ev.userId == selfUserId {
|
||||
EventFeedback.shared.play(talking ? .vaStart : .vaStop)
|
||||
}
|
||||
let who = users.first(where: { $0.id == ev.userId })?.nickname ?? "user \(ev.userId)"
|
||||
addActivity(talking ? "\(who) started talking" : "\(who) stopped talking")
|
||||
case .streamStarted:
|
||||
// Our own SCREEN_AUDIO stream is live — begin draining the broadcast ring into it,
|
||||
// in the stream's effective channel mode (downmix to mono if the channel is mono).
|
||||
if ev.userId == selfUserId && ev.streamId == voiceState.screenStreamId {
|
||||
let sid = voiceState.screenStreamId
|
||||
let (r, cfg) = client.getStreamAudioConfig(userId: selfUserId, streamId: sid)
|
||||
let channels: UInt32 = (r == .ok && cfg?.stereo == true) ? 2 : 1
|
||||
let c = client
|
||||
broadcastPump.beginFeeding(streamChannels: channels) { pcm, samples, ch in
|
||||
c.feedPcm(streamId: sid, pcm: pcm, samplesPerChannel: samples, channels: ch)
|
||||
}
|
||||
addActivity("Sharing screen audio (\(channels == 2 ? "stereo" : "mono"))")
|
||||
break
|
||||
}
|
||||
if ev.userId != selfUserId {
|
||||
do {
|
||||
try AudioSessionManager.shared.ensureSessionActive()
|
||||
} catch {
|
||||
addActivity("Audio session activate failed: \(error)")
|
||||
}
|
||||
}
|
||||
AudioSessionManager.shared.logSessionState("stream started (user \(ev.userId))")
|
||||
addActivity("Stream started (user \(ev.userId))")
|
||||
case .streamStopped:
|
||||
if ev.userId == selfUserId {
|
||||
if ev.streamId == voiceState.localStreamId {
|
||||
voiceState.localStreamId = 0
|
||||
voiceState.micActive = false
|
||||
voiceState.level = 0
|
||||
}
|
||||
} else {
|
||||
addActivity("Stream stopped (user \(ev.userId))")
|
||||
}
|
||||
case .voiceState:
|
||||
let subscribed = ev.u32a != 0
|
||||
voiceState.voiceSubscribed = subscribed
|
||||
if subscribed {
|
||||
doStartMicStream()
|
||||
} else {
|
||||
voiceState.micActive = false
|
||||
voiceState.level = 0
|
||||
EventFeedback.shared.play(.voiceOff)
|
||||
}
|
||||
case .joinResult:
|
||||
if ev.result == .ok {
|
||||
currentChannelId = ev.channelId
|
||||
addActivity("Joined channel")
|
||||
refreshUsers()
|
||||
// Reconnect restore: this was our restore-join. Now that the server has
|
||||
// processed the channel move, re-arm voice subscription (if the user was
|
||||
// transmitting before the drop) and re-apply the local mute/deafen state.
|
||||
// The server returns ok even when joining the channel we're already in, so
|
||||
// this fires reliably for the Lobby-too case.
|
||||
if didIssueRestoreJoin, let r = pendingRestore, r.channelId == ev.channelId {
|
||||
didIssueRestoreJoin = false
|
||||
completeRestore()
|
||||
}
|
||||
} else {
|
||||
addActivity("Join failed: \(ev.result.description)")
|
||||
// Restore-join failed (channel was deleted, became password-protected or
|
||||
// full while we were away). Give up on the voice/mute restore cleanly so we
|
||||
// don't leave dangling state or attempt voice without being in a channel.
|
||||
if didIssueRestoreJoin {
|
||||
didIssueRestoreJoin = false
|
||||
pendingRestore = nil
|
||||
}
|
||||
}
|
||||
case .error:
|
||||
addActivity("Error: \(ev.text ?? ev.result.description)")
|
||||
case .genericResult:
|
||||
if ev.result != .ok {
|
||||
addActivity("Operation failed: \(ev.result.description)")
|
||||
}
|
||||
case .accountList:
|
||||
accounts = client.listAccounts()
|
||||
case .disconnected:
|
||||
// Audible cue, then hand the disconnect back to AppState so its reconnect state
|
||||
// machine fires. This is the ONLY way AppState learns a live session dropped —
|
||||
// after auth success, `SessionState.init` overwrites `client.onEvent`, so
|
||||
// `AppState.handleConnectEvent` never sees this event. (Without this callback, a
|
||||
// network drop on a live session would just play the cue and leave the session as a
|
||||
// zombie — the user would have to tap Disconnect manually.)
|
||||
EventFeedback.shared.play(ev.result == .ok ? .logout : .connectionLost)
|
||||
EventFeedback.shared.speak(ev.result == .ok ? "Disconnected" : "Connection lost")
|
||||
appState?.onLiveSessionDisconnected()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func addActivity(_ text: String) {
|
||||
activityLog.append(ActivityEntry(timestamp: Date(), text: text))
|
||||
if activityLog.count > 500 { activityLog.removeFirst() }
|
||||
}
|
||||
|
||||
// MARK: - Self-channel / server-mute sync
|
||||
|
||||
/// Sync currentChannelId from the self user's channelId in the user list. Mirrors macOS
|
||||
/// MainWindowController's bootstrap/event-handling sync. The server auto-places every
|
||||
/// authed user into the Lobby (channel 1) on connect, but without this sync
|
||||
/// currentChannelId stays 0 and the mic button (gated on currentChannelId == 0) stays
|
||||
/// permanently dimmed.
|
||||
private func syncSelfChannel() {
|
||||
if let me = users.first(where: { $0.id == selfUserId }) {
|
||||
currentChannelId = me.channelId
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply server-side mute/deafen state — mirrors macOS MainWindowController's handling
|
||||
/// of UserEvent.UPDATED for the self user.
|
||||
private func applyServerMuteState(muted: Bool, deafened: Bool) {
|
||||
if muted && !voiceState.serverMuted { addActivity("You have been server-muted") }
|
||||
if deafened && !voiceState.serverDeafened { addActivity("You have been server-deafened") }
|
||||
if !muted && voiceState.serverMuted { addActivity("Server mute cleared") }
|
||||
if !deafened && voiceState.serverDeafened { addActivity("Server deafen cleared") }
|
||||
voiceState.serverMuted = muted
|
||||
voiceState.serverDeafened = deafened
|
||||
}
|
||||
|
||||
// MARK: - Data refresh
|
||||
|
||||
func refreshChannels() { channels = client.listChannels() }
|
||||
func refreshUsers() { users = client.listUsers() }
|
||||
func refreshDevices() { devices = client.listDevices(.input) }
|
||||
|
||||
// MARK: - Voice controls
|
||||
|
||||
func joinChannel(_ channelId: UInt32, password: String = "") {
|
||||
client.joinChannel(channelId, password: password.isEmpty ? nil : password)
|
||||
}
|
||||
|
||||
func leaveChannel() {
|
||||
client.leaveChannel()
|
||||
currentChannelId = 0
|
||||
}
|
||||
|
||||
func joinVoice() {
|
||||
AVAudioApplication.requestRecordPermission { [weak self] granted in
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
if granted {
|
||||
let result = self.client.joinVoice()
|
||||
if result != .ok {
|
||||
self.addActivity("Failed to join voice: \(result.description)")
|
||||
}
|
||||
} else {
|
||||
self.addActivity("Microphone permission denied — grant in Settings > Privacy > Microphone")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func doStartMicStream() {
|
||||
do {
|
||||
try AudioSessionManager.shared.ensureSessionActive()
|
||||
} catch {
|
||||
addActivity("AVAudioSession activate failed: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
let desc = StreamDescriptor(kind: .mic, deviceId: voiceState.currentDeviceId, label: "Mic",
|
||||
externalFeed: true)
|
||||
let (result, streamId) = client.startStream(desc)
|
||||
guard result == .ok else {
|
||||
addActivity("Failed to start mic: \(result.description)")
|
||||
return
|
||||
}
|
||||
voiceState.micActive = true
|
||||
voiceState.localStreamId = streamId
|
||||
EventFeedback.shared.play(.voiceOn)
|
||||
|
||||
let channels = IOSAudioRouter.shared.captureChannels.channelCount
|
||||
if channels != 1 {
|
||||
client.setCaptureChannels(streamId: streamId, channels: channels)
|
||||
}
|
||||
IOSAudioEngine.shared.startMic(streamId: streamId, channels: channels)
|
||||
}
|
||||
|
||||
func leaveVoice() {
|
||||
if voiceState.screenStreamId != 0 { stopScreenShare() }
|
||||
client.setPushToTalk(false)
|
||||
IOSAudioEngine.shared.stopMic()
|
||||
client.leaveVoice()
|
||||
}
|
||||
|
||||
// MARK: - Reconnect restore
|
||||
|
||||
/// Called by `AppState` after a reconnect's auth success to rejoin the prior channel and
|
||||
/// re-enable the prior voice/mic state. Drives the restore through the `.joinResult` event
|
||||
/// so we re-arm voice only AFTER the server processed the join — joining voice before the
|
||||
/// channel move would be rejected server-side. `micMuted`/`deafened` are the user's LOCAL
|
||||
/// mute/deafen state at the moment of the drop; the server resets those on a fresh auth, so
|
||||
/// we re-push them via `setMute` after the channel is restored.
|
||||
func requestRestore(channelId: UInt32, voiceSubscribed: Bool,
|
||||
micMuted: Bool, deafened: Bool) {
|
||||
pendingRestore = RestoreRequest(channelId: channelId,
|
||||
voiceSubscribed: voiceSubscribed,
|
||||
micMuted: micMuted,
|
||||
deafened: deafened)
|
||||
didIssueRestoreJoin = false
|
||||
if channelId != 0 {
|
||||
// The server auto-placed us in the Lobby on auth; join our prior channel explicitly.
|
||||
// `vc_join_channel` is idempotent server-side (joining the channel you're already in
|
||||
// returns ok), so this is safe even if the prior channel was the Lobby.
|
||||
client.joinChannel(channelId)
|
||||
didIssueRestoreJoin = true
|
||||
} else {
|
||||
// No prior channel — go straight to the voice/mute restore. (voiceSubscribed with
|
||||
// channelId == 0 is contradictory; `completeRestore` further guards on
|
||||
// currentChannelId != 0 before subscribing to voice.)
|
||||
completeRestore()
|
||||
}
|
||||
}
|
||||
|
||||
/// Finish the restore after the channel is in place (or there was no channel to restore):
|
||||
/// re-subscribe to voice if the user was transmitting, and re-apply the local mute/deafen
|
||||
/// state. Safe to call once per `pendingRestore`; clears it.
|
||||
private func completeRestore() {
|
||||
guard let r = pendingRestore else { return }
|
||||
if r.voiceSubscribed && currentChannelId != 0 {
|
||||
joinVoice()
|
||||
}
|
||||
setMute(r.micMuted, deafened: r.deafened)
|
||||
addActivity("Restored to channel \(currentChannelId)"
|
||||
+ (r.voiceSubscribed ? " with voice" : ""))
|
||||
pendingRestore = nil
|
||||
}
|
||||
|
||||
// MARK: - Screen audio share
|
||||
|
||||
/// Called when the broadcast extension becomes active. Announces the SCREEN_AUDIO stream;
|
||||
/// feeding begins on the resulting `.streamStarted` event (see handleEvent). The actual
|
||||
/// system-audio capture happens in the ReplayKit upload extension (a separate process).
|
||||
private func startScreenShare() {
|
||||
guard voiceState.screenStreamId == 0 else { return }
|
||||
guard currentChannelId != 0 else {
|
||||
addActivity("Screen audio ignored — join a channel first")
|
||||
return
|
||||
}
|
||||
let (result, streamId) = client.startStream(
|
||||
StreamDescriptor(kind: .screenAudio, deviceId: nil, label: "Screen audio"))
|
||||
if result == .ok {
|
||||
voiceState.screenStreamId = streamId
|
||||
voiceState.screenSharing = true
|
||||
addActivity("Screen audio share starting…")
|
||||
} else {
|
||||
addActivity("Failed to start screen audio: \(result.description)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Called when the broadcast ends (or on disconnect). Stops feeding and the stream.
|
||||
private func stopScreenShare() {
|
||||
broadcastPump.endFeeding()
|
||||
if voiceState.screenStreamId != 0 {
|
||||
client.stopStream(voiceState.screenStreamId)
|
||||
voiceState.screenStreamId = 0
|
||||
}
|
||||
if voiceState.screenSharing {
|
||||
voiceState.screenSharing = false
|
||||
addActivity("Stopped sharing screen audio")
|
||||
}
|
||||
}
|
||||
|
||||
func setMute(_ muted: Bool, deafened: Bool) {
|
||||
client.setSelfMute(micMuted: muted, deafened: deafened)
|
||||
voiceState.selfMuted = muted
|
||||
voiceState.selfDeafened = deafened
|
||||
}
|
||||
|
||||
func setInputMode(_ mode: VoiceCatInputMode) {
|
||||
client.setInputMode(mode)
|
||||
voiceState.inputMode = mode
|
||||
UserDefaults.standard.set(Int(mode.rawValue), forKey: DefaultsKey.inputMode)
|
||||
}
|
||||
|
||||
func setVadThreshold(_ threshold: Float) {
|
||||
client.setVadThreshold(threshold)
|
||||
voiceState.vadThreshold = threshold
|
||||
UserDefaults.standard.set(threshold, forKey: DefaultsKey.vadThreshold)
|
||||
}
|
||||
|
||||
func setInputGain(_ gain: Float) {
|
||||
client.setInputGain(gain)
|
||||
voiceState.inputGain = gain
|
||||
UserDefaults.standard.set(gain, forKey: DefaultsKey.inputGain)
|
||||
}
|
||||
|
||||
func setInputNoiseReduction(_ on: Bool) {
|
||||
client.setInputNoiseReduction(on)
|
||||
voiceState.inputNoiseReduction = on
|
||||
UserDefaults.standard.set(on, forKey: DefaultsKey.inputNoiseReduction)
|
||||
}
|
||||
|
||||
// MARK: - Persisted input settings
|
||||
|
||||
private enum DefaultsKey {
|
||||
static let inputMode = "voice.inputMode"
|
||||
static let vadThreshold = "voice.vadThreshold"
|
||||
static let inputGain = "voice.inputGain"
|
||||
static let inputNoiseReduction = "voice.inputNoiseReduction"
|
||||
}
|
||||
|
||||
/// Restore the saved input mode / VAD threshold / mic gain and push them into the core so a
|
||||
/// relaunch keeps the user's transmission settings instead of resetting to VAD defaults.
|
||||
private func loadAndApplyVoiceSettings() {
|
||||
let d = UserDefaults.standard
|
||||
if d.object(forKey: DefaultsKey.inputMode) != nil {
|
||||
let raw = UInt32(d.integer(forKey: DefaultsKey.inputMode))
|
||||
voiceState.inputMode = VoiceCatInputMode(rawValue: raw) ?? .voiceActivation
|
||||
}
|
||||
if d.object(forKey: DefaultsKey.vadThreshold) != nil {
|
||||
voiceState.vadThreshold = d.float(forKey: DefaultsKey.vadThreshold)
|
||||
}
|
||||
if d.object(forKey: DefaultsKey.inputGain) != nil {
|
||||
voiceState.inputGain = d.float(forKey: DefaultsKey.inputGain)
|
||||
}
|
||||
if d.object(forKey: DefaultsKey.inputNoiseReduction) != nil {
|
||||
voiceState.inputNoiseReduction = d.bool(forKey: DefaultsKey.inputNoiseReduction)
|
||||
}
|
||||
client.setInputMode(voiceState.inputMode)
|
||||
client.setVadThreshold(voiceState.vadThreshold)
|
||||
client.setInputGain(voiceState.inputGain)
|
||||
client.setInputNoiseReduction(voiceState.inputNoiseReduction)
|
||||
}
|
||||
|
||||
private var pttEngaged = false
|
||||
func setPushToTalk(_ active: Bool) {
|
||||
client.setPushToTalk(active)
|
||||
// Play the PTT cue only on the press transition (the gesture fires repeatedly while held).
|
||||
if active && !pttEngaged { EventFeedback.shared.play(.ptt) }
|
||||
pttEngaged = active
|
||||
}
|
||||
|
||||
// MARK: - Text
|
||||
|
||||
func sendText(_ text: String, scope: VoiceCatTextScope, targetId: UInt32 = 0) {
|
||||
client.sendText(scope: scope, targetId: targetId, text: text)
|
||||
}
|
||||
|
||||
// MARK: - Admin
|
||||
|
||||
func kickUser(_ userId: UInt32, reason: String) {
|
||||
client.kickUser(userId, reason: reason.isEmpty ? nil : reason)
|
||||
}
|
||||
|
||||
func banUser(_ userId: UInt32, reason: String, expiresUnixMs: UInt64) {
|
||||
client.banUser(userId, reason: reason.isEmpty ? nil : reason, expiresUnixMs: expiresUnixMs)
|
||||
}
|
||||
|
||||
func moveUser(_ userId: UInt32, toChannel channelId: UInt32) {
|
||||
client.moveUser(userId, toChannel: channelId)
|
||||
}
|
||||
|
||||
func setPermissions(_ userId: UInt32, perms: Permissions) {
|
||||
client.setPermission(userId, perms: perms)
|
||||
}
|
||||
|
||||
func setServerMute(_ userId: UInt32, muted: Bool, deafened: Bool) {
|
||||
client.setServerMute(userId, muted: muted, deafened: deafened)
|
||||
}
|
||||
|
||||
func createChannel(_ info: ChannelEdit) {
|
||||
client.createChannel(info)
|
||||
}
|
||||
|
||||
func editChannel(_ info: ChannelEdit) {
|
||||
client.editChannel(info)
|
||||
}
|
||||
|
||||
func deleteChannel(_ channelId: UInt32) {
|
||||
client.deleteChannel(channelId)
|
||||
}
|
||||
|
||||
func fetchAccountList() {
|
||||
client.requestAccountList()
|
||||
}
|
||||
|
||||
func createAccount(username: String, password: String) {
|
||||
client.createAccount(username, password: password)
|
||||
}
|
||||
|
||||
func deleteAccount(username: String) {
|
||||
client.deleteAccount(username)
|
||||
}
|
||||
|
||||
func resetPassword(username: String, newPassword: String) {
|
||||
client.resetPassword(username, newPassword: newPassword)
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
struct AccountsView: View {
|
||||
@Bindable var session: SessionState
|
||||
@State private var showCreateAccount = false
|
||||
@State private var newUsername = ""
|
||||
@State private var newPassword = ""
|
||||
@State private var accountToDelete: Account?
|
||||
@State private var showResetPassword = false
|
||||
@State private var resetForAccount: Account?
|
||||
@State private var resetPassword = ""
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(session.accounts, id: \.username) { account in
|
||||
AccountRowView(account: account)
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) {
|
||||
accountToDelete = account
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
Button {
|
||||
resetForAccount = account
|
||||
showResetPassword = true
|
||||
} label: {
|
||||
Label("Reset PW", systemImage: "key")
|
||||
}
|
||||
.tint(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Accounts")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
showCreateAccount = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.accessibilityLabel("Create account")
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
session.fetchAccountList()
|
||||
}
|
||||
.onAppear {
|
||||
session.fetchAccountList()
|
||||
}
|
||||
.confirmationDialog("Delete account?", isPresented: Binding(
|
||||
get: { accountToDelete != nil },
|
||||
set: { if !$0 { accountToDelete = nil } }
|
||||
)) {
|
||||
if let a = accountToDelete {
|
||||
Button("Delete \(a.username)", role: .destructive) {
|
||||
session.deleteAccount(username: a.username)
|
||||
accountToDelete = nil
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) { accountToDelete = nil }
|
||||
}
|
||||
.sheet(isPresented: $showCreateAccount) {
|
||||
CreateAccountSheet(session: session)
|
||||
}
|
||||
.alert("Reset Password", isPresented: $showResetPassword) {
|
||||
SecureField("New password", text: $resetPassword)
|
||||
.accessibilityLabel("New password for account")
|
||||
Button("Reset") {
|
||||
if let a = resetForAccount, !resetPassword.isEmpty {
|
||||
session.resetPassword(username: a.username, newPassword: resetPassword)
|
||||
}
|
||||
resetPassword = ""
|
||||
resetForAccount = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
resetPassword = ""
|
||||
resetForAccount = nil
|
||||
}
|
||||
} message: {
|
||||
Text("Enter a new password for \(resetForAccount?.username ?? "").")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AccountRowView: View {
|
||||
let account: Account
|
||||
|
||||
private var joinedDate: String {
|
||||
let date = Date(timeIntervalSince1970: Double(account.createdAtUnixMs) / 1000)
|
||||
return date.formatted(.dateTime.year().month().day())
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack {
|
||||
Text(account.username)
|
||||
.fontWeight(.medium)
|
||||
if account.isAdmin {
|
||||
Text("admin")
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||
.background(.orange.opacity(0.2), in: Capsule())
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
Text("Created \(joinedDate)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(account.username)\(account.isAdmin ? ", administrator" : ""), created \(joinedDate)")
|
||||
}
|
||||
}
|
||||
|
||||
private struct CreateAccountSheet: View {
|
||||
@Bindable var session: SessionState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField("Username", text: $username)
|
||||
.textContentType(.username)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.accessibilityLabel("Username")
|
||||
SecureField("Password", text: $password)
|
||||
.textContentType(.newPassword)
|
||||
.accessibilityLabel("Password")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Create Account")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Create") {
|
||||
session.createAccount(username: username, password: password)
|
||||
dismiss()
|
||||
}
|
||||
.disabled(username.isEmpty || password.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AddServerView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let editing: SavedServer?
|
||||
|
||||
@State private var host = ""
|
||||
@State private var port = "7878"
|
||||
@State private var authMode = SavedServer.AuthMode.guest
|
||||
@State private var nickname = ""
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
@State private var savePassword = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Server") {
|
||||
TextField("Hostname or IP", text: $host)
|
||||
.textContentType(.URL)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.accessibilityLabel("Server hostname or IP address")
|
||||
TextField("Port", text: $port)
|
||||
.keyboardType(.numberPad)
|
||||
.accessibilityLabel("Port number")
|
||||
}
|
||||
|
||||
Section("Authentication") {
|
||||
Picker("Mode", selection: $authMode) {
|
||||
Text("Guest").tag(SavedServer.AuthMode.guest)
|
||||
Text("Account").tag(SavedServer.AuthMode.password)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.accessibilityLabel("Authentication mode")
|
||||
|
||||
if authMode == .guest {
|
||||
TextField("Nickname (optional)", text: $nickname)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.accessibilityLabel("Guest nickname, optional display name")
|
||||
}
|
||||
|
||||
if authMode == .password {
|
||||
TextField("Username", text: $username)
|
||||
.textContentType(.username)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.accessibilityLabel("Username")
|
||||
SecureField("Password (optional)", text: $password)
|
||||
.textContentType(.password)
|
||||
.accessibilityLabel("Password, optional, leave blank to enter at connect time")
|
||||
Toggle("Save password in Keychain", isOn: $savePassword)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(editing == nil ? "Add Server" : "Edit Server")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") { save() }
|
||||
.disabled(host.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|| UInt16(port) == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if let s = editing {
|
||||
host = s.host
|
||||
port = "\(s.port)"
|
||||
authMode = s.authMode
|
||||
username = s.savedUsername
|
||||
nickname = s.nickname ?? ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
let trimmedHost = host.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmedHost.isEmpty, let portNum = UInt16(port) else { return }
|
||||
|
||||
let pw = (savePassword && authMode == .password && !password.isEmpty) ? password : nil
|
||||
let trimmedNick = nickname.trimmingCharacters(in: .whitespaces)
|
||||
let nick: String? = (authMode == .guest && !trimmedNick.isEmpty) ? trimmedNick : nil
|
||||
|
||||
if var s = editing {
|
||||
s.host = trimmedHost
|
||||
s.port = portNum
|
||||
s.authMode = authMode
|
||||
s.savedUsername = authMode == .password ? username : ""
|
||||
s.nickname = nick
|
||||
appState.updateServer(s, password: pw)
|
||||
} else {
|
||||
let s = SavedServer(host: trimmedHost, port: portNum,
|
||||
authMode: authMode,
|
||||
savedUsername: authMode == .password ? username : "",
|
||||
nickname: nick)
|
||||
appState.addServer(s, password: pw)
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
struct BanUserView: View {
|
||||
let user: User
|
||||
@Bindable var session: SessionState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var reason = ""
|
||||
@State private var permanent = true
|
||||
@State private var duration: Double = 60 // minutes
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Ban \(user.nickname)") {
|
||||
TextField("Reason (optional)", text: $reason)
|
||||
.accessibilityLabel("Ban reason, optional")
|
||||
Toggle("Permanent", isOn: $permanent)
|
||||
.accessibilityLabel("Permanent ban")
|
||||
if !permanent {
|
||||
HStack {
|
||||
Text("Duration")
|
||||
Slider(value: $duration, in: 1...10080, step: 1)
|
||||
.accessibilityLabel("Ban duration in minutes")
|
||||
Text(formattedDuration)
|
||||
.monospacedDigit()
|
||||
.frame(width: 60, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Ban User")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Ban", role: .destructive) {
|
||||
let expiresMs: UInt64 = permanent ? 0
|
||||
: UInt64(Date().timeIntervalSince1970 * 1000) + UInt64(duration * 60 * 1000)
|
||||
session.banUser(user.id, reason: reason, expiresUnixMs: expiresMs)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var formattedDuration: String {
|
||||
let mins = Int(duration)
|
||||
if mins < 60 { return "\(mins)m" }
|
||||
let hours = mins / 60
|
||||
if hours < 24 { return "\(hours)h" }
|
||||
return "\(hours / 24)d"
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
/// iPhone channels tab: a drill-down browser. The root list shows only top-level channels;
|
||||
/// tapping (or VoiceOver-activating) a channel pushes `ChannelDetailView`, which shows the
|
||||
/// people in it and any sub-channels. Joining is an explicit action inside the detail view.
|
||||
struct ChannelBrowserView: View {
|
||||
@Bindable var session: SessionState
|
||||
@State private var showCreateChannel = false
|
||||
@State private var editChannel: Channel?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(rootChannels) { ch in
|
||||
NavigationLink {
|
||||
ChannelDetailView(channel: ch, session: session)
|
||||
} label: {
|
||||
ChannelRow(channel: ch, session: session)
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
if session.permissions.isAdmin {
|
||||
Button(role: .destructive) {
|
||||
session.deleteChannel(ch.id)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .leading) {
|
||||
if session.permissions.isAdmin {
|
||||
Button {
|
||||
editChannel = ch
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Channels")
|
||||
.toolbar {
|
||||
if session.permissions.canCreateTempChannel || session.permissions.isAdmin {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
showCreateChannel = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.accessibilityLabel("Create channel")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showCreateChannel) {
|
||||
ChannelEditView(channelId: nil, session: session)
|
||||
}
|
||||
.sheet(item: $editChannel) { ch in
|
||||
ChannelEditView(channelId: ch.id, session: session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var rootChannels: [Channel] {
|
||||
session.channels
|
||||
.filter { $0.parentId == 0 }
|
||||
.sorted { $0.name < $1.name }
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared channel row used by the iPhone browser and detail views. Mirrors the look of the
|
||||
/// iPad `ChannelTreeView` row but keyed on a `Channel` rather than a tree `ChannelNode`.
|
||||
struct ChannelRow: View {
|
||||
let channel: Channel
|
||||
let session: SessionState
|
||||
|
||||
var body: some View {
|
||||
let isCurrent = session.currentChannelId == channel.id
|
||||
let usersHere = session.users.filter { $0.channelId == channel.id }
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: channel.passwordProtected ? "lock.fill" : "number")
|
||||
.foregroundStyle(isCurrent ? .blue : .secondary)
|
||||
.imageScale(.small)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(channel.name)
|
||||
.fontWeight(isCurrent ? .semibold : .regular)
|
||||
if !channel.topic.isEmpty {
|
||||
Text(channel.topic)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if !usersHere.isEmpty {
|
||||
Text("\(usersHere.count)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(channel.name)\(isCurrent ? ", current" : "")\(channel.passwordProtected ? ", password protected" : "")\(!usersHere.isEmpty ? ", \(usersHere.count) users" : "")")
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
/// The drilled-into view for a single channel: a Join control, the people currently in the
|
||||
/// channel, and any sub-channels (each drilling deeper via a nested `ChannelDetailView`).
|
||||
struct ChannelDetailView: View {
|
||||
let channel: Channel
|
||||
@Bindable var session: SessionState
|
||||
|
||||
@State private var showPasswordPrompt = false
|
||||
@State private var password = ""
|
||||
|
||||
private var isCurrent: Bool { session.currentChannelId == channel.id }
|
||||
private var people: [User] { session.users.filter { $0.channelId == channel.id } }
|
||||
private var subchannels: [Channel] {
|
||||
session.channels.filter { $0.parentId == channel.id }.sorted { $0.name < $1.name }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
if isCurrent {
|
||||
Label("You're here", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.accessibilityLabel("You are in this channel")
|
||||
} else {
|
||||
Button {
|
||||
join()
|
||||
} label: {
|
||||
Label("Join Channel", systemImage: "arrow.right.circle.fill")
|
||||
}
|
||||
.accessibilityLabel("Join \(channel.name)")
|
||||
}
|
||||
}
|
||||
|
||||
Section("People") {
|
||||
if people.isEmpty {
|
||||
Text("No one here yet.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(people) { user in
|
||||
UserRow(user: user, session: session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !subchannels.isEmpty {
|
||||
Section("Channels") {
|
||||
ForEach(subchannels) { sub in
|
||||
NavigationLink {
|
||||
ChannelDetailView(channel: sub, session: session)
|
||||
} label: {
|
||||
ChannelRow(channel: sub, session: session)
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
if session.permissions.isAdmin {
|
||||
Button(role: .destructive) {
|
||||
session.deleteChannel(sub.id)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(channel.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.alert("Channel Password", isPresented: $showPasswordPrompt) {
|
||||
SecureField("Password", text: $password)
|
||||
.accessibilityLabel("Channel password")
|
||||
Button("Join") {
|
||||
session.joinChannel(channel.id, password: password)
|
||||
password = ""
|
||||
}
|
||||
Button("Cancel", role: .cancel) { password = "" }
|
||||
}
|
||||
}
|
||||
|
||||
private func join() {
|
||||
if channel.passwordProtected {
|
||||
showPasswordPrompt = true
|
||||
} else {
|
||||
session.joinChannel(channel.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
struct ChannelEditView: View {
|
||||
let channelId: UInt32?
|
||||
@Bindable var session: SessionState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
// General
|
||||
@State private var name = ""
|
||||
@State private var topic = ""
|
||||
@State private var parentId: UInt32 = 0
|
||||
@State private var passwordProtected = false
|
||||
@State private var password = ""
|
||||
@State private var maxUsers = "0"
|
||||
@State private var sortOrder = "0"
|
||||
|
||||
// Audio (Opus) — populated from the channel's current config when editing.
|
||||
@State private var stereo = false
|
||||
@State private var bitrate = "64000"
|
||||
@State private var sampleRate = "48000"
|
||||
@State private var frameMs: UInt32 = 20
|
||||
@State private var application: UInt32 = 0
|
||||
@State private var packetLoss = "5"
|
||||
@State private var complexity = 10
|
||||
@State private var fec = true
|
||||
@State private var dtx = false
|
||||
@State private var dred = false
|
||||
|
||||
private var isEditing: Bool { channelId != nil }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Channel Info") {
|
||||
TextField("Name", text: $name)
|
||||
.autocorrectionDisabled()
|
||||
.accessibilityLabel("Channel name")
|
||||
TextField("Topic (optional)", text: $topic)
|
||||
.accessibilityLabel("Channel topic, optional")
|
||||
Picker("Parent", selection: $parentId) {
|
||||
Text("(root)").tag(UInt32(0))
|
||||
ForEach(parentOptions) { ch in
|
||||
Text(ch.name).tag(ch.id)
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Parent channel")
|
||||
Toggle("Password protected", isOn: $passwordProtected)
|
||||
if passwordProtected {
|
||||
SecureField("Password (blank keeps existing)", text: $password)
|
||||
.accessibilityLabel("Channel password")
|
||||
}
|
||||
TextField("Max users (0 = unlimited)", text: $maxUsers)
|
||||
.keyboardType(.numberPad)
|
||||
.accessibilityLabel("Maximum users, zero means unlimited")
|
||||
TextField("Sort order", text: $sortOrder)
|
||||
.keyboardType(.numberPad)
|
||||
.accessibilityLabel("Sort order")
|
||||
}
|
||||
|
||||
Section("Audio (Opus)") {
|
||||
Toggle("Stereo", isOn: $stereo)
|
||||
TextField("Bitrate (bps)", text: $bitrate)
|
||||
.keyboardType(.numberPad)
|
||||
.accessibilityLabel("Bitrate in bits per second")
|
||||
TextField("Sample rate (Hz)", text: $sampleRate)
|
||||
.keyboardType(.numberPad)
|
||||
.accessibilityLabel("Sample rate in Hz")
|
||||
Picker("Frame", selection: $frameMs) {
|
||||
ForEach([UInt32(10), 20, 40, 60], id: \.self) { ms in
|
||||
Text("\(ms) ms").tag(ms)
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Opus frame duration")
|
||||
Picker("Application", selection: $application) {
|
||||
Text("VoIP").tag(UInt32(0))
|
||||
Text("Audio").tag(UInt32(1))
|
||||
Text("Low delay").tag(UInt32(2))
|
||||
}
|
||||
.accessibilityLabel("Opus application profile")
|
||||
TextField("Expected packet loss %", text: $packetLoss)
|
||||
.keyboardType(.numberPad)
|
||||
.accessibilityLabel("Expected packet loss percent, 0 to 100")
|
||||
Stepper("Complexity: \(complexity)", value: $complexity, in: 0...10)
|
||||
.accessibilityLabel("Opus complexity, 0 to 10")
|
||||
Toggle("FEC (forward error correction)", isOn: $fec)
|
||||
Toggle("DTX (discontinuous transmission)", isOn: $dtx)
|
||||
Toggle("DRED (deep redundancy)", isOn: $dred)
|
||||
}
|
||||
}
|
||||
.navigationTitle(isEditing ? "Edit Channel" : "New Channel")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
save()
|
||||
dismiss()
|
||||
}
|
||||
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
.onAppear(perform: loadIfEditing)
|
||||
}
|
||||
}
|
||||
|
||||
/// Channels offered as a parent. Excludes the channel being edited so it can't parent itself.
|
||||
private var parentOptions: [Channel] {
|
||||
session.channels
|
||||
.filter { $0.id != channelId }
|
||||
.sorted { $0.name < $1.name }
|
||||
}
|
||||
|
||||
private func loadIfEditing() {
|
||||
guard let id = channelId,
|
||||
let ch = session.channels.first(where: { $0.id == id }) else { return }
|
||||
name = ch.name
|
||||
topic = ch.topic
|
||||
parentId = ch.parentId
|
||||
passwordProtected = ch.passwordProtected
|
||||
maxUsers = "\(ch.maxUsers)"
|
||||
sortOrder = "\(ch.sortOrder)"
|
||||
stereo = ch.audio.stereo
|
||||
bitrate = "\(ch.audio.bitrateBps)"
|
||||
sampleRate = "\(ch.audio.sampleRate)"
|
||||
frameMs = ch.audio.frameMs
|
||||
application = ch.audio.application
|
||||
packetLoss = "\(ch.audio.expectedPacketLoss)"
|
||||
complexity = Int(ch.audio.complexity)
|
||||
fec = ch.audio.fec
|
||||
dtx = ch.audio.dtx
|
||||
dred = ch.audio.dred
|
||||
}
|
||||
|
||||
private func save() {
|
||||
let trimmedName = name.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmedName.isEmpty else { return }
|
||||
|
||||
let audio = AudioConfig(
|
||||
stereo: stereo,
|
||||
sampleRate: UInt32(sampleRate) ?? 48000,
|
||||
bitrateBps: UInt32(bitrate) ?? 64000,
|
||||
frameMs: frameMs,
|
||||
application: application,
|
||||
fec: fec,
|
||||
expectedPacketLoss: min(UInt32(packetLoss) ?? 5, 100),
|
||||
dtx: dtx,
|
||||
complexity: UInt32(complexity),
|
||||
dred: dred
|
||||
)
|
||||
|
||||
let pw: String? = passwordProtected ? (password.isEmpty ? nil : password) : nil
|
||||
let info = ChannelEdit(
|
||||
id: channelId ?? 0,
|
||||
parentId: parentId,
|
||||
name: trimmedName,
|
||||
topic: topic,
|
||||
passwordProtected: passwordProtected,
|
||||
password: pw,
|
||||
maxUsers: UInt32(maxUsers) ?? 0,
|
||||
sortOrder: UInt32(sortOrder) ?? 0,
|
||||
audio: audio
|
||||
)
|
||||
|
||||
if isEditing {
|
||||
session.editChannel(info)
|
||||
} else {
|
||||
session.createChannel(info)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
// ChannelNode wraps Channel for OutlineGroup; childrenOrNil must be nil (not empty [])
|
||||
// for leaf channels so OutlineGroup doesn't render expand buttons.
|
||||
struct ChannelNode: Identifiable {
|
||||
let channel: Channel
|
||||
let children: [ChannelNode]?
|
||||
var id: UInt32 { channel.id }
|
||||
}
|
||||
|
||||
struct ChannelTreeView: View {
|
||||
@Bindable var session: SessionState
|
||||
@State private var showCreateChannel = false
|
||||
@State private var editChannel: Channel?
|
||||
@State private var channelPassword = ""
|
||||
@State private var passwordChannelId: UInt32?
|
||||
|
||||
var body: some View {
|
||||
List(channelTree, children: \.children) { node in
|
||||
ChannelRowView(node: node, session: session)
|
||||
.onTapGesture {
|
||||
if node.channel.passwordProtected {
|
||||
passwordChannelId = node.channel.id
|
||||
} else {
|
||||
session.joinChannel(node.channel.id)
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
if session.permissions.isAdmin {
|
||||
Button(role: .destructive) {
|
||||
session.deleteChannel(node.channel.id)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .leading) {
|
||||
if session.permissions.isAdmin {
|
||||
Button {
|
||||
editChannel = node.channel
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.toolbar {
|
||||
if session.permissions.canCreateTempChannel || session.permissions.isAdmin {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
showCreateChannel = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.accessibilityLabel("Create channel")
|
||||
}
|
||||
}
|
||||
if session.currentChannelId != 0 {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Leave", systemImage: "arrow.left.circle") {
|
||||
session.leaveChannel()
|
||||
}
|
||||
.accessibilityLabel("Leave current channel")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showCreateChannel) {
|
||||
ChannelEditView(channelId: nil, session: session)
|
||||
}
|
||||
.sheet(item: $editChannel) { ch in
|
||||
ChannelEditView(channelId: ch.id, session: session)
|
||||
}
|
||||
.alert("Channel Password", isPresented: Binding(
|
||||
get: { passwordChannelId != nil },
|
||||
set: { if !$0 { passwordChannelId = nil; channelPassword = "" } }
|
||||
)) {
|
||||
SecureField("Password", text: $channelPassword)
|
||||
.accessibilityLabel("Channel password")
|
||||
Button("Join") {
|
||||
if let cid = passwordChannelId {
|
||||
session.joinChannel(cid, password: channelPassword)
|
||||
}
|
||||
passwordChannelId = nil
|
||||
channelPassword = ""
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
passwordChannelId = nil
|
||||
channelPassword = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var channelTree: [ChannelNode] {
|
||||
buildTree(parentId: 0, channels: session.channels)
|
||||
}
|
||||
|
||||
private func buildTree(parentId: UInt32, channels: [Channel]) -> [ChannelNode] {
|
||||
channels
|
||||
.filter { $0.parentId == parentId }
|
||||
.map { ch in
|
||||
let kids = buildTree(parentId: ch.id, channels: channels)
|
||||
return ChannelNode(channel: ch, children: kids.isEmpty ? nil : kids)
|
||||
}
|
||||
.sorted { $0.channel.name < $1.channel.name }
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChannelRowView: View {
|
||||
let node: ChannelNode
|
||||
let session: SessionState
|
||||
|
||||
var body: some View {
|
||||
let ch = node.channel
|
||||
let isCurrent = session.currentChannelId == ch.id
|
||||
let usersHere = session.users.filter { $0.channelId == ch.id }
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: ch.passwordProtected ? "lock.fill" : "number")
|
||||
.foregroundStyle(isCurrent ? .blue : .secondary)
|
||||
.imageScale(.small)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(ch.name)
|
||||
.fontWeight(isCurrent ? .semibold : .regular)
|
||||
if !ch.topic.isEmpty {
|
||||
Text(ch.topic)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if !usersHere.isEmpty {
|
||||
Text("\(usersHere.count)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityLabel("\(usersHere.count) users")
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(ch.name)\(isCurrent ? ", current" : "")\(ch.passwordProtected ? ", password protected" : "")\(!usersHere.isEmpty ? ", \(usersHere.count) users" : "")")
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
/// One row of the combined chat/activity timeline. Mirrors the macOS/Windows clients, which
|
||||
/// collapse chat messages and activity events into a single scrolling log (chat in normal
|
||||
/// text, activity events in gray).
|
||||
private enum TimelineItem: Identifiable {
|
||||
case message(ChatMessage)
|
||||
case activity(ActivityEntry)
|
||||
|
||||
var id: UUID {
|
||||
switch self {
|
||||
case .message(let m): return m.id
|
||||
case .activity(let a): return a.id
|
||||
}
|
||||
}
|
||||
|
||||
var timestamp: Date {
|
||||
switch self {
|
||||
case .message(let m): return m.timestamp
|
||||
case .activity(let a): return a.timestamp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatView: View {
|
||||
@Bindable var session: SessionState
|
||||
@State private var composeText = ""
|
||||
@State private var scope: VoiceCatTextScope = .channel
|
||||
@State private var privateTargetId: UInt32 = 0
|
||||
|
||||
private var timeline: [TimelineItem] {
|
||||
let merged = session.messages.map(TimelineItem.message)
|
||||
+ session.activityLog.map(TimelineItem.activity)
|
||||
return merged.sorted { $0.timestamp < $1.timestamp }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Combined chat + activity timeline
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(timeline) { item in
|
||||
switch item {
|
||||
case .message(let msg):
|
||||
ChatBubble(message: msg)
|
||||
.id(item.id)
|
||||
case .activity(let entry):
|
||||
ActivityRow(entry: entry)
|
||||
.id(item.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.onChange(of: session.messages.count + session.activityLog.count) { _, _ in
|
||||
if let last = timeline.last {
|
||||
proxy.scrollTo(last.id, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Compose bar
|
||||
HStack(spacing: 8) {
|
||||
TextField("Message…", text: $composeText, axis: .vertical)
|
||||
.lineLimit(1...5)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.accessibilityLabel("Message text field")
|
||||
.onSubmit { sendMessage() }
|
||||
|
||||
Button {
|
||||
sendMessage()
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.imageScale(.large)
|
||||
}
|
||||
.disabled(composeText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
|| session.currentChannelId == 0)
|
||||
.accessibilityLabel("Send message")
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.navigationTitle("Chat")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private func sendMessage() {
|
||||
let text = composeText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
session.sendText(text, scope: .channel, targetId: session.currentChannelId)
|
||||
composeText = ""
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChatBubble: View {
|
||||
let message: ChatMessage
|
||||
|
||||
private var timeString: String {
|
||||
let fmt = DateFormatter()
|
||||
fmt.dateStyle = .none
|
||||
fmt.timeStyle = .short
|
||||
return fmt.string(from: message.timestamp)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 4) {
|
||||
Text(message.senderName)
|
||||
.font(.caption)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(timeString)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
Text(message.text)
|
||||
.font(.body)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(message.senderName) at \(timeString): \(message.text)")
|
||||
}
|
||||
}
|
||||
|
||||
/// A compact, gray activity row interleaved into the chat timeline (joins/leaves, talk state,
|
||||
/// streams, server mute, etc.). Matches the "activity = gray" convention of the macOS/Windows
|
||||
/// unified logs.
|
||||
private struct ActivityRow: View {
|
||||
let entry: ActivityEntry
|
||||
|
||||
private static let timeFormatter: DateFormatter = {
|
||||
let fmt = DateFormatter()
|
||||
fmt.dateStyle = .none
|
||||
fmt.timeStyle = .short
|
||||
return fmt
|
||||
}()
|
||||
|
||||
private var timeString: String { Self.timeFormatter.string(from: entry.timestamp) }
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
Text(timeString)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.monospacedDigit()
|
||||
Text(entry.text)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(timeString): \(entry.text)")
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MainView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||
|
||||
var body: some View {
|
||||
if let session = appState.session {
|
||||
if sizeClass == .regular {
|
||||
iPadMainView(session: session)
|
||||
} else {
|
||||
iPhoneMainView(session: session)
|
||||
}
|
||||
} else {
|
||||
ServerListView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iPhone layout: TabView
|
||||
|
||||
private struct iPhoneMainView: View {
|
||||
let session: SessionState
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
ChannelBrowserView(session: session)
|
||||
.voiceControlsBar(session)
|
||||
.tabItem {
|
||||
Label("Channels", systemImage: "list.bullet.indent")
|
||||
}
|
||||
ChatView(session: session)
|
||||
.voiceControlsBar(session)
|
||||
.tabItem {
|
||||
Label("Chat", systemImage: "message")
|
||||
}
|
||||
SettingsView(session: session)
|
||||
.voiceControlsBar(session)
|
||||
.tabItem {
|
||||
Label("Settings", systemImage: "gear")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
/// Pin the shared voice controls just above the tab bar, *inside each tab's content area*.
|
||||
/// Applying this per-tab (rather than to the TabView itself) reserves layout space above
|
||||
/// the tab bar — keeping ChatView's compose box visible and cooperating with keyboard
|
||||
/// avoidance — without the bar covering the tab bar's buttons.
|
||||
func voiceControlsBar(_ session: SessionState) -> some View {
|
||||
safeAreaInset(edge: .bottom) {
|
||||
VoiceControlsView(session: session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iPad layout: NavigationSplitView
|
||||
|
||||
private struct iPadMainView: View {
|
||||
let session: SessionState
|
||||
@State private var columnVisibility = NavigationSplitViewVisibility.all
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
ChannelTreeView(session: session)
|
||||
.navigationTitle("Channels")
|
||||
} content: {
|
||||
UserListView(session: session)
|
||||
.navigationTitle("Users")
|
||||
} detail: {
|
||||
VStack(spacing: 0) {
|
||||
ChatView(session: session)
|
||||
VoiceControlsView(session: session)
|
||||
}
|
||||
.navigationTitle("Chat")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
struct MoveUserView: View {
|
||||
let user: User
|
||||
@Bindable var session: SessionState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var selectedChannelId: UInt32 = 0
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List(session.channels) { channel in
|
||||
HStack {
|
||||
Text(channel.name)
|
||||
Spacer()
|
||||
if channel.id == selectedChannelId {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(.blue)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { selectedChannelId = channel.id }
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(channel.name)\(channel.id == selectedChannelId ? ", selected" : "")")
|
||||
.accessibilityAddTraits(channel.id == selectedChannelId ? .isSelected : [])
|
||||
}
|
||||
.navigationTitle("Move \(user.nickname)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Move") {
|
||||
session.moveUser(user.id, toChannel: selectedChannelId)
|
||||
dismiss()
|
||||
}
|
||||
.disabled(selectedChannelId == 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
selectedChannelId = user.channelId
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user