diff --git a/CLAUDE.md b/CLAUDE.md index f37f4ab..6620cea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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, > server-mute, channel CRUD, in-app account management, disconnect/keepalive/reaper. Windows -> WinForms C# client is shipped (M4). **macOS AppKit client shipped** — `VoiceCatMac.xcodeproj` -> at `clients/apple/macOS/`. `ctest --preset dev` green — 21/21 tests. -> iOS SwiftUI client is next. See [`PROGRESS.md`](PROGRESS.md). +> WinForms C# client shipped (M4). **macOS AppKit client shipped** — `VoiceCatMac.xcodeproj` +> at `clients/apple/macOS/`. **iOS SwiftUI client shipped** — `VoiceCatiOS.xcodeproj` at +> `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) + UDP (media), no WebRTC, encrypted by default. A shared C++ core (`libvoicecat`) drives diff --git a/CMakeLists.txt b/CMakeLists.txt index 79b3c5f..d42536a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,6 +5,13 @@ project(voicecat DESCRIPTION "Self-hosted native voice & text chat (see docs/)" 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 ─────────────────────────────────────────────────────────────────── # 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 diff --git a/CMakePresets.json b/CMakePresets.json index 6b480ee..dede54c 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -90,15 +90,19 @@ }, { "name": "apple-ios", - "displayName": "Apple iOS device (XCFramework slice, scaffolding)", - "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.", + "displayName": "Apple iOS device (XCFramework slice)", + "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", "binaryDir": "${sourceDir}/build/apple-ios", "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", "CMAKE_SYSTEM_NAME": "iOS", "CMAKE_SYSTEM_PROCESSOR": "arm64", + "CMAKE_OSX_ARCHITECTURES": "arm64", + "CMAKE_OSX_SYSROOT": "iphoneos", + "CMAKE_OSX_DEPLOYMENT_TARGET": "17.0", "VCPKG_TARGET_TRIPLET": "arm64-ios", + "VCPKG_OVERLAY_TRIPLETS": "${sourceDir}/cmake/vcpkg-overlays/triplets", "VOICECAT_BUILD_SERVER": "OFF", "VOICECAT_BUILD_TOOLS": "OFF", "VOICECAT_BUILD_TESTS": "OFF" @@ -106,15 +110,19 @@ }, { "name": "apple-ios-sim", - "displayName": "Apple iOS simulator (XCFramework slice, scaffolding)", - "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.", + "displayName": "Apple iOS simulator (XCFramework slice)", + "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", "binaryDir": "${sourceDir}/build/apple-ios-sim", "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", "CMAKE_SYSTEM_NAME": "iOS", "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_TOOLS": "OFF", "VOICECAT_BUILD_TESTS": "OFF" diff --git a/PROGRESS.md b/PROGRESS.md index b0248f8..782f9b7 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -10,6 +10,30 @@ up instantly. Newest status at the top. ## ▶ 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 "shipped" claim in the entry below was incorrect — `xcodebuild` actually failed on a fresh 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] **M2 — Voice, single stream** ✓ 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, …) --- diff --git a/clients/apple/Package.swift b/clients/apple/Package.swift index 6402795..e197cd0 100644 --- a/clients/apple/Package.swift +++ b/clients/apple/Package.swift @@ -16,11 +16,12 @@ import PackageDescription let package = Package( name: "VoiceCatCore", - // macOS 14 (Sonoma) is the AppKit client's deployment target. iOS is deferred; when the - // iOS app lands, add `.iOS(.v17)` here and the apple-ios / apple-ios-sim slices to the - // XCFramework (scripts/build-xcframework.sh --all). + // macOS 14 (Sonoma) is the AppKit client's deployment target. iOS 17 is the SwiftUI client + // target (clients/apple/iOS/). Run `scripts/build-xcframework.sh --all` to produce all + // three slices: macos-arm64, ios-arm64, ios-arm64-simulator. platforms: [ .macOS(.v14), + .iOS(.v17), ], products: [ .library(name: "VoiceCatCore", targets: ["VoiceCatCore"]), diff --git a/clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift b/clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift index cdd512e..9a91124 100644 --- a/clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift +++ b/clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift @@ -322,6 +322,20 @@ public final class VoiceCatClient { 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) @discardableResult diff --git a/clients/apple/iOS/VoiceCatiOS.xcodeproj/project.pbxproj b/clients/apple/iOS/VoiceCatiOS.xcodeproj/project.pbxproj new file mode 100644 index 0000000..bf81bb7 --- /dev/null +++ b/clients/apple/iOS/VoiceCatiOS.xcodeproj/project.pbxproj @@ -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 = ""; }; + BBBB00000000000000000016 /* VoiceCatiOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = VoiceCatiOS.entitlements; sourceTree = ""; }; + BBBB00000000000000000017 /* VoiceCatiOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceCatiOSApp.swift; sourceTree = ""; }; + BBBB00000000000000000018 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; + BBBB00000000000000000019 /* SessionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionState.swift; sourceTree = ""; }; + BBBB0000000000000000001A /* AudioSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionManager.swift; sourceTree = ""; }; + BBBB0000000000000000001B /* ServerListStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListStore.swift; sourceTree = ""; }; + BBBB0000000000000000001C /* SavedServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedServer.swift; sourceTree = ""; }; + BBBB0000000000000000001D /* BroadcastCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastCredentials.swift; sourceTree = ""; }; + BBBB0000000000000000001E /* ServerListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListView.swift; sourceTree = ""; }; + BBBB0000000000000000001F /* AddServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddServerView.swift; sourceTree = ""; }; + BBBB00000000000000000020 /* ServerIdentityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerIdentityView.swift; sourceTree = ""; }; + BBBB00000000000000000021 /* PasswordPromptView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordPromptView.swift; sourceTree = ""; }; + BBBB00000000000000000022 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = ""; }; + BBBB00000000000000000023 /* ChannelTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelTreeView.swift; sourceTree = ""; }; + BBBB00000000000000000024 /* UserListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListView.swift; sourceTree = ""; }; + BBBB00000000000000000025 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; + BBBB00000000000000000026 /* ActivityLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityLogView.swift; sourceTree = ""; }; + BBBB00000000000000000027 /* VoiceControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceControlsView.swift; sourceTree = ""; }; + BBBB00000000000000000028 /* PerUserTuningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerUserTuningView.swift; sourceTree = ""; }; + BBBB00000000000000000029 /* ChannelEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelEditView.swift; sourceTree = ""; }; + BBBB0000000000000000002A /* BanUserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BanUserView.swift; sourceTree = ""; }; + BBBB0000000000000000002B /* MoveUserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveUserView.swift; sourceTree = ""; }; + BBBB0000000000000000002C /* PermissionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsView.swift; sourceTree = ""; }; + BBBB0000000000000000002D /* AccountsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsView.swift; sourceTree = ""; }; + BBBB0000000000000000002E /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; +/* 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 = ""; + }; + 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 = ""; + }; + 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 = ""; + }; + BBBB00000000000000000007 /* Products */ = { + isa = PBXGroup; + children = ( + BBBB00000000000000000012 /* VoiceCatiOS.app */, + ); + name = Products; + sourceTree = ""; + }; +/* 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 */; +} diff --git a/clients/apple/iOS/VoiceCatiOS.xcodeproj/xcshareddata/xcschemes/VoiceCatiOS.xcscheme b/clients/apple/iOS/VoiceCatiOS.xcodeproj/xcshareddata/xcschemes/VoiceCatiOS.xcscheme new file mode 100644 index 0000000..2d6b689 --- /dev/null +++ b/clients/apple/iOS/VoiceCatiOS.xcodeproj/xcshareddata/xcschemes/VoiceCatiOS.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/clients/apple/iOS/VoiceCatiOS/AppState.swift b/clients/apple/iOS/VoiceCatiOS/AppState.swift new file mode 100644 index 0000000..ab11080 --- /dev/null +++ b/clients/apple/iOS/VoiceCatiOS/AppState.swift @@ -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 + } + } +} diff --git a/clients/apple/iOS/VoiceCatiOS/AudioSessionManager.swift b/clients/apple/iOS/VoiceCatiOS/AudioSessionManager.swift new file mode 100644 index 0000000..15f4e5b --- /dev/null +++ b/clients/apple/iOS/VoiceCatiOS/AudioSessionManager.swift @@ -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") +} diff --git a/clients/apple/iOS/VoiceCatiOS/BroadcastCredentials.swift b/clients/apple/iOS/VoiceCatiOS/BroadcastCredentials.swift new file mode 100644 index 0000000..d6ccfa5 --- /dev/null +++ b/clients/apple/iOS/VoiceCatiOS/BroadcastCredentials.swift @@ -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) + } +} diff --git a/clients/apple/iOS/VoiceCatiOS/Info.plist b/clients/apple/iOS/VoiceCatiOS/Info.plist new file mode 100644 index 0000000..6c723dd --- /dev/null +++ b/clients/apple/iOS/VoiceCatiOS/Info.plist @@ -0,0 +1,47 @@ + + + + + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + VoiceCat + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.0.1 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + NSHumanReadableCopyright + Copyright © 2026 VoiceCat contributors. All rights reserved. + NSMicrophoneUsageDescription + VoiceCat needs microphone access to transmit your voice in channels. + UIBackgroundModes + + audio + + UILaunchStoryboardName + LaunchScreen + UIRequiresFullScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/clients/apple/iOS/VoiceCatiOS/SavedServer.swift b/clients/apple/iOS/VoiceCatiOS/SavedServer.swift new file mode 100644 index 0000000..f0a28c0 --- /dev/null +++ b/clients/apple/iOS/VoiceCatiOS/SavedServer.swift @@ -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)" } +} diff --git a/clients/apple/iOS/VoiceCatiOS/ServerListStore.swift b/clients/apple/iOS/VoiceCatiOS/ServerListStore.swift new file mode 100644 index 0000000..816c974 --- /dev/null +++ b/clients/apple/iOS/VoiceCatiOS/ServerListStore.swift @@ -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) + } +} diff --git a/clients/apple/iOS/VoiceCatiOS/SessionState.swift b/clients/apple/iOS/VoiceCatiOS/SessionState.swift new file mode 100644 index 0000000..be7f772 --- /dev/null +++ b/clients/apple/iOS/VoiceCatiOS/SessionState.swift @@ -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) + } +} diff --git a/clients/apple/iOS/VoiceCatiOS/Views/AccountsView.swift b/clients/apple/iOS/VoiceCatiOS/Views/AccountsView.swift new file mode 100644 index 0000000..e2dc0d2 --- /dev/null +++ b/clients/apple/iOS/VoiceCatiOS/Views/AccountsView.swift @@ -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) + } + } + } + } +} diff --git a/clients/apple/iOS/VoiceCatiOS/Views/ActivityLogView.swift b/clients/apple/iOS/VoiceCatiOS/Views/ActivityLogView.swift new file mode 100644 index 0000000..3793558 --- /dev/null +++ b/clients/apple/iOS/VoiceCatiOS/Views/ActivityLogView.swift @@ -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.") + ) + } + } + } +} diff --git a/clients/apple/iOS/VoiceCatiOS/Views/AddServerView.swift b/clients/apple/iOS/VoiceCatiOS/Views/AddServerView.swift new file mode 100644 index 0000000..b6ffe2d --- /dev/null +++ b/clients/apple/iOS/VoiceCatiOS/Views/AddServerView.swift @@ -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() + } +} diff --git a/clients/apple/iOS/VoiceCatiOS/Views/BanUserView.swift b/clients/apple/iOS/VoiceCatiOS/Views/BanUserView.swift new file mode 100644 index 0000000..75ea5e4 --- /dev/null +++ b/clients/apple/iOS/VoiceCatiOS/Views/BanUserView.swift @@ -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" + } +} diff --git a/clients/apple/iOS/VoiceCatiOS/Views/ChannelEditView.swift b/clients/apple/iOS/VoiceCatiOS/Views/ChannelEditView.swift new file mode 100644 index 0000000..914d7fd --- /dev/null +++ b/clients/apple/iOS/VoiceCatiOS/Views/ChannelEditView.swift @@ -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) + } + } + } + } +} diff --git a/clients/apple/iOS/VoiceCatiOS/Views/ChannelTreeView.swift b/clients/apple/iOS/VoiceCatiOS/Views/ChannelTreeView.swift new file mode 100644 index 0000000..17d3059 --- /dev/null +++ b/clients/apple/iOS/VoiceCatiOS/Views/ChannelTreeView.swift @@ -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" : "")") + } +} diff --git a/clients/apple/iOS/VoiceCatiOS/Views/ChatView.swift b/clients/apple/iOS/VoiceCatiOS/Views/ChatView.swift new file mode 100644 index 0000000..9332eb2 --- /dev/null +++ b/clients/apple/iOS/VoiceCatiOS/Views/ChatView.swift @@ -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)") + } +} diff --git a/clients/apple/iOS/VoiceCatiOS/Views/MainView.swift b/clients/apple/iOS/VoiceCatiOS/Views/MainView.swift new file mode 100644 index 0000000..2656c22 --- /dev/null +++ b/clients/apple/iOS/VoiceCatiOS/Views/MainView.swift @@ -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") + } + } +} diff --git a/clients/apple/iOS/VoiceCatiOS/Views/MoveUserView.swift b/clients/apple/iOS/VoiceCatiOS/Views/MoveUserView.swift new file mode 100644 index 0000000..ebbf575 --- /dev/null +++ b/clients/apple/iOS/VoiceCatiOS/Views/MoveUserView.swift @@ -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 + } + } +} diff --git a/clients/apple/iOS/VoiceCatiOS/Views/PasswordPromptView.swift b/clients/apple/iOS/VoiceCatiOS/Views/PasswordPromptView.swift new file mode 100644 index 0000000..21be025 --- /dev/null +++ b/clients/apple/iOS/VoiceCatiOS/Views/PasswordPromptView.swift @@ -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() + } +} diff --git a/clients/apple/iOS/VoiceCatiOS/Views/PerUserTuningView.swift b/clients/apple/iOS/VoiceCatiOS/Views/PerUserTuningView.swift new file mode 100644 index 0000000..b520994 --- /dev/null +++ b/clients/apple/iOS/VoiceCatiOS/Views/PerUserTuningView.swift @@ -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) + } + } +} diff --git a/clients/apple/iOS/VoiceCatiOS/Views/PermissionsView.swift b/clients/apple/iOS/VoiceCatiOS/Views/PermissionsView.swift new file mode 100644 index 0000000..64380b4 --- /dev/null +++ b/clients/apple/iOS/VoiceCatiOS/Views/PermissionsView.swift @@ -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() + } + } + } + } + } +} diff --git a/clients/apple/iOS/VoiceCatiOS/Views/ServerIdentityView.swift b/clients/apple/iOS/VoiceCatiOS/Views/ServerIdentityView.swift new file mode 100644 index 0000000..fa91e1a --- /dev/null +++ b/clients/apple/iOS/VoiceCatiOS/Views/ServerIdentityView.swift @@ -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() + } +} diff --git a/clients/apple/iOS/VoiceCatiOS/Views/ServerListView.swift b/clients/apple/iOS/VoiceCatiOS/Views/ServerListView.swift new file mode 100644 index 0000000..8a6cbbd --- /dev/null +++ b/clients/apple/iOS/VoiceCatiOS/Views/ServerListView.swift @@ -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)) + } + } +} diff --git a/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift b/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift new file mode 100644 index 0000000..0eb78f5 --- /dev/null +++ b/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift @@ -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") + } + } +} diff --git a/clients/apple/iOS/VoiceCatiOS/Views/UserListView.swift b/clients/apple/iOS/VoiceCatiOS/Views/UserListView.swift new file mode 100644 index 0000000..cb68154 --- /dev/null +++ b/clients/apple/iOS/VoiceCatiOS/Views/UserListView.swift @@ -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" : "")") + } +} diff --git a/clients/apple/iOS/VoiceCatiOS/Views/VoiceControlsView.swift b/clients/apple/iOS/VoiceCatiOS/Views/VoiceControlsView.swift new file mode 100644 index 0000000..0c242ae --- /dev/null +++ b/clients/apple/iOS/VoiceCatiOS/Views/VoiceControlsView.swift @@ -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) + } +} diff --git a/clients/apple/iOS/VoiceCatiOS/VoiceCatiOS.entitlements b/clients/apple/iOS/VoiceCatiOS/VoiceCatiOS.entitlements new file mode 100644 index 0000000..63c7480 --- /dev/null +++ b/clients/apple/iOS/VoiceCatiOS/VoiceCatiOS.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.cat.voice.VoiceCat + + + diff --git a/clients/apple/iOS/VoiceCatiOS/VoiceCatiOSApp.swift b/clients/apple/iOS/VoiceCatiOS/VoiceCatiOSApp.swift new file mode 100644 index 0000000..e761f2a --- /dev/null +++ b/clients/apple/iOS/VoiceCatiOS/VoiceCatiOSApp.swift @@ -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) + } + } +} diff --git a/clients/apple/scripts/build-xcframework.sh b/clients/apple/scripts/build-xcframework.sh index 28efed2..adf6992 100755 --- a/clients/apple/scripts/build-xcframework.sh +++ b/clients/apple/scripts/build-xcframework.sh @@ -73,7 +73,7 @@ echo "[build-xcframework] VCPKG_ROOT=$VCPKG_ROOT" # ── Build each requested 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) ===" if $DO_CONFIGURE; then cmake --preset "$preset" @@ -97,25 +97,29 @@ build_slice() { # Without this, the final executable (test runner / app) would get undefined-symbol # 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. - 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" - echo "[build-xcframework] $slice_name: merging vcpkg deps into fat static lib" - # Collect all .a files (libvoicecat.a + every vcpkg .a). libtool -static concatenates + echo "[build-xcframework] $slice_name: merging vcpkg deps into fat static lib (triplet: $vcpkg_triplet)" + # 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 # resolves duplicates at final link time). "no symbols" warnings are for empty AVX2/AVX512 # objects on arm64 — also benign. local all_libs=( "$lib" ) - # Only release libs (arm64-osx/lib/*.a), NOT debug libs (arm64-osx/debug/lib/*.a) — - # 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) + while IFS= read -r f; do all_libs+=( "$f" ); done < <(find "$vcpkg_target_lib_dir" -name '*.a' -not -name 'libvoicecat*' | sort) 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)" } 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_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_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_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)" "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)" "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 ─────────────────────────────────────────────────── # Each slice gets its own headers dir (xcodebuild -create-xcframework requires a -headers diff --git a/cmake/toolchains/ios-device.cmake b/cmake/toolchains/ios-device.cmake new file mode 100644 index 0000000..98e8218 --- /dev/null +++ b/cmake/toolchains/ios-device.cmake @@ -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) diff --git a/cmake/toolchains/ios-simulator.cmake b/cmake/toolchains/ios-simulator.cmake new file mode 100644 index 0000000..d4eda77 --- /dev/null +++ b/cmake/toolchains/ios-simulator.cmake @@ -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) diff --git a/cmake/vcpkg-overlays/triplets/arm64-ios-simulator.cmake b/cmake/vcpkg-overlays/triplets/arm64-ios-simulator.cmake new file mode 100644 index 0000000..c404304 --- /dev/null +++ b/cmake/vcpkg-overlays/triplets/arm64-ios-simulator.cmake @@ -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") diff --git a/cmake/vcpkg-overlays/triplets/arm64-ios.cmake b/cmake/vcpkg-overlays/triplets/arm64-ios.cmake new file mode 100644 index 0000000..c4fa4b9 --- /dev/null +++ b/cmake/vcpkg-overlays/triplets/arm64-ios.cmake @@ -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") diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index a2b8ae6..6764216 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -75,6 +75,16 @@ if(VOICECAT_USE_VCPKG_DEPS) 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) # AcceptEx / GetAcceptExSockaddrs live in mswsock; ws2_32 covers the base Winsock API. target_link_libraries(voicecat PRIVATE ws2_32 mswsock) diff --git a/core/include/voicecat.h b/core/include/voicecat.h index 893ffde..b68704b 100644 --- a/core/include/voicecat.h +++ b/core/include/voicecat.h @@ -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). */ 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) } /* extern "C" */ #endif diff --git a/core/src/audio/audio_engine.cpp b/core/src/audio/audio_engine.cpp index 7562606..57be441 100644 --- a/core/src/audio/audio_engine.cpp +++ b/core/src/audio/audio_engine.cpp @@ -274,6 +274,30 @@ void AudioEngine::stop() { #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) { InjectTap* tap; { diff --git a/core/src/audio/audio_engine.h b/core/src/audio/audio_engine.h index 648aaa2..44f7f64 100644 --- a/core/src/audio/audio_engine.h +++ b/core/src/audio/audio_engine.h @@ -127,6 +127,12 @@ class AudioEngine { 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 // 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 diff --git a/core/src/core/client.cpp b/core/src/core/client.cpp index 746e2a4..781c78c 100644 --- a/core/src/core/client.cpp +++ b/core/src/core/client.cpp @@ -1399,6 +1399,14 @@ vc_result vc_client::get_remote_stream(uint32_t user_id, uint32_t stream_id, 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_audio_config* out) { 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::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::audio_suspend() { return VC_ERR_NOT_IMPLEMENTED; } +vc_result vc_client::audio_resume() { return VC_ERR_NOT_IMPLEMENTED; } #endif // VOICECAT_HAS_NET diff --git a/core/src/core/client.h b/core/src/core/client.h index c05be18..755803f 100644 --- a/core/src/core/client.h +++ b/core/src/core/client.h @@ -57,6 +57,8 @@ struct vc_client { bool noise_reduction); vc_result get_remote_stream(uint32_t user_id, uint32_t stream_id, 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); diff --git a/core/src/voicecat.cpp b/core/src/voicecat.cpp index 9167d2b..7eda84d 100644 --- a/core/src/voicecat.cpp +++ b/core/src/voicecat.cpp @@ -295,4 +295,14 @@ vc_result vc_get_permissions(vc_client* c, vc_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"