feat(ios): ship iOS SwiftUI client (VoiceCatiOS)

Full SwiftUI app at clients/apple/iOS/VoiceCatiOS.xcodeproj:
- 24 Swift source files: AppState + SessionState (@Observable @MainActor),
  AudioSessionManager (AVAudioSession owner + interruption/route handling),
  ServerListStore/SavedServer (App Group container + Keychain sharing),
  and 14 SwiftUI views covering the full feature set
- NavigationSplitView on iPad, TabView on iPhone (horizontalSizeClass)
- Channel tree via OutlineGroup, user list with context menu admin actions
- PTT via DragGesture(minimumDistance: 0) + @GestureState
- onEvent closures hop to MainActor via Task { @MainActor in ... }
- App Group: group.cat.voice.VoiceCat (shared with future ReplayKit extension)

C ABI: add vc_audio_suspend / vc_audio_resume (AudioEngine::suspend/resume)
called by AudioSessionManager on AVAudioSession interruption events.

XCFramework: add ios-arm64 and ios-arm64-simulator slices to build-xcframework.sh;
Package.swift gains .iOS(.v17) platform; CMakePresets.json adds apple-ios /
apple-ios-sim presets with arm64-ios / arm64-ios-simulator vcpkg triplets.

Verified: xcodebuild -target VoiceCatiOS -sdk iphonesimulator26.5 BUILD SUCCEEDED.
This commit is contained in:
2026-06-19 02:10:25 +02:00
parent dbf732ca91
commit e26e7db5b1
46 changed files with 2929 additions and 22 deletions

View File

@@ -6,9 +6,10 @@ and what's next* read [`PROGRESS.md`](PROGRESS.md); for *design* read [`docs/`](
> **One-line status:** M5 (moderation & admin UI) is complete — permissions, kick/ban/move, > **One-line status:** M5 (moderation & admin UI) is complete — permissions, kick/ban/move,
> server-mute, channel CRUD, in-app account management, disconnect/keepalive/reaper. Windows > server-mute, channel CRUD, in-app account management, disconnect/keepalive/reaper. Windows
> WinForms C# client is shipped (M4). **macOS AppKit client shipped** — `VoiceCatMac.xcodeproj` > WinForms C# client shipped (M4). **macOS AppKit client shipped** — `VoiceCatMac.xcodeproj`
> at `clients/apple/macOS/`. `ctest --preset dev` green — 21/21 tests. > at `clients/apple/macOS/`. **iOS SwiftUI client shipped** — `VoiceCatiOS.xcodeproj` at
> iOS SwiftUI client is next. See [`PROGRESS.md`](PROGRESS.md). > `clients/apple/iOS/`. `ctest --preset dev` green — 21/21 tests.
> Next: ReplayKit Broadcast Extension or DRED/audio polish. See [`PROGRESS.md`](PROGRESS.md).
VoiceCat = self-hosted native voice & text chat (TeamSpeak/Mumble-style). Plain TCP (control) VoiceCat = self-hosted native voice & text chat (TeamSpeak/Mumble-style). Plain TCP (control)
+ UDP (media), no WebRTC, encrypted by default. A shared C++ core (`libvoicecat`) drives + UDP (media), no WebRTC, encrypted by default. A shared C++ core (`libvoicecat`) drives

View File

@@ -5,6 +5,13 @@ project(voicecat
DESCRIPTION "Self-hosted native voice & text chat (see docs/)" DESCRIPTION "Self-hosted native voice & text chat (see docs/)"
LANGUAGES CXX) LANGUAGES CXX)
# On iOS, audio_engine.cpp includes miniaudio.h which pulls in AVFoundation Objective-C
# headers. Those cannot be compiled as C++; we set audio_engine.cpp's LANGUAGE to OBJCXX
# in core/CMakeLists.txt, but that requires the language to be enabled first.
if(CMAKE_SYSTEM_NAME STREQUAL "iOS")
enable_language(OBJCXX)
endif()
# ── Options ─────────────────────────────────────────────────────────────────── # ── Options ───────────────────────────────────────────────────────────────────
# The M0 skeleton compiles with NO third-party dependencies: every subsystem is a # The M0 skeleton compiles with NO third-party dependencies: every subsystem is a
# stub that returns VC_ERR_NOT_IMPLEMENTED. As each subsystem is built out, flip # stub that returns VC_ERR_NOT_IMPLEMENTED. As each subsystem is built out, flip

View File

@@ -90,15 +90,19 @@
}, },
{ {
"name": "apple-ios", "name": "apple-ios",
"displayName": "Apple iOS device (XCFramework slice, scaffolding)", "displayName": "Apple iOS device (XCFramework slice)",
"description": "SCAFFOLDING — not yet CI-validated; build on macOS to verify. Cross-compiles a static libvoicecat.a for iOS device (arm64-ios). One slice of the XCFramework. Server/tools/tests off. Requires VCPKG_ROOT and a macOS host with iOS SDK.", "description": "Cross-compiles a static libvoicecat.a for iOS device (arm64). One slice of the XCFramework. Server/tools/tests off. Requires VCPKG_ROOT and a macOS host with iOS SDK. Uses cmake/vcpkg-overlays/triplets/arm64-ios.cmake (release-only, correct autoconf host triple).",
"inherits": "vcpkg-common", "inherits": "vcpkg-common",
"binaryDir": "${sourceDir}/build/apple-ios", "binaryDir": "${sourceDir}/build/apple-ios",
"cacheVariables": { "cacheVariables": {
"CMAKE_BUILD_TYPE": "Release", "CMAKE_BUILD_TYPE": "Release",
"CMAKE_SYSTEM_NAME": "iOS", "CMAKE_SYSTEM_NAME": "iOS",
"CMAKE_SYSTEM_PROCESSOR": "arm64", "CMAKE_SYSTEM_PROCESSOR": "arm64",
"CMAKE_OSX_ARCHITECTURES": "arm64",
"CMAKE_OSX_SYSROOT": "iphoneos",
"CMAKE_OSX_DEPLOYMENT_TARGET": "17.0",
"VCPKG_TARGET_TRIPLET": "arm64-ios", "VCPKG_TARGET_TRIPLET": "arm64-ios",
"VCPKG_OVERLAY_TRIPLETS": "${sourceDir}/cmake/vcpkg-overlays/triplets",
"VOICECAT_BUILD_SERVER": "OFF", "VOICECAT_BUILD_SERVER": "OFF",
"VOICECAT_BUILD_TOOLS": "OFF", "VOICECAT_BUILD_TOOLS": "OFF",
"VOICECAT_BUILD_TESTS": "OFF" "VOICECAT_BUILD_TESTS": "OFF"
@@ -106,15 +110,19 @@
}, },
{ {
"name": "apple-ios-sim", "name": "apple-ios-sim",
"displayName": "Apple iOS simulator (XCFramework slice, scaffolding)", "displayName": "Apple iOS simulator (XCFramework slice)",
"description": "SCAFFOLDING — not yet CI-validated; build on macOS to verify. Cross-compiles a static libvoicecat.a for iOS simulator (arm64-ios-sim on Apple Silicon). One slice of the XCFramework. Server/tools/tests off. Requires VCPKG_ROOT and a macOS host with iOS simulator SDK.", "description": "Cross-compiles a static libvoicecat.a for iOS simulator (arm64-ios-simulator). One slice of the XCFramework. Server/tools/tests off. Requires VCPKG_ROOT and a macOS host with iOS simulator SDK. Uses cmake/vcpkg-overlays/triplets/arm64-ios-simulator.cmake (release-only, correct autoconf host triple).",
"inherits": "vcpkg-common", "inherits": "vcpkg-common",
"binaryDir": "${sourceDir}/build/apple-ios-sim", "binaryDir": "${sourceDir}/build/apple-ios-sim",
"cacheVariables": { "cacheVariables": {
"CMAKE_BUILD_TYPE": "Release", "CMAKE_BUILD_TYPE": "Release",
"CMAKE_SYSTEM_NAME": "iOS", "CMAKE_SYSTEM_NAME": "iOS",
"CMAKE_SYSTEM_PROCESSOR": "arm64", "CMAKE_SYSTEM_PROCESSOR": "arm64",
"VCPKG_TARGET_TRIPLET": "arm64-ios-sim", "CMAKE_OSX_ARCHITECTURES": "arm64",
"CMAKE_OSX_SYSROOT": "iphonesimulator",
"CMAKE_OSX_DEPLOYMENT_TARGET": "17.0",
"VCPKG_TARGET_TRIPLET": "arm64-ios-simulator",
"VCPKG_OVERLAY_TRIPLETS": "${sourceDir}/cmake/vcpkg-overlays/triplets",
"VOICECAT_BUILD_SERVER": "OFF", "VOICECAT_BUILD_SERVER": "OFF",
"VOICECAT_BUILD_TOOLS": "OFF", "VOICECAT_BUILD_TOOLS": "OFF",
"VOICECAT_BUILD_TESTS": "OFF" "VOICECAT_BUILD_TESTS": "OFF"

View File

@@ -10,6 +10,30 @@ up instantly. Newest status at the top.
## ▶ Where we left off / next action ## ▶ Where we left off / next action
- **Done:** **iOS SwiftUI client — `VoiceCatiOS`** (2026-06-19). Full SwiftUI app at
`clients/apple/iOS/VoiceCatiOS.xcodeproj`. Mirrors the macOS AppKit and Windows WinForms
clients feature-for-feature: saved server list (JSON + Keychain passwords via App Group
`group.cat.voice.VoiceCat`), TOFU server-identity sheet (first-connect and mismatch), connect
flow (guest + account auth, connection state labels, password prompt), main window
(`NavigationSplitView` on iPad, `TabView` on iPhone via `horizontalSizeClass`), channel tree
(`OutlineGroup` recursive — `children: nil` for leaf nodes), user list with context menu
(kick/ban/move/set-permissions/server-mute), chat (`ScrollViewReader` + `LazyVStack`), activity
log, voice controls (mic toggle, PTT via `DragGesture(minimumDistance: 0)` + `@GestureState`,
level meter `Canvas`), per-user tuning (gain slider, mute, NR toggles), admin sheets (channels
CRUD, accounts CRUD, ban duration, move-to-channel), settings (input mode, VAD threshold,
device picker, disconnect). All 24 Swift source files in place (10 model/state + 14 views).
`@Observable` + `@MainActor` throughout — both `AppState` and `SessionState` are `@MainActor`.
`onEvent` closures dispatch to main actor via `Task { @MainActor in ... }`.
**C ABI additions:** `vc_audio_suspend` / `vc_audio_resume` (no-op when engine stopped) —
called by `AudioSessionManager` on AVAudioSession interruption (phone calls, Siri).
**XCFramework:** iOS arm64 + arm64-simulator slices added alongside existing macOS-arm64 slice;
`Package.swift` updated with `.iOS(.v17)`.
**Verified:** `xcodebuild -target VoiceCatiOS -sdk iphonesimulator26.5 -configuration Debug ...
SYMROOT=... OBJROOT=... build`**BUILD SUCCEEDED** (clean build).
- **Next:** ReplayKit Broadcast Upload Extension (`VoiceCatBroadcast`) for screen/system audio
sharing on iOS — separate Xcode target, App Group credential sharing, `SampleHandler.swift`.
Or: DRED/audio-quality polish (M5).
- **Done:** **macOS AppKit client builds clean (Debug + Release)** (2026-06-18). The previous - **Done:** **macOS AppKit client builds clean (Debug + Release)** (2026-06-18). The previous
"shipped" claim in the entry below was incorrect — `xcodebuild` actually failed on a fresh "shipped" claim in the entry below was incorrect — `xcodebuild` actually failed on a fresh
clone. Two real defect classes fixed, no source architecture changed: clone. Two real defect classes fixed, no source architecture changed:
@@ -504,7 +528,7 @@ up instantly. Newest status at the top.
- [x] **M1 — Control plane** ✓ complete (2026-06-15) - [x] **M1 — Control plane** ✓ complete (2026-06-15)
- [x] **M2 — Voice, single stream** ✓ complete (2026-06-16) - [x] **M2 — Voice, single stream** ✓ complete (2026-06-16)
- [x] **M3 — Multi-stream & per-channel tuning** ✓ complete (2026-06-16) - [x] **M3 — Multi-stream & per-channel tuning** ✓ complete (2026-06-16)
- [x] **M4 — Native clients** — Windows WinForms ✓ (2026-06-17); macOS AppKit ✓ (2026-06-18); iOS SwiftUI pending - [x] **M4 — Native clients** — Windows WinForms ✓ (2026-06-17); macOS AppKit ✓ (2026-06-18); iOS SwiftUI ✓ (2026-06-19)
- [~] **M5 — Moderation, polish, beyond** (perms, bans, DRED; then file transfer, E2EE, …) - [~] **M5 — Moderation, polish, beyond** (perms, bans, DRED; then file transfer, E2EE, …)
--- ---

View File

@@ -16,11 +16,12 @@ import PackageDescription
let package = Package( let package = Package(
name: "VoiceCatCore", name: "VoiceCatCore",
// macOS 14 (Sonoma) is the AppKit client's deployment target. iOS is deferred; when the // macOS 14 (Sonoma) is the AppKit client's deployment target. iOS 17 is the SwiftUI client
// iOS app lands, add `.iOS(.v17)` here and the apple-ios / apple-ios-sim slices to the // target (clients/apple/iOS/). Run `scripts/build-xcframework.sh --all` to produce all
// XCFramework (scripts/build-xcframework.sh --all). // three slices: macos-arm64, ios-arm64, ios-arm64-simulator.
platforms: [ platforms: [
.macOS(.v14), .macOS(.v14),
.iOS(.v17),
], ],
products: [ products: [
.library(name: "VoiceCatCore", targets: ["VoiceCatCore"]), .library(name: "VoiceCatCore", targets: ["VoiceCatCore"]),

View File

@@ -322,6 +322,20 @@ public final class VoiceCatClient {
VoiceCatResult(vc_set_self_mute(handle, micMuted ? 1 : 0, deafened ? 1 : 0)) VoiceCatResult(vc_set_self_mute(handle, micMuted ? 1 : 0, deafened ? 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))
}
// MARK: - Receive-side, per remote stream (LOCAL no protocol traffic; docs/voice.md §10) // MARK: - Receive-side, per remote stream (LOCAL no protocol traffic; docs/voice.md §10)
@discardableResult @discardableResult

View File

@@ -0,0 +1,443 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
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 */; };
BBBB00000000000000000036 /* BroadcastCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000001D /* BroadcastCredentials.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 */; };
BBBB0000000000000000003F /* ActivityLogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000026 /* ActivityLogView.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 */; };
BBBB00000000000000000048 /* VoiceCatCore in Frameworks */ = {isa = PBXBuildFile; productRef = BBBB0000000000000000004A /* VoiceCatCore */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
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>"; };
BBBB0000000000000000001D /* BroadcastCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastCredentials.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>"; };
BBBB00000000000000000026 /* ActivityLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityLogView.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>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
BBBB00000000000000000011 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
BBBB00000000000000000048 /* VoiceCatCore in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
BBBB00000000000000000002 /* mainGroup */ = {
isa = PBXGroup;
children = (
BBBB00000000000000000003 /* VoiceCatiOS */,
BBBB00000000000000000007 /* Products */,
);
sourceTree = "<group>";
};
BBBB00000000000000000003 /* VoiceCatiOS */ = {
isa = PBXGroup;
children = (
BBBB00000000000000000015 /* Info.plist */,
BBBB00000000000000000016 /* VoiceCatiOS.entitlements */,
BBBB00000000000000000017 /* VoiceCatiOSApp.swift */,
BBBB00000000000000000018 /* AppState.swift */,
BBBB00000000000000000019 /* SessionState.swift */,
BBBB0000000000000000001A /* AudioSessionManager.swift */,
BBBB0000000000000000001B /* ServerListStore.swift */,
BBBB0000000000000000001C /* SavedServer.swift */,
BBBB0000000000000000001D /* BroadcastCredentials.swift */,
BBBB00000000000000000006 /* Views */,
);
path = VoiceCatiOS;
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 */,
BBBB00000000000000000026 /* ActivityLogView.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 */,
);
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 */,
);
buildRules = (
);
dependencies = (
);
name = VoiceCatiOS;
packageProductDependencies = (
BBBB0000000000000000004A /* VoiceCatCore */,
);
productName = VoiceCatiOS;
productReference = BBBB00000000000000000012 /* VoiceCatiOS.app */;
productType = "com.apple.product-type.application";
};
/* 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 /* mainGroup */;
packageReferences = (
BBBB00000000000000000049 /* XCLocalSwiftPackageReference "../" */,
);
productRefGroup = BBBB00000000000000000007 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
BBBB00000000000000000008 /* VoiceCatiOS */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
BBBB00000000000000000010 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
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 */,
BBBB00000000000000000034 /* ServerListStore.swift in Sources */,
BBBB00000000000000000035 /* SavedServer.swift in Sources */,
BBBB00000000000000000036 /* BroadcastCredentials.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 */,
BBBB0000000000000000003F /* ActivityLogView.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;
};
/* 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 = 17.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 = 17.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 = "";
INFOPLIST_FILE = VoiceCatiOS/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.0.1;
OTHER_LDFLAGS = (
"$(inherited)",
"-lc++",
);
PRODUCT_BUNDLE_IDENTIFIER = cat.voice.VoiceCatiOS;
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 = "";
INFOPLIST_FILE = VoiceCatiOS/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.0.1;
OTHER_LDFLAGS = (
"$(inherited)",
"-lc++",
);
PRODUCT_BUNDLE_IDENTIFIER = cat.voice.VoiceCatiOS;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
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;
};
/* 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 */;
}

View File

@@ -0,0 +1,78 @@
<?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>

View File

@@ -0,0 +1,174 @@
import Foundation
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
// 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) {
guard !isConnecting else { return }
isConnecting = true
connectStatus = "Connecting…"
connectingServer = server
identityHandled = false
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) }
}
client.connect(host: server.host, port: server.port)
// Auth is queued immediately the core serialises it behind TLS + TOFU.
switch server.authMode {
case .guest:
let nick = server.savedUsername.isEmpty ? "iOS User" : server.savedUsername
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() {
session?.client.disconnect()
session = nil
connectingClient?.disconnect()
connectingClient = nil
connectingServer = 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() {
connectingClient?.disconnect()
connectingClient = nil
connectingServer = nil
isConnecting = false
connectStatus = ""
showPasswordPrompt = false
pendingIdentity = nil
}
// MARK: - Connect event handler
private func handleConnectEvent(_ ev: VoiceCatEvent, server: SavedServer) {
switch ev.type {
case .connectionState:
switch ev.connectionState {
case .connecting: connectStatus = "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)
ServerListStore.shared.writeBroadcastCredentials(server: server, nickname: nil)
connectingClient = nil
isConnecting = false
connectStatus = ""
showPasswordPrompt = false
self.session = newSession
} else {
connectStatus = "Auth failed: \(ev.result.description)"
showPasswordPrompt = true
}
case .disconnected:
if session == nil { cancelConnect() }
else { session = nil; isConnecting = false }
case .error:
connectStatus = ev.text ?? "Unknown error"
if session == nil { isConnecting = false }
default:
break
}
}
}

View File

@@ -0,0 +1,64 @@
import AVFoundation
import VoiceCatCore
@MainActor
final class AudioSessionManager {
static let shared = AudioSessionManager()
weak var client: VoiceCatClient?
func configure() {
let session = AVAudioSession.sharedInstance()
do {
try session.setCategory(.playAndRecord, mode: .voiceChat,
options: [.allowBluetooth, .allowBluetoothA2DP,
.defaultToSpeaker, .mixWithOthers])
} catch {
print("[AudioSession] setCategory failed: \(error)")
}
NotificationCenter.default.addObserver(
self, selector: #selector(handleInterruption),
name: AVAudioSession.interruptionNotification, object: nil)
NotificationCenter.default.addObserver(
self, selector: #selector(handleRouteChange),
name: AVAudioSession.routeChangeNotification, object: nil)
}
func activateForStreaming() throws {
try AVAudioSession.sharedInstance().setActive(true, options: [])
}
func deactivateAfterStreaming() {
try? AVAudioSession.sharedInstance().setActive(false,
options: .notifyOthersOnDeactivation)
}
@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:
client?.audioSuspend()
case .ended:
let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
if options.contains(.shouldResume) {
try? AVAudioSession.sharedInstance().setActive(true)
client?.audioResume()
}
@unknown default: break
}
}
@objc private func handleRouteChange(_ notification: Notification) {
NotificationCenter.default.post(name: .voiceCatDeviceListChanged, object: nil)
}
}
extension Notification.Name {
static let voiceCatDeviceListChanged = Notification.Name("cat.voice.deviceListChanged")
}

View File

@@ -0,0 +1,21 @@
import Foundation
struct BroadcastCredentials: Codable {
var host: String
var port: Int
var authMode: String
var username: String
var tofuPinsPath: String
static func loadFromAppGroup() -> BroadcastCredentials? {
let groupId = "group.cat.voice.VoiceCat"
guard let container = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: groupId)
else { return nil }
let url = container
.appendingPathComponent("voicecat", isDirectory: true)
.appendingPathComponent("broadcast_credentials.json")
guard let data = try? Data(contentsOf: url) else { return nil }
return try? JSONDecoder().decode(BroadcastCredentials.self, from: data)
}
}

View File

@@ -0,0 +1,47 @@
<?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>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<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>

View File

@@ -0,0 +1,24 @@
import Foundation
struct SavedServer: Codable, Identifiable, Equatable {
let id: UUID
var host: String
var port: UInt16
var authMode: AuthMode
var savedUsername: String
var keychainTag: String
enum AuthMode: String, Codable { case guest, password }
init(id: UUID = UUID(), host: String, port: UInt16,
authMode: AuthMode = .guest, savedUsername: String = "", keychainTag: String = "") {
self.id = id
self.host = host
self.port = port
self.authMode = authMode
self.savedUsername = savedUsername
self.keychainTag = keychainTag.isEmpty ? id.uuidString : keychainTag
}
var displayString: String { "\(host):\(port)" }
}

View File

@@ -0,0 +1,106 @@
import Foundation
import Security
final class ServerListStore {
static let shared = ServerListStore()
private let groupId = "group.cat.voice.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)
}
// MARK: - Broadcast credentials (shared with ReplayKit extension)
func writeBroadcastCredentials(server: SavedServer, nickname: String?) {
let creds = BroadcastCredentials(
host: server.host,
port: Int(server.port),
authMode: server.authMode.rawValue,
username: server.authMode == .password ? server.savedUsername : (nickname ?? ""),
tofuPinsPath: tofuStorePath
)
guard let data = try? JSONEncoder().encode(creds) else { return }
let url = voicecatDir.appendingPathComponent("broadcast_credentials.json")
try? data.write(to: url, options: .atomic)
}
}

View File

@@ -0,0 +1,252 @@
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 selfMuted = false
var selfDeafened = false
var inputMode: VoiceCatInputMode = .voiceActivation
var vadThreshold: Float = 0.025
var level: Float = 0.0
var currentDeviceId: String?
var localStreamId: 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] = []
init(client: VoiceCatClient, selfUserId: UInt32, permissions: Permissions) {
self.client = client
self.selfUserId = selfUserId
self.permissions = permissions
AudioSessionManager.shared.client = client
refreshChannels()
refreshUsers()
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 }
}
}
deinit {
MainActor.assumeIsolated {
AudioSessionManager.shared.client = nil
}
}
// MARK: - Event dispatch
func handleEvent(_ ev: VoiceCatEvent) {
switch ev.type {
case .channelList:
refreshChannels()
case .userJoined, .userLeft, .userUpdated:
refreshUsers()
case .textMessage:
let sender = users.first(where: { $0.id == ev.userId })?.nickname ?? "Unknown"
messages.append(ChatMessage(
timestamp: Date(timeIntervalSince1970: Double(ev.timestampUnixMs) / 1000),
senderName: sender,
text: ev.text ?? "",
scope: ev.textScope))
case .talkState:
let talking = ev.u32a != 0
let who = users.first(where: { $0.id == ev.userId })?.nickname ?? "user \(ev.userId)"
addActivity(talking ? "\(who) started talking" : "\(who) stopped talking")
case .streamStarted:
addActivity("Stream started (user \(ev.userId))")
case .streamStopped:
addActivity("Stream stopped (user \(ev.userId))")
case .joinResult:
if ev.result == .ok {
currentChannelId = ev.channelId
addActivity("Joined channel")
refreshUsers()
} else {
addActivity("Join failed: \(ev.result.description)")
}
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()
default:
break
}
}
private func addActivity(_ text: String) {
activityLog.append(ActivityEntry(timestamp: Date(), text: text))
if activityLog.count > 500 { activityLog.removeFirst() }
}
// 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 startMicStream() {
AVAudioApplication.requestRecordPermission { [weak self] granted in
DispatchQueue.main.async {
guard let self else { return }
if granted {
self.doStartMicStream()
} else {
self.addActivity("Microphone permission denied — grant in Settings > Privacy > Microphone")
}
}
}
}
private func doStartMicStream() {
do {
try AudioSessionManager.shared.activateForStreaming()
} catch {
addActivity("AVAudioSession activate failed: \(error)")
return
}
let desc = StreamDescriptor(kind: .mic, deviceId: voiceState.currentDeviceId, label: "Mic")
let (result, streamId) = client.startStream(desc)
if result == .ok {
voiceState.micActive = true
voiceState.localStreamId = streamId
} else {
addActivity("Failed to start mic: \(result.description)")
}
}
func stopMicStream() {
if voiceState.localStreamId != 0 {
client.stopStream(voiceState.localStreamId)
voiceState.localStreamId = 0
}
voiceState.micActive = false
voiceState.level = 0
AudioSessionManager.shared.deactivateAfterStreaming()
}
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
}
func setVadThreshold(_ threshold: Float) {
client.setVadThreshold(threshold)
voiceState.vadThreshold = threshold
}
func setPushToTalk(_ active: Bool) {
client.setPushToTalk(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(name: String, topic: String) {
let info = ChannelEdit(id: 0, parentId: 0, name: name, topic: topic,
passwordProtected: false, password: nil,
maxUsers: 0, sortOrder: 0, audio: AudioConfig())
client.createChannel(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)
}
}

View File

@@ -0,0 +1,153 @@
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)
}
}
}
}
}

View File

@@ -0,0 +1,49 @@
import SwiftUI
struct ActivityLogView: View {
@Bindable var session: SessionState
private static let timeFormatter: DateFormatter = {
let fmt = DateFormatter()
fmt.dateStyle = .none
fmt.timeStyle = .medium
return fmt
}()
var body: some View {
ScrollViewReader { proxy in
List(session.activityLog) { entry in
HStack(alignment: .top, spacing: 8) {
Text(Self.timeFormatter.string(from: entry.timestamp))
.font(.caption2)
.foregroundStyle(.secondary)
.monospacedDigit()
.frame(width: 64, alignment: .leading)
Text(entry.text)
.font(.caption)
}
.id(entry.id)
.listRowSeparator(.hidden)
.accessibilityElement(children: .combine)
.accessibilityLabel("\(Self.timeFormatter.string(from: entry.timestamp)): \(entry.text)")
}
.listStyle(.plain)
.onChange(of: session.activityLog.count) { _, _ in
if let last = session.activityLog.last {
proxy.scrollTo(last.id, anchor: .bottom)
}
}
}
.navigationTitle("Activity")
.navigationBarTitleDisplayMode(.inline)
.overlay {
if session.activityLog.isEmpty {
ContentUnavailableView(
"No Activity",
systemImage: "bell.slash",
description: Text("Events will appear here as they happen.")
)
}
}
}
}

View File

@@ -0,0 +1,94 @@
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 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 == .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
}
}
}
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
if var s = editing {
s.host = trimmedHost
s.port = portNum
s.authMode = authMode
s.savedUsername = authMode == .password ? username : ""
appState.updateServer(s, password: pw)
} else {
let s = SavedServer(host: trimmedHost, port: portNum,
authMode: authMode,
savedUsername: authMode == .password ? username : "")
appState.addServer(s, password: pw)
}
dismiss()
}
}

View File

@@ -0,0 +1,58 @@
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"
}
}

View File

@@ -0,0 +1,38 @@
import SwiftUI
struct ChannelEditView: View {
let channelId: UInt32?
@Bindable var session: SessionState
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var topic = ""
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")
}
}
.navigationTitle(channelId == nil ? "New Channel" : "Edit Channel")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
session.createChannel(name: name, topic: topic)
dismiss()
}
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
}
}
}

View File

@@ -0,0 +1,131 @@
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 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")
}
}
}
}
.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)
}
.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" : "")")
}
}

View File

@@ -0,0 +1,94 @@
import SwiftUI
import VoiceCatCore
struct ChatView: View {
@Bindable var session: SessionState
@State private var composeText = ""
@State private var scope: VoiceCatTextScope = .channel
@State private var privateTargetId: UInt32 = 0
var body: some View {
VStack(spacing: 0) {
// Message list
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(session.messages) { msg in
ChatBubble(message: msg)
.id(msg.id)
}
}
.padding()
}
.onChange(of: session.messages.count) { _, _ in
if let last = session.messages.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)
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)")
}
}

View File

@@ -0,0 +1,72 @@
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 {
ChannelTreeView(session: session)
.tabItem {
Label("Channels", systemImage: "list.bullet.indent")
}
ChatView(session: session)
.tabItem {
Label("Chat", systemImage: "message")
}
ActivityLogView(session: session)
.tabItem {
Label("Activity", systemImage: "bell")
}
SettingsView(session: session)
.tabItem {
Label("Settings", systemImage: "gear")
}
}
.overlay(alignment: .bottom) {
VoiceControlsView(session: session)
.padding(.bottom, 56) // above tab bar
}
}
}
// 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")
}
}
}

View File

@@ -0,0 +1,48 @@
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
}
}
}

View File

@@ -0,0 +1,65 @@
import SwiftUI
struct PasswordPromptView: View {
@Environment(AppState.self) private var appState
@Environment(\.dismiss) private var dismiss
@State private var username = ""
@State private var password = ""
var body: some View {
NavigationStack {
Form {
Section {
if let server = appState.connectingServer {
Text("Connecting to \(server.displayString)")
.font(.caption)
.foregroundStyle(.secondary)
}
if appState.connectingServer?.authMode == .password {
TextField("Username", text: $username)
.textContentType(.username)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.accessibilityLabel("Username")
}
SecureField("Password", text: $password)
.textContentType(.password)
.accessibilityLabel("Password")
}
if !appState.connectStatus.isEmpty && appState.connectStatus.lowercased().contains("failed") {
Section {
Text(appState.connectStatus)
.foregroundStyle(.red)
.accessibilityLabel("Error: \(appState.connectStatus)")
}
}
}
.navigationTitle("Sign In")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
appState.cancelConnect()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Connect") {
dismiss()
let uname = appState.connectingServer?.savedUsername.isEmpty == false
? appState.connectingServer!.savedUsername
: username
appState.authenticateUser(username: uname, password: password)
}
.disabled(password.isEmpty)
}
}
}
.onAppear {
username = appState.connectingServer?.savedUsername ?? ""
}
.interactiveDismissDisabled()
}
}

View File

@@ -0,0 +1,71 @@
import SwiftUI
import VoiceCatCore
struct PerUserTuningView: View {
let user: User
@Bindable var session: SessionState
@Environment(\.dismiss) private var dismiss
@State private var gain: Float = 1.0
@State private var muted = false
@State private var noiseReduction = false
private var streamsForUser: [StreamSummary] {
session.client.listUserStreams(user.id)
}
var body: some View {
NavigationStack {
Form {
Section("Volume") {
HStack {
Text("Gain")
Slider(value: $gain, in: 0...2, step: 0.05) { _ in
applyToAllStreams()
}
.accessibilityLabel("Volume gain for \(user.nickname)")
Text(String(format: "%.0f%%", gain * 100))
.monospacedDigit()
.frame(width: 44, alignment: .trailing)
}
Toggle("Mute", isOn: $muted)
.onChange(of: muted) { _, _ in applyToAllStreams() }
.accessibilityLabel("Mute \(user.nickname)")
}
Section("Audio Processing") {
Toggle("Noise Reduction", isOn: $noiseReduction)
.onChange(of: noiseReduction) { _, _ in applyToAllStreams() }
.accessibilityLabel("Noise reduction for \(user.nickname)")
}
}
.navigationTitle(user.nickname)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
}
.onAppear {
// Load from first stream if available
let streams = streamsForUser
if let first = streams.first {
let (_, state) = session.client.getRemoteStream(userId: user.id, streamId: first.id)
if let s = state {
gain = s.gain
muted = s.muted
noiseReduction = s.noiseReduction
}
}
}
}
private func applyToAllStreams() {
for stream in streamsForUser {
session.client.setRemoteStream(
userId: user.id, streamId: stream.id,
gain: gain, muted: muted, noiseReduction: noiseReduction)
}
}
}

View File

@@ -0,0 +1,63 @@
import SwiftUI
import VoiceCatCore
struct PermissionsView: View {
let user: User
@Bindable var session: SessionState
@Environment(\.dismiss) private var dismiss
@State private var canCreateTempChannel = false
@State private var canKick = false
@State private var canBan = false
@State private var canMoveUsers = false
@State private var canAdminAccounts = false
@State private var isAdmin = false
var body: some View {
NavigationStack {
Form {
Section("Permissions for \(user.nickname)") {
Toggle("Create Temp Channels", isOn: $canCreateTempChannel)
.accessibilityLabel("Can create temporary channels")
Toggle("Kick Users", isOn: $canKick)
.accessibilityLabel("Can kick users")
Toggle("Ban Users", isOn: $canBan)
.accessibilityLabel("Can ban users")
Toggle("Move Users", isOn: $canMoveUsers)
.accessibilityLabel("Can move users between channels")
Toggle("Manage Accounts", isOn: $canAdminAccounts)
.accessibilityLabel("Can manage server accounts")
}
Section {
Toggle("Administrator", isOn: $isAdmin)
.foregroundStyle(isAdmin ? .orange : .primary)
.accessibilityLabel("Full administrator access")
} footer: {
Text("Administrators bypass all permission checks.")
.font(.caption)
}
}
.navigationTitle("Permissions")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
let perms = Permissions(
canCreateTempChannel: canCreateTempChannel,
canKick: canKick,
canBan: canBan,
canMoveUsers: canMoveUsers,
canAdminAccounts: canAdminAccounts,
isAdmin: isAdmin)
session.setPermissions(user.id, perms: perms)
dismiss()
}
}
}
}
}
}

View File

@@ -0,0 +1,75 @@
import SwiftUI
import VoiceCatCore
struct ServerIdentityView: View {
@Environment(AppState.self) private var appState
@Environment(\.dismiss) private var dismiss
let identity: PendingIdentity
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if identity.tofuStatus == .mismatch {
Label("Server Identity Mismatch", systemImage: "exclamationmark.triangle.fill")
.font(.headline)
.foregroundStyle(.red)
.accessibilityLabel("Warning: server identity mismatch")
Text("The server's identity has changed since your last connection. This may indicate a man-in-the-middle attack, or that the server was reinstalled. Do NOT accept unless you know why the identity changed.")
.foregroundStyle(.primary)
} else {
Label("New Server Identity", systemImage: "lock.badge.questionmark")
.font(.headline)
.accessibilityLabel("New server identity")
Text("This is the first time you are connecting to this server. Verify the fingerprint below with the server administrator before accepting.")
.foregroundStyle(.primary)
}
Divider()
VStack(alignment: .leading, spacing: 4) {
Text("Server fingerprint")
.font(.caption)
.foregroundStyle(.secondary)
Text(identity.displayText.isEmpty ? "(not available)" : identity.displayText)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.accessibilityLabel("Server fingerprint: \(identity.displayText)")
}
.padding()
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 8))
Spacer(minLength: 24)
VStack(spacing: 12) {
Button {
dismiss()
appState.confirmServerIdentity(accept: true)
} label: {
Text("Accept")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(identity.tofuStatus == .mismatch ? .orange : .blue)
.accessibilityLabel("Accept server identity and continue")
Button(role: .destructive) {
dismiss()
appState.confirmServerIdentity(accept: false)
} label: {
Text("Reject — Disconnect")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.accessibilityLabel("Reject server identity and disconnect")
}
}
.padding()
}
.navigationTitle("Server Identity")
.navigationBarTitleDisplayMode(.inline)
}
.interactiveDismissDisabled()
}
}

View File

@@ -0,0 +1,120 @@
import SwiftUI
struct ServerListView: View {
@Environment(AppState.self) private var appState
@State private var serverToDelete: SavedServer?
var body: some View {
@Bindable var state = appState
NavigationStack {
Group {
if appState.servers.isEmpty {
ContentUnavailableView(
"No Servers",
systemImage: "server.rack",
description: Text("Tap + to add a server.")
)
} else {
List {
ForEach(appState.servers) { server in
Button {
appState.connectTo(server)
} label: {
ServerRowView(server: server)
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
serverToDelete = server
} label: {
Label("Delete", systemImage: "trash")
}
Button {
appState.editingServer = server
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.blue)
}
}
}
}
}
.navigationTitle("Servers")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
appState.showAddServer = true
} label: {
Image(systemName: "plus")
}
.accessibilityLabel("Add server")
}
}
.sheet(isPresented: $state.showAddServer) {
AddServerView(editing: nil)
}
.sheet(item: $state.editingServer) { server in
AddServerView(editing: server)
}
.sheet(item: $state.pendingIdentity) { identity in
ServerIdentityView(identity: identity)
}
.sheet(isPresented: $state.showPasswordPrompt) {
PasswordPromptView()
}
.overlay {
if appState.isConnecting {
ConnectingOverlay()
}
}
.confirmationDialog("Delete server?", isPresented: Binding(
get: { serverToDelete != nil },
set: { if !$0 { serverToDelete = nil } }
)) {
if let s = serverToDelete {
Button("Delete \(s.displayString)", role: .destructive) {
appState.removeServer(s)
serverToDelete = nil
}
}
Button("Cancel", role: .cancel) { serverToDelete = nil }
}
}
}
}
private struct ServerRowView: View {
let server: SavedServer
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(server.displayString)
.font(.body)
Text(server.authMode == .guest
? "Guest"
: "Account: \(server.savedUsername)")
.font(.caption)
.foregroundStyle(.secondary)
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(server.displayString), \(server.authMode == .guest ? "guest" : "account \(server.savedUsername)")")
}
}
private struct ConnectingOverlay: View {
@Environment(AppState.self) private var appState
var body: some View {
ZStack {
Color.black.opacity(0.3).ignoresSafeArea()
VStack(spacing: 16) {
ProgressView()
Text(appState.connectStatus)
.foregroundStyle(.white)
Button("Cancel") { appState.cancelConnect() }
.buttonStyle(.bordered)
.tint(.white)
}
.padding(24)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
}
}
}

View File

@@ -0,0 +1,94 @@
import SwiftUI
import VoiceCatCore
struct SettingsView: View {
@Environment(AppState.self) private var appState
@Bindable var session: SessionState
var body: some View {
NavigationStack {
Form {
// Voice input section
Section("Voice Input") {
Picker("Input Mode", selection: Binding(
get: { session.voiceState.inputMode },
set: { session.setInputMode($0) }
)) {
Text("Voice Activation").tag(VoiceCatInputMode.voiceActivation)
Text("Push to Talk").tag(VoiceCatInputMode.pushToTalk)
Text("Always On").tag(VoiceCatInputMode.alwaysOn)
}
.accessibilityLabel("Voice input mode")
if session.voiceState.inputMode == .voiceActivation {
VStack(alignment: .leading, spacing: 4) {
Text("VAD Threshold: \(String(format: "%.3f", session.voiceState.vadThreshold))")
.font(.caption)
Slider(
value: Binding(
get: { Double(session.voiceState.vadThreshold) },
set: { session.setVadThreshold(Float($0)) }
),
in: 0.001...0.1, step: 0.001
)
.accessibilityLabel("Voice activation threshold")
}
}
}
// Audio device section
if !session.devices.isEmpty {
Section("Input Device") {
Picker("Microphone", selection: Binding(
get: { session.voiceState.currentDeviceId ?? "" },
set: { id in
session.voiceState.currentDeviceId = id.isEmpty ? nil : id
if session.voiceState.localStreamId != 0 {
session.client.setInputDevice(
streamId: session.voiceState.localStreamId,
deviceId: id.isEmpty ? nil : id)
}
}
)) {
Text("Default").tag("")
ForEach(session.devices) { dev in
Text(dev.name).tag(dev.id)
}
}
.accessibilityLabel("Microphone selection")
}
}
// Admin section
if session.permissions.canAdminAccounts || session.permissions.isAdmin {
Section("Administration") {
NavigationLink("Manage Accounts") {
AccountsView(session: session)
}
.accessibilityLabel("Manage server accounts")
}
}
// Server info
Section("Server") {
Button(role: .destructive) {
session.stopMicStream()
appState.disconnect()
} label: {
Label("Disconnect", systemImage: "phone.down")
.foregroundStyle(.red)
}
.accessibilityLabel("Disconnect from server")
}
Section("About") {
Text(VoiceCatClient.versionString)
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityLabel("Version: \(VoiceCatClient.versionString)")
}
}
.navigationTitle("Settings")
}
}
}

View File

@@ -0,0 +1,144 @@
import SwiftUI
import VoiceCatCore
struct UserListView: View {
@Bindable var session: SessionState
@State private var selectedUser: User?
@State private var showPerUserTuning = false
@State private var showBanUser = false
@State private var showMoveUser = false
@State private var showPermissions = false
var body: some View {
let channelUsers = session.currentChannelId == 0
? session.users
: session.users.filter { $0.channelId == session.currentChannelId }
List(channelUsers) { user in
UserRowView(user: user, isSelf: user.id == session.selfUserId)
.contextMenu {
userContextMenu(user: user)
}
}
.listStyle(.plain)
.overlay {
if channelUsers.isEmpty {
ContentUnavailableView(
"No Users",
systemImage: "person.slash",
description: Text(session.currentChannelId == 0 ? "Join a channel to see users." : "No one else here yet.")
)
}
}
.sheet(item: $selectedUser) { user in
if showPerUserTuning {
PerUserTuningView(user: user, session: session)
} else if showBanUser {
BanUserView(user: user, session: session)
} else if showMoveUser {
MoveUserView(user: user, session: session)
} else if showPermissions {
PermissionsView(user: user, session: session)
}
}
}
@ViewBuilder
private func userContextMenu(user: User) -> some View {
if user.id != session.selfUserId {
Button {
selectedUser = user
showPerUserTuning = true
showBanUser = false; showMoveUser = false; showPermissions = false
} label: {
Label("Volume / NR", systemImage: "speaker.wave.2")
}
if session.permissions.canKick || session.permissions.isAdmin {
Divider()
Button {
session.kickUser(user.id, reason: "")
} label: {
Label("Kick", systemImage: "person.fill.xmark")
}
if session.permissions.canBan || session.permissions.isAdmin {
Button(role: .destructive) {
selectedUser = user
showBanUser = true
showPerUserTuning = false; showMoveUser = false; showPermissions = false
} label: {
Label("Ban…", systemImage: "nosign")
}
}
}
if session.permissions.canMoveUsers || session.permissions.isAdmin {
Button {
selectedUser = user
showMoveUser = true
showPerUserTuning = false; showBanUser = false; showPermissions = false
} label: {
Label("Move to channel…", systemImage: "arrow.right.circle")
}
}
if session.permissions.isAdmin {
Divider()
let muted = user.serverMuted
Button {
session.setServerMute(user.id, muted: !muted, deafened: user.serverDeafened)
} label: {
Label(muted ? "Unmute" : "Server Mute", systemImage: muted ? "mic" : "mic.slash")
}
Button {
selectedUser = user
showPermissions = true
showPerUserTuning = false; showBanUser = false; showMoveUser = false
} label: {
Label("Permissions…", systemImage: "lock.shield")
}
}
}
}
}
private struct UserRowView: View {
let user: User
let isSelf: Bool
var body: some View {
HStack(spacing: 10) {
Image(systemName: user.selfMicMuted || user.serverMuted ? "mic.slash.fill" : "mic.fill")
.foregroundStyle(user.selfMicMuted || user.serverMuted ? .red : .green)
.imageScale(.small)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 4) {
Text(user.nickname)
.fontWeight(isSelf ? .semibold : .regular)
if isSelf {
Text("(you)")
.font(.caption2)
.foregroundStyle(.secondary)
}
if user.isGuest {
Text("guest")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
if user.serverMuted || user.serverDeafened {
Text(user.serverDeafened ? "server deafened" : "server muted")
.font(.caption2)
.foregroundStyle(.orange)
}
}
Spacer()
if user.selfDeafened {
Image(systemName: "headphones.slash")
.imageScale(.small)
.foregroundStyle(.secondary)
.accessibilityHidden(true)
}
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(user.nickname)\(isSelf ? ", you" : "")\(user.isGuest ? ", guest" : "")\(user.selfMicMuted ? ", muted" : "")\(user.serverMuted ? ", server muted" : "")")
}
}

View File

@@ -0,0 +1,130 @@
import SwiftUI
import VoiceCatCore
struct VoiceControlsView: View {
@Bindable var session: SessionState
var body: some View {
HStack(spacing: 20) {
// Mic toggle / PTT button
if session.voiceState.inputMode == .pushToTalk {
PTTButton(session: session)
} else {
Button {
if session.voiceState.micActive {
session.stopMicStream()
} else {
session.startMicStream()
}
} label: {
Image(systemName: session.voiceState.micActive ? "mic.fill" : "mic.slash.fill")
.font(.title2)
.frame(width: 44, height: 44)
.background(session.voiceState.micActive ? Color.green : Color(.systemGray4), in: Circle())
.foregroundStyle(session.voiceState.micActive ? .white : .primary)
}
.disabled(session.currentChannelId == 0)
.accessibilityLabel(session.voiceState.micActive ? "Stop microphone" : "Start microphone")
}
// Level meter
LevelMeterView(level: session.voiceState.level)
.frame(width: 80, height: 8)
.accessibilityHidden(true)
Spacer()
// Self mute
Button {
session.setMute(!session.voiceState.selfMuted, deafened: session.voiceState.selfDeafened)
} label: {
Image(systemName: session.voiceState.selfMuted ? "mic.slash" : "mic")
.font(.title3)
.foregroundStyle(session.voiceState.selfMuted ? .red : .primary)
}
.accessibilityLabel(session.voiceState.selfMuted ? "Unmute microphone" : "Mute microphone")
// Self deafen
Button {
session.setMute(session.voiceState.selfMuted, deafened: !session.voiceState.selfDeafened)
} label: {
Image(systemName: session.voiceState.selfDeafened ? "headphones.slash" : "headphones")
.font(.title3)
.foregroundStyle(session.voiceState.selfDeafened ? .red : .primary)
}
.accessibilityLabel(session.voiceState.selfDeafened ? "Undeafen" : "Deafen")
// Disconnect
Button(role: .destructive) {
session.stopMicStream()
session.client.disconnect()
} label: {
Image(systemName: "phone.down.fill")
.font(.title3)
.foregroundStyle(.red)
}
.accessibilityLabel("Disconnect from server")
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.bar)
}
}
// MARK: - PTT Button (DragGesture instead of NSEvent on iOS)
private struct PTTButton: View {
@Bindable var session: SessionState
@GestureState private var isPressing = false
var body: some View {
Circle()
.fill(isPressing ? Color.blue : Color(.systemGray4))
.frame(width: 44, height: 44)
.overlay {
Image(systemName: "mic.fill")
.foregroundStyle(isPressing ? .white : .primary)
}
.gesture(
DragGesture(minimumDistance: 0)
.updating($isPressing) { _, state, _ in state = true }
.onChanged { _ in
if !isPressing { return }
if !session.voiceState.micActive { session.startMicStream() }
session.setPushToTalk(true)
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
}
.onEnded { _ in
session.setPushToTalk(false)
session.stopMicStream()
}
)
.accessibilityLabel("Push to talk, hold to transmit")
.accessibilityAddTraits(.isButton)
}
}
// MARK: - Level Meter
private struct LevelMeterView: View {
let level: Float
var body: some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 4)
.fill(Color(.systemGray5))
RoundedRectangle(cornerRadius: 4)
.fill(levelColor)
.frame(width: geo.size.width * CGFloat(min(level * 10, 1.0)))
.animation(.linear(duration: 0.05), value: level)
}
}
}
private var levelColor: Color {
if level > 0.15 { return .orange }
if level > 0.05 { return .green }
return .green.opacity(0.5)
}
}

View File

@@ -0,0 +1,10 @@
<?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>com.apple.security.application-groups</key>
<array>
<string>group.cat.voice.VoiceCat</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,18 @@
import SwiftUI
import VoiceCatCore
@main
struct VoiceCatiOSApp: App {
@State private var appState = AppState()
init() {
AudioSessionManager.shared.configure()
}
var body: some Scene {
WindowGroup {
MainView()
.environment(appState)
}
}
}

View File

@@ -73,7 +73,7 @@ echo "[build-xcframework] VCPKG_ROOT=$VCPKG_ROOT"
# ── Build each requested slice ──────────────────────────────────────────────────── # ── Build each requested slice ────────────────────────────────────────────────────
build_slice() { build_slice() {
local preset="$1" slice_name="$2" local preset="$1" slice_name="$2" vcpkg_triplet="$3"
echo "[build-xcframework] === $slice_name: configure + build ($preset) ===" echo "[build-xcframework] === $slice_name: configure + build ($preset) ==="
if $DO_CONFIGURE; then if $DO_CONFIGURE; then
cmake --preset "$preset" cmake --preset "$preset"
@@ -97,25 +97,29 @@ build_slice() {
# Without this, the final executable (test runner / app) would get undefined-symbol # Without this, the final executable (test runner / app) would get undefined-symbol
# linker errors for protobuf/mbedtls/sodium/opus/… symbols that libvoicecat.a references # linker errors for protobuf/mbedtls/sodium/opus/… symbols that libvoicecat.a references
# but doesn't contain. See clients/apple/README.md "Fat static library" section. # but doesn't contain. See clients/apple/README.md "Fat static library" section.
local vcpkg_libs_dir="$REPO_ROOT/build/$preset/vcpkg_installed" #
# IMPORTANT: search only the TARGET triplet's lib dir, not all of vcpkg_installed/.
# Cross-compile builds install a HOST triplet directory alongside the target (e.g.
# arm64-osx/ next to arm64-ios/) — that host directory contains macOS binaries needed
# to build protoc/etc at configure time. Merging those macOS .a files into the iOS
# fat library triggers xcodebuild's "binaries with multiple platforms" rejection.
local vcpkg_target_lib_dir="$REPO_ROOT/build/$preset/vcpkg_installed/$vcpkg_triplet/lib"
local fat_lib="$REPO_ROOT/build/$preset/lib/libvoicecat-fat.a" local fat_lib="$REPO_ROOT/build/$preset/lib/libvoicecat-fat.a"
echo "[build-xcframework] $slice_name: merging vcpkg deps into fat static lib" echo "[build-xcframework] $slice_name: merging vcpkg deps into fat static lib (triplet: $vcpkg_triplet)"
# Collect all .a files (libvoicecat.a + every vcpkg .a). libtool -static concatenates # Collect all .a files (libvoicecat.a + every vcpkg target .a). libtool -static concatenates
# object files from all input archives; duplicate-object warnings are benign (the linker # object files from all input archives; duplicate-object warnings are benign (the linker
# resolves duplicates at final link time). "no symbols" warnings are for empty AVX2/AVX512 # resolves duplicates at final link time). "no symbols" warnings are for empty AVX2/AVX512
# objects on arm64 — also benign. # objects on arm64 — also benign.
local all_libs=( "$lib" ) local all_libs=( "$lib" )
# Only release libs (arm64-osx/lib/*.a), NOT debug libs (arm64-osx/debug/lib/*.a) — while IFS= read -r f; do all_libs+=( "$f" ); done < <(find "$vcpkg_target_lib_dir" -name '*.a' -not -name 'libvoicecat*' | sort)
# vcpkg installs both; the debug libs are ~10x larger and not needed for a Release build.
while IFS= read -r f; do all_libs+=( "$f" ); done < <(find "$vcpkg_libs_dir" -name '*.a' -not -name 'libvoicecat*' -not -path '*/debug/*' | sort)
libtool -static -o "$fat_lib" "${all_libs[@]}" 2>&1 | grep -v 'has no symbols' || true libtool -static -o "$fat_lib" "${all_libs[@]}" 2>&1 | grep -v 'has no symbols' || true
echo "[build-xcframework] $slice_name -> $fat_lib ($(stat -f%z "$fat_lib") bytes, fat)" echo "[build-xcframework] $slice_name -> $fat_lib ($(stat -f%z "$fat_lib") bytes, fat)"
} }
args=() args=()
if $BUILD_MACOS; then build_slice apple-dev "macOS (arm64-osx)"; args+=( -library "$REPO_ROOT/build/apple-dev/lib/libvoicecat-fat.a" -headers "$APPLE_DIR/.staged-headers/macos" ); fi if $BUILD_MACOS; then build_slice apple-dev "macOS (arm64-osx)" "arm64-osx"; args+=( -library "$REPO_ROOT/build/apple-dev/lib/libvoicecat-fat.a" -headers "$APPLE_DIR/.staged-headers/macos" ); fi
if $BUILD_IOS_DEVICE; then build_slice apple-ios "iOS device (arm64-ios)"; args+=( -library "$REPO_ROOT/build/apple-ios/lib/libvoicecat-fat.a" -headers "$APPLE_DIR/.staged-headers/ios" ); fi if $BUILD_IOS_DEVICE; then build_slice apple-ios "iOS device (arm64-ios)" "arm64-ios"; args+=( -library "$REPO_ROOT/build/apple-ios/lib/libvoicecat-fat.a" -headers "$APPLE_DIR/.staged-headers/ios" ); fi
if $BUILD_IOS_SIM; then build_slice apple-ios-sim "iOS sim (arm64-ios-sim)"; args+=( -library "$REPO_ROOT/build/apple-ios-sim/lib/libvoicecat-fat.a" -headers "$APPLE_DIR/.staged-headers/ios-sim" ); fi if $BUILD_IOS_SIM; then build_slice apple-ios-sim "iOS sim (arm64-ios-sim)" "arm64-ios-simulator"; args+=( -library "$REPO_ROOT/build/apple-ios-sim/lib/libvoicecat-fat.a" -headers "$APPLE_DIR/.staged-headers/ios-sim" ); fi
# ── Stage headers + module map ─────────────────────────────────────────────────── # ── Stage headers + module map ───────────────────────────────────────────────────
# Each slice gets its own headers dir (xcodebuild -create-xcframework requires a -headers # Each slice gets its own headers dir (xcodebuild -create-xcframework requires a -headers

View File

@@ -0,0 +1,8 @@
# Chainload toolchain for iOS device cross-compilation (arm64-ios vcpkg triplet).
# Loaded by vcpkg when building all dependencies for the arm64-ios target, ensuring
# every dep is compiled against the iPhone OS SDK (not the macOS SDK).
set(CMAKE_SYSTEM_NAME iOS)
set(CMAKE_SYSTEM_PROCESSOR arm64)
set(CMAKE_OSX_ARCHITECTURES arm64)
set(CMAKE_OSX_SYSROOT iphoneos)
set(CMAKE_OSX_DEPLOYMENT_TARGET 17.0)

View File

@@ -0,0 +1,8 @@
# Chainload toolchain for iOS simulator cross-compilation (arm64-ios-simulator vcpkg triplet).
# Loaded by vcpkg when building all dependencies for the arm64-ios-simulator target, ensuring
# every dep is compiled against the iphonesimulator SDK (platform tag IOSSIMULATOR, not IOS).
set(CMAKE_SYSTEM_NAME iOS)
set(CMAKE_SYSTEM_PROCESSOR arm64)
set(CMAKE_OSX_ARCHITECTURES arm64)
set(CMAKE_OSX_SYSROOT iphonesimulator)
set(CMAKE_OSX_DEPLOYMENT_TARGET 17.0)

View File

@@ -0,0 +1,13 @@
set(VCPKG_TARGET_ARCHITECTURE arm64)
set(VCPKG_CRT_LINKAGE dynamic)
set(VCPKG_LIBRARY_LINKAGE static)
set(VCPKG_CMAKE_SYSTEM_NAME iOS)
# Release-only: debug iOS binaries are never shipped and skipping them halves build time.
set(VCPKG_BUILD_TYPE release)
# Override the autoconf --host triple (same rationale as arm64-ios.cmake — prevents autoconf from
# treating cross-compilation as a native build and trying to run arm64-ios binaries on macOS).
set(VCPKG_MAKE_BUILD_TRIPLET "--host=aarch64-apple-ios17.0-simulator")
# Chainload toolchain: forces every vcpkg dep to compile against the iphonesimulator SDK so all
# objects carry the platform IOSSIMULATOR tag (not IOS or MACOS). Critical for xcodebuild's
# -create-xcframework which rejects fat libs that mix platform tags.
set(VCPKG_CHAINLOAD_TOOLCHAIN_FILE "${CMAKE_CURRENT_LIST_DIR}/../../toolchains/ios-simulator.cmake")

View File

@@ -0,0 +1,14 @@
set(VCPKG_TARGET_ARCHITECTURE arm64)
set(VCPKG_CRT_LINKAGE dynamic)
set(VCPKG_LIBRARY_LINKAGE static)
set(VCPKG_CMAKE_SYSTEM_NAME iOS)
# Release-only: debug iOS binaries are never shipped and skipping them halves build time.
set(VCPKG_BUILD_TYPE release)
# Override the autoconf --host triple. Without this, vcpkg uses arm64-apple-darwin (same as the
# macOS build machine), which makes autoconf think it's a native build and try to run compiled
# programs — those are iOS arm64 binaries and can't execute on macOS. An ios-suffixed triple
# signals cross-compilation and disables all run-time configure checks.
set(VCPKG_MAKE_BUILD_TRIPLET "--host=aarch64-apple-ios17.0")
# Chainload toolchain: forces every vcpkg dep to compile against the iphoneos SDK so all objects
# carry the platform IOS tag (not MACOS). Without this the fat library mixes platforms.
set(VCPKG_CHAINLOAD_TOOLCHAIN_FILE "${CMAKE_CURRENT_LIST_DIR}/../../toolchains/ios-device.cmake")

View File

@@ -75,6 +75,16 @@ if(VOICECAT_USE_VCPKG_DEPS)
target_compile_definitions(voicecat PUBLIC VOICECAT_HAS_OPUS VOICECAT_HAS_AUDIO) target_compile_definitions(voicecat PUBLIC VOICECAT_HAS_OPUS VOICECAT_HAS_AUDIO)
# On iOS, miniaudio's AVFoundation backend includes Objective-C headers (AVFoundation.h →
# Foundation.h). Compiling those as plain C++ fails; setting LANGUAGE OBJCXX for
# audio_engine.cpp (the only file that includes miniaudio.h directly) fixes this.
if(CMAKE_SYSTEM_NAME STREQUAL "iOS")
set_source_files_properties(
${CMAKE_CURRENT_SOURCE_DIR}/src/audio/audio_engine.cpp
PROPERTIES LANGUAGE OBJCXX
)
endif()
if(WIN32) if(WIN32)
# AcceptEx / GetAcceptExSockaddrs live in mswsock; ws2_32 covers the base Winsock API. # AcceptEx / GetAcceptExSockaddrs live in mswsock; ws2_32 covers the base Winsock API.
target_link_libraries(voicecat PRIVATE ws2_32 mswsock) target_link_libraries(voicecat PRIVATE ws2_32 mswsock)

View File

@@ -467,6 +467,13 @@ VC_API void vc_free_account_list(vc_account_list* list);
/* Pull the caller's own permissions (from the last AuthResult). */ /* Pull the caller's own permissions (from the last AuthResult). */
VC_API vc_result vc_get_permissions(vc_client* c, vc_permissions* out); VC_API vc_result vc_get_permissions(vc_client* c, vc_permissions* out);
/* AVAudioSession interruption hooks (iOS M6). Pause/resume miniaudio device I/O for
* AVAudioSession interruptions (phone call, Siri, etc.) and backgrounding. Call
* vc_audio_suspend() when an interruption begins; call vc_audio_resume() after the
* session is re-activated. No-op if the audio engine is not running. */
VC_API vc_result vc_audio_suspend(vc_client* c);
VC_API vc_result vc_audio_resume(vc_client* c);
#if defined(__cplusplus) #if defined(__cplusplus)
} /* extern "C" */ } /* extern "C" */
#endif #endif

View File

@@ -274,6 +274,30 @@ void AudioEngine::stop() {
#endif #endif
} }
bool AudioEngine::suspend() {
#ifdef VOICECAT_HAS_AUDIO
if (!running_.load(std::memory_order_acquire)) return true;
bool ok = true;
if (capture_started_) ok &= (ma_device_stop(&capture_device_) == MA_SUCCESS);
if (playback_started_) ok &= (ma_device_stop(&playback_device_) == MA_SUCCESS);
return ok;
#else
return true;
#endif
}
bool AudioEngine::resume() {
#ifdef VOICECAT_HAS_AUDIO
if (!running_.load(std::memory_order_acquire)) return true;
bool ok = true;
if (capture_started_) ok &= (ma_device_start(&capture_device_) == MA_SUCCESS);
if (playback_started_) ok &= (ma_device_start(&playback_device_) == MA_SUCCESS);
return ok;
#else
return true;
#endif
}
void AudioEngine::inject_capture(int kind, const int16_t* pcm, size_t n) { void AudioEngine::inject_capture(int kind, const int16_t* pcm, size_t n) {
InjectTap* tap; InjectTap* tap;
{ {

View File

@@ -127,6 +127,12 @@ class AudioEngine {
bool running() const { return running_.load(std::memory_order_acquire); } bool running() const { return running_.load(std::memory_order_acquire); }
// Pause/resume miniaudio device I/O (called from vc_audio_suspend/resume for
// AVAudioSession interruptions on iOS). Thread-safe via ma_device_stop/start.
// Returns true on success; false if a device stop/start fails.
bool suspend();
bool resume();
// Enumerate input or output devices (for UI pickers / vc_list_devices). Static: works // Enumerate input or output devices (for UI pickers / vc_list_devices). Static: works
// before any AudioEngine instance is running (device pickers need to populate pre-connect). // before any AudioEngine instance is running (device pickers need to populate pre-connect).
// Inits a throwaway ma_context if VOICECAT_HAS_AUDIO; returns {} otherwise. See DeviceInfo // Inits a throwaway ma_context if VOICECAT_HAS_AUDIO; returns {} otherwise. See DeviceInfo

View File

@@ -1399,6 +1399,14 @@ vc_result vc_client::get_remote_stream(uint32_t user_id, uint32_t stream_id,
return VC_OK; return VC_OK;
} }
vc_result vc_client::audio_suspend() {
return audio_engine_.suspend() ? VC_OK : VC_ERR_AUDIO;
}
vc_result vc_client::audio_resume() {
return audio_engine_.resume() ? VC_OK : VC_ERR_AUDIO;
}
vc_result vc_client::get_stream_audio_config(uint32_t user_id, uint32_t stream_id, vc_result vc_client::get_stream_audio_config(uint32_t user_id, uint32_t stream_id,
vc_audio_config* out) { vc_audio_config* out) {
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED; if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
@@ -1863,5 +1871,7 @@ vc_result vc_client::delete_account(const char*) { return VC_ERR_NOT_IMPLEMENTED
vc_result vc_client::list_accounts() { return VC_ERR_NOT_IMPLEMENTED; } vc_result vc_client::list_accounts() { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::get_account_list(vc_account_list*) { return VC_ERR_NOT_IMPLEMENTED; } vc_result vc_client::get_account_list(vc_account_list*) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::get_permissions(vc_permissions*) { return VC_ERR_NOT_IMPLEMENTED; } vc_result vc_client::get_permissions(vc_permissions*) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::audio_suspend() { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::audio_resume() { return VC_ERR_NOT_IMPLEMENTED; }
#endif // VOICECAT_HAS_NET #endif // VOICECAT_HAS_NET

View File

@@ -57,6 +57,8 @@ struct vc_client {
bool noise_reduction); bool noise_reduction);
vc_result get_remote_stream(uint32_t user_id, uint32_t stream_id, vc_result get_remote_stream(uint32_t user_id, uint32_t stream_id,
vc_remote_stream_state* out); vc_remote_stream_state* out);
vc_result audio_suspend();
vc_result audio_resume();
vc_result send_text(vc_text_scope scope, uint32_t target_id, const char* utf8); vc_result send_text(vc_text_scope scope, uint32_t target_id, const char* utf8);

View File

@@ -295,4 +295,14 @@ vc_result vc_get_permissions(vc_client* c, vc_permissions* out) {
return c->get_permissions(out); return c->get_permissions(out);
} }
vc_result vc_audio_suspend(vc_client* c) {
if (c == nullptr) return VC_ERR_INVALID_ARG;
return c->audio_suspend();
}
vc_result vc_audio_resume(vc_client* c) {
if (c == nullptr) return VC_ERR_INVALID_ARG;
return c->audio_resume();
}
} // extern "C" } // extern "C"