feat(ios): ship iOS SwiftUI client (VoiceCatiOS)
Full SwiftUI app at clients/apple/iOS/VoiceCatiOS.xcodeproj:
- 24 Swift source files: AppState + SessionState (@Observable @MainActor),
AudioSessionManager (AVAudioSession owner + interruption/route handling),
ServerListStore/SavedServer (App Group container + Keychain sharing),
and 14 SwiftUI views covering the full feature set
- NavigationSplitView on iPad, TabView on iPhone (horizontalSizeClass)
- Channel tree via OutlineGroup, user list with context menu admin actions
- PTT via DragGesture(minimumDistance: 0) + @GestureState
- onEvent closures hop to MainActor via Task { @MainActor in ... }
- App Group: group.cat.voice.VoiceCat (shared with future ReplayKit extension)
C ABI: add vc_audio_suspend / vc_audio_resume (AudioEngine::suspend/resume)
called by AudioSessionManager on AVAudioSession interruption events.
XCFramework: add ios-arm64 and ios-arm64-simulator slices to build-xcframework.sh;
Package.swift gains .iOS(.v17) platform; CMakePresets.json adds apple-ios /
apple-ios-sim presets with arm64-ios / arm64-ios-simulator vcpkg triplets.
Verified: xcodebuild -target VoiceCatiOS -sdk iphonesimulator26.5 BUILD SUCCEEDED.
This commit is contained in:
@@ -6,9 +6,10 @@ and what's next* read [`PROGRESS.md`](PROGRESS.md); for *design* read [`docs/`](
|
|||||||
|
|
||||||
> **One-line status:** M5 (moderation & admin UI) is complete — permissions, kick/ban/move,
|
> **One-line status:** M5 (moderation & admin UI) is complete — permissions, kick/ban/move,
|
||||||
> server-mute, channel CRUD, in-app account management, disconnect/keepalive/reaper. Windows
|
> server-mute, channel CRUD, in-app account management, disconnect/keepalive/reaper. Windows
|
||||||
> WinForms C# client is shipped (M4). **macOS AppKit client shipped** — `VoiceCatMac.xcodeproj`
|
> WinForms C# client shipped (M4). **macOS AppKit client shipped** — `VoiceCatMac.xcodeproj`
|
||||||
> at `clients/apple/macOS/`. `ctest --preset dev` green — 21/21 tests.
|
> at `clients/apple/macOS/`. **iOS SwiftUI client shipped** — `VoiceCatiOS.xcodeproj` at
|
||||||
> iOS SwiftUI client is next. See [`PROGRESS.md`](PROGRESS.md).
|
> `clients/apple/iOS/`. `ctest --preset dev` green — 21/21 tests.
|
||||||
|
> Next: ReplayKit Broadcast Extension or DRED/audio polish. See [`PROGRESS.md`](PROGRESS.md).
|
||||||
|
|
||||||
VoiceCat = self-hosted native voice & text chat (TeamSpeak/Mumble-style). Plain TCP (control)
|
VoiceCat = self-hosted native voice & text chat (TeamSpeak/Mumble-style). Plain TCP (control)
|
||||||
+ UDP (media), no WebRTC, encrypted by default. A shared C++ core (`libvoicecat`) drives
|
+ UDP (media), no WebRTC, encrypted by default. A shared C++ core (`libvoicecat`) drives
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ project(voicecat
|
|||||||
DESCRIPTION "Self-hosted native voice & text chat (see docs/)"
|
DESCRIPTION "Self-hosted native voice & text chat (see docs/)"
|
||||||
LANGUAGES CXX)
|
LANGUAGES CXX)
|
||||||
|
|
||||||
|
# On iOS, audio_engine.cpp includes miniaudio.h which pulls in AVFoundation Objective-C
|
||||||
|
# headers. Those cannot be compiled as C++; we set audio_engine.cpp's LANGUAGE to OBJCXX
|
||||||
|
# in core/CMakeLists.txt, but that requires the language to be enabled first.
|
||||||
|
if(CMAKE_SYSTEM_NAME STREQUAL "iOS")
|
||||||
|
enable_language(OBJCXX)
|
||||||
|
endif()
|
||||||
|
|
||||||
# ── Options ───────────────────────────────────────────────────────────────────
|
# ── Options ───────────────────────────────────────────────────────────────────
|
||||||
# The M0 skeleton compiles with NO third-party dependencies: every subsystem is a
|
# The M0 skeleton compiles with NO third-party dependencies: every subsystem is a
|
||||||
# stub that returns VC_ERR_NOT_IMPLEMENTED. As each subsystem is built out, flip
|
# stub that returns VC_ERR_NOT_IMPLEMENTED. As each subsystem is built out, flip
|
||||||
|
|||||||
@@ -90,15 +90,19 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "apple-ios",
|
"name": "apple-ios",
|
||||||
"displayName": "Apple iOS device (XCFramework slice, scaffolding)",
|
"displayName": "Apple iOS device (XCFramework slice)",
|
||||||
"description": "SCAFFOLDING — not yet CI-validated; build on macOS to verify. Cross-compiles a static libvoicecat.a for iOS device (arm64-ios). One slice of the XCFramework. Server/tools/tests off. Requires VCPKG_ROOT and a macOS host with iOS SDK.",
|
"description": "Cross-compiles a static libvoicecat.a for iOS device (arm64). One slice of the XCFramework. Server/tools/tests off. Requires VCPKG_ROOT and a macOS host with iOS SDK. Uses cmake/vcpkg-overlays/triplets/arm64-ios.cmake (release-only, correct autoconf host triple).",
|
||||||
"inherits": "vcpkg-common",
|
"inherits": "vcpkg-common",
|
||||||
"binaryDir": "${sourceDir}/build/apple-ios",
|
"binaryDir": "${sourceDir}/build/apple-ios",
|
||||||
"cacheVariables": {
|
"cacheVariables": {
|
||||||
"CMAKE_BUILD_TYPE": "Release",
|
"CMAKE_BUILD_TYPE": "Release",
|
||||||
"CMAKE_SYSTEM_NAME": "iOS",
|
"CMAKE_SYSTEM_NAME": "iOS",
|
||||||
"CMAKE_SYSTEM_PROCESSOR": "arm64",
|
"CMAKE_SYSTEM_PROCESSOR": "arm64",
|
||||||
|
"CMAKE_OSX_ARCHITECTURES": "arm64",
|
||||||
|
"CMAKE_OSX_SYSROOT": "iphoneos",
|
||||||
|
"CMAKE_OSX_DEPLOYMENT_TARGET": "17.0",
|
||||||
"VCPKG_TARGET_TRIPLET": "arm64-ios",
|
"VCPKG_TARGET_TRIPLET": "arm64-ios",
|
||||||
|
"VCPKG_OVERLAY_TRIPLETS": "${sourceDir}/cmake/vcpkg-overlays/triplets",
|
||||||
"VOICECAT_BUILD_SERVER": "OFF",
|
"VOICECAT_BUILD_SERVER": "OFF",
|
||||||
"VOICECAT_BUILD_TOOLS": "OFF",
|
"VOICECAT_BUILD_TOOLS": "OFF",
|
||||||
"VOICECAT_BUILD_TESTS": "OFF"
|
"VOICECAT_BUILD_TESTS": "OFF"
|
||||||
@@ -106,15 +110,19 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "apple-ios-sim",
|
"name": "apple-ios-sim",
|
||||||
"displayName": "Apple iOS simulator (XCFramework slice, scaffolding)",
|
"displayName": "Apple iOS simulator (XCFramework slice)",
|
||||||
"description": "SCAFFOLDING — not yet CI-validated; build on macOS to verify. Cross-compiles a static libvoicecat.a for iOS simulator (arm64-ios-sim on Apple Silicon). One slice of the XCFramework. Server/tools/tests off. Requires VCPKG_ROOT and a macOS host with iOS simulator SDK.",
|
"description": "Cross-compiles a static libvoicecat.a for iOS simulator (arm64-ios-simulator). One slice of the XCFramework. Server/tools/tests off. Requires VCPKG_ROOT and a macOS host with iOS simulator SDK. Uses cmake/vcpkg-overlays/triplets/arm64-ios-simulator.cmake (release-only, correct autoconf host triple).",
|
||||||
"inherits": "vcpkg-common",
|
"inherits": "vcpkg-common",
|
||||||
"binaryDir": "${sourceDir}/build/apple-ios-sim",
|
"binaryDir": "${sourceDir}/build/apple-ios-sim",
|
||||||
"cacheVariables": {
|
"cacheVariables": {
|
||||||
"CMAKE_BUILD_TYPE": "Release",
|
"CMAKE_BUILD_TYPE": "Release",
|
||||||
"CMAKE_SYSTEM_NAME": "iOS",
|
"CMAKE_SYSTEM_NAME": "iOS",
|
||||||
"CMAKE_SYSTEM_PROCESSOR": "arm64",
|
"CMAKE_SYSTEM_PROCESSOR": "arm64",
|
||||||
"VCPKG_TARGET_TRIPLET": "arm64-ios-sim",
|
"CMAKE_OSX_ARCHITECTURES": "arm64",
|
||||||
|
"CMAKE_OSX_SYSROOT": "iphonesimulator",
|
||||||
|
"CMAKE_OSX_DEPLOYMENT_TARGET": "17.0",
|
||||||
|
"VCPKG_TARGET_TRIPLET": "arm64-ios-simulator",
|
||||||
|
"VCPKG_OVERLAY_TRIPLETS": "${sourceDir}/cmake/vcpkg-overlays/triplets",
|
||||||
"VOICECAT_BUILD_SERVER": "OFF",
|
"VOICECAT_BUILD_SERVER": "OFF",
|
||||||
"VOICECAT_BUILD_TOOLS": "OFF",
|
"VOICECAT_BUILD_TOOLS": "OFF",
|
||||||
"VOICECAT_BUILD_TESTS": "OFF"
|
"VOICECAT_BUILD_TESTS": "OFF"
|
||||||
|
|||||||
26
PROGRESS.md
26
PROGRESS.md
@@ -10,6 +10,30 @@ up instantly. Newest status at the top.
|
|||||||
|
|
||||||
## ▶ Where we left off / next action
|
## ▶ Where we left off / next action
|
||||||
|
|
||||||
|
- **Done:** **iOS SwiftUI client — `VoiceCatiOS`** (2026-06-19). Full SwiftUI app at
|
||||||
|
`clients/apple/iOS/VoiceCatiOS.xcodeproj`. Mirrors the macOS AppKit and Windows WinForms
|
||||||
|
clients feature-for-feature: saved server list (JSON + Keychain passwords via App Group
|
||||||
|
`group.cat.voice.VoiceCat`), TOFU server-identity sheet (first-connect and mismatch), connect
|
||||||
|
flow (guest + account auth, connection state labels, password prompt), main window
|
||||||
|
(`NavigationSplitView` on iPad, `TabView` on iPhone via `horizontalSizeClass`), channel tree
|
||||||
|
(`OutlineGroup` recursive — `children: nil` for leaf nodes), user list with context menu
|
||||||
|
(kick/ban/move/set-permissions/server-mute), chat (`ScrollViewReader` + `LazyVStack`), activity
|
||||||
|
log, voice controls (mic toggle, PTT via `DragGesture(minimumDistance: 0)` + `@GestureState`,
|
||||||
|
level meter `Canvas`), per-user tuning (gain slider, mute, NR toggles), admin sheets (channels
|
||||||
|
CRUD, accounts CRUD, ban duration, move-to-channel), settings (input mode, VAD threshold,
|
||||||
|
device picker, disconnect). All 24 Swift source files in place (10 model/state + 14 views).
|
||||||
|
`@Observable` + `@MainActor` throughout — both `AppState` and `SessionState` are `@MainActor`.
|
||||||
|
`onEvent` closures dispatch to main actor via `Task { @MainActor in ... }`.
|
||||||
|
**C ABI additions:** `vc_audio_suspend` / `vc_audio_resume` (no-op when engine stopped) —
|
||||||
|
called by `AudioSessionManager` on AVAudioSession interruption (phone calls, Siri).
|
||||||
|
**XCFramework:** iOS arm64 + arm64-simulator slices added alongside existing macOS-arm64 slice;
|
||||||
|
`Package.swift` updated with `.iOS(.v17)`.
|
||||||
|
**Verified:** `xcodebuild -target VoiceCatiOS -sdk iphonesimulator26.5 -configuration Debug ...
|
||||||
|
SYMROOT=... OBJROOT=... build` → **BUILD SUCCEEDED** (clean build).
|
||||||
|
- **Next:** ReplayKit Broadcast Upload Extension (`VoiceCatBroadcast`) for screen/system audio
|
||||||
|
sharing on iOS — separate Xcode target, App Group credential sharing, `SampleHandler.swift`.
|
||||||
|
Or: DRED/audio-quality polish (M5).
|
||||||
|
|
||||||
- **Done:** **macOS AppKit client builds clean (Debug + Release)** (2026-06-18). The previous
|
- **Done:** **macOS AppKit client builds clean (Debug + Release)** (2026-06-18). The previous
|
||||||
"shipped" claim in the entry below was incorrect — `xcodebuild` actually failed on a fresh
|
"shipped" claim in the entry below was incorrect — `xcodebuild` actually failed on a fresh
|
||||||
clone. Two real defect classes fixed, no source architecture changed:
|
clone. Two real defect classes fixed, no source architecture changed:
|
||||||
@@ -504,7 +528,7 @@ up instantly. Newest status at the top.
|
|||||||
- [x] **M1 — Control plane** ✓ complete (2026-06-15)
|
- [x] **M1 — Control plane** ✓ complete (2026-06-15)
|
||||||
- [x] **M2 — Voice, single stream** ✓ complete (2026-06-16)
|
- [x] **M2 — Voice, single stream** ✓ complete (2026-06-16)
|
||||||
- [x] **M3 — Multi-stream & per-channel tuning** ✓ complete (2026-06-16)
|
- [x] **M3 — Multi-stream & per-channel tuning** ✓ complete (2026-06-16)
|
||||||
- [x] **M4 — Native clients** — Windows WinForms ✓ (2026-06-17); macOS AppKit ✓ (2026-06-18); iOS SwiftUI pending
|
- [x] **M4 — Native clients** — Windows WinForms ✓ (2026-06-17); macOS AppKit ✓ (2026-06-18); iOS SwiftUI ✓ (2026-06-19)
|
||||||
- [~] **M5 — Moderation, polish, beyond** (perms, bans, DRED; then file transfer, E2EE, …)
|
- [~] **M5 — Moderation, polish, beyond** (perms, bans, DRED; then file transfer, E2EE, …)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -16,11 +16,12 @@ import PackageDescription
|
|||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "VoiceCatCore",
|
name: "VoiceCatCore",
|
||||||
// macOS 14 (Sonoma) is the AppKit client's deployment target. iOS is deferred; when the
|
// macOS 14 (Sonoma) is the AppKit client's deployment target. iOS 17 is the SwiftUI client
|
||||||
// iOS app lands, add `.iOS(.v17)` here and the apple-ios / apple-ios-sim slices to the
|
// target (clients/apple/iOS/). Run `scripts/build-xcframework.sh --all` to produce all
|
||||||
// XCFramework (scripts/build-xcframework.sh --all).
|
// three slices: macos-arm64, ios-arm64, ios-arm64-simulator.
|
||||||
platforms: [
|
platforms: [
|
||||||
.macOS(.v14),
|
.macOS(.v14),
|
||||||
|
.iOS(.v17),
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
.library(name: "VoiceCatCore", targets: ["VoiceCatCore"]),
|
.library(name: "VoiceCatCore", targets: ["VoiceCatCore"]),
|
||||||
|
|||||||
@@ -322,6 +322,20 @@ public final class VoiceCatClient {
|
|||||||
VoiceCatResult(vc_set_self_mute(handle, micMuted ? 1 : 0, deafened ? 1 : 0))
|
VoiceCatResult(vc_set_self_mute(handle, micMuted ? 1 : 0, deafened ? 1 : 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - AVAudioSession interruption hooks (iOS)
|
||||||
|
|
||||||
|
/// Pause miniaudio device I/O. Call when AVAudioSession interruption begins.
|
||||||
|
@discardableResult
|
||||||
|
public func audioSuspend() -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_audio_suspend(handle))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resume miniaudio device I/O. Call after re-activating AVAudioSession.
|
||||||
|
@discardableResult
|
||||||
|
public func audioResume() -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_audio_resume(handle))
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Receive-side, per remote stream (LOCAL — no protocol traffic; docs/voice.md §10)
|
// MARK: - Receive-side, per remote stream (LOCAL — no protocol traffic; docs/voice.md §10)
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
|
|||||||
443
clients/apple/iOS/VoiceCatiOS.xcodeproj/project.pbxproj
Normal file
443
clients/apple/iOS/VoiceCatiOS.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 56;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
BBBB00000000000000000030 /* VoiceCatiOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000017 /* VoiceCatiOSApp.swift */; };
|
||||||
|
BBBB00000000000000000031 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000018 /* AppState.swift */; };
|
||||||
|
BBBB00000000000000000032 /* SessionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000019 /* SessionState.swift */; };
|
||||||
|
BBBB00000000000000000033 /* AudioSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000001A /* AudioSessionManager.swift */; };
|
||||||
|
BBBB00000000000000000034 /* ServerListStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000001B /* ServerListStore.swift */; };
|
||||||
|
BBBB00000000000000000035 /* SavedServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000001C /* SavedServer.swift */; };
|
||||||
|
BBBB00000000000000000036 /* BroadcastCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000001D /* BroadcastCredentials.swift */; };
|
||||||
|
BBBB00000000000000000037 /* ServerListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000001E /* ServerListView.swift */; };
|
||||||
|
BBBB00000000000000000038 /* AddServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000001F /* AddServerView.swift */; };
|
||||||
|
BBBB00000000000000000039 /* ServerIdentityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000020 /* ServerIdentityView.swift */; };
|
||||||
|
BBBB0000000000000000003A /* PasswordPromptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000021 /* PasswordPromptView.swift */; };
|
||||||
|
BBBB0000000000000000003B /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000022 /* MainView.swift */; };
|
||||||
|
BBBB0000000000000000003C /* ChannelTreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000023 /* ChannelTreeView.swift */; };
|
||||||
|
BBBB0000000000000000003D /* UserListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000024 /* UserListView.swift */; };
|
||||||
|
BBBB0000000000000000003E /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000025 /* ChatView.swift */; };
|
||||||
|
BBBB0000000000000000003F /* ActivityLogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000026 /* ActivityLogView.swift */; };
|
||||||
|
BBBB00000000000000000040 /* VoiceControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000027 /* VoiceControlsView.swift */; };
|
||||||
|
BBBB00000000000000000041 /* PerUserTuningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000028 /* PerUserTuningView.swift */; };
|
||||||
|
BBBB00000000000000000042 /* ChannelEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000029 /* ChannelEditView.swift */; };
|
||||||
|
BBBB00000000000000000043 /* BanUserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002A /* BanUserView.swift */; };
|
||||||
|
BBBB00000000000000000044 /* MoveUserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002B /* MoveUserView.swift */; };
|
||||||
|
BBBB00000000000000000045 /* PermissionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002C /* PermissionsView.swift */; };
|
||||||
|
BBBB00000000000000000046 /* AccountsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002D /* AccountsView.swift */; };
|
||||||
|
BBBB00000000000000000047 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002E /* SettingsView.swift */; };
|
||||||
|
BBBB00000000000000000048 /* VoiceCatCore in Frameworks */ = {isa = PBXBuildFile; productRef = BBBB0000000000000000004A /* VoiceCatCore */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
BBBB00000000000000000012 /* VoiceCatiOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VoiceCatiOS.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
BBBB00000000000000000015 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000016 /* VoiceCatiOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = VoiceCatiOS.entitlements; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000017 /* VoiceCatiOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceCatiOSApp.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000018 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000019 /* SessionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionState.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB0000000000000000001A /* AudioSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionManager.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB0000000000000000001B /* ServerListStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListStore.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB0000000000000000001C /* SavedServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedServer.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB0000000000000000001D /* BroadcastCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastCredentials.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB0000000000000000001E /* ServerListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB0000000000000000001F /* AddServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddServerView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000020 /* ServerIdentityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerIdentityView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000021 /* PasswordPromptView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordPromptView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000022 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000023 /* ChannelTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelTreeView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000024 /* UserListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000025 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000026 /* ActivityLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityLogView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000027 /* VoiceControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceControlsView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000028 /* PerUserTuningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerUserTuningView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000029 /* ChannelEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelEditView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB0000000000000000002A /* BanUserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BanUserView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB0000000000000000002B /* MoveUserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveUserView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB0000000000000000002C /* PermissionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB0000000000000000002D /* AccountsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB0000000000000000002E /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
BBBB00000000000000000011 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
BBBB00000000000000000048 /* VoiceCatCore in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
BBBB00000000000000000002 /* mainGroup */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
BBBB00000000000000000003 /* VoiceCatiOS */,
|
||||||
|
BBBB00000000000000000007 /* Products */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
BBBB00000000000000000003 /* VoiceCatiOS */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
BBBB00000000000000000015 /* Info.plist */,
|
||||||
|
BBBB00000000000000000016 /* VoiceCatiOS.entitlements */,
|
||||||
|
BBBB00000000000000000017 /* VoiceCatiOSApp.swift */,
|
||||||
|
BBBB00000000000000000018 /* AppState.swift */,
|
||||||
|
BBBB00000000000000000019 /* SessionState.swift */,
|
||||||
|
BBBB0000000000000000001A /* AudioSessionManager.swift */,
|
||||||
|
BBBB0000000000000000001B /* ServerListStore.swift */,
|
||||||
|
BBBB0000000000000000001C /* SavedServer.swift */,
|
||||||
|
BBBB0000000000000000001D /* BroadcastCredentials.swift */,
|
||||||
|
BBBB00000000000000000006 /* Views */,
|
||||||
|
);
|
||||||
|
path = VoiceCatiOS;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
BBBB00000000000000000006 /* Views */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
BBBB0000000000000000001E /* ServerListView.swift */,
|
||||||
|
BBBB0000000000000000001F /* AddServerView.swift */,
|
||||||
|
BBBB00000000000000000020 /* ServerIdentityView.swift */,
|
||||||
|
BBBB00000000000000000021 /* PasswordPromptView.swift */,
|
||||||
|
BBBB00000000000000000022 /* MainView.swift */,
|
||||||
|
BBBB00000000000000000023 /* ChannelTreeView.swift */,
|
||||||
|
BBBB00000000000000000024 /* UserListView.swift */,
|
||||||
|
BBBB00000000000000000025 /* ChatView.swift */,
|
||||||
|
BBBB00000000000000000026 /* ActivityLogView.swift */,
|
||||||
|
BBBB00000000000000000027 /* VoiceControlsView.swift */,
|
||||||
|
BBBB00000000000000000028 /* PerUserTuningView.swift */,
|
||||||
|
BBBB00000000000000000029 /* ChannelEditView.swift */,
|
||||||
|
BBBB0000000000000000002A /* BanUserView.swift */,
|
||||||
|
BBBB0000000000000000002B /* MoveUserView.swift */,
|
||||||
|
BBBB0000000000000000002C /* PermissionsView.swift */,
|
||||||
|
BBBB0000000000000000002D /* AccountsView.swift */,
|
||||||
|
BBBB0000000000000000002E /* SettingsView.swift */,
|
||||||
|
);
|
||||||
|
path = Views;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
BBBB00000000000000000007 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
BBBB00000000000000000012 /* VoiceCatiOS.app */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
BBBB00000000000000000008 /* VoiceCatiOS */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = BBBB0000000000000000000A /* Build configuration list for PBXNativeTarget "VoiceCatiOS" */;
|
||||||
|
buildPhases = (
|
||||||
|
BBBB0000000000000000000F /* Sources */,
|
||||||
|
BBBB00000000000000000010 /* Resources */,
|
||||||
|
BBBB00000000000000000011 /* Frameworks */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = VoiceCatiOS;
|
||||||
|
packageProductDependencies = (
|
||||||
|
BBBB0000000000000000004A /* VoiceCatCore */,
|
||||||
|
);
|
||||||
|
productName = VoiceCatiOS;
|
||||||
|
productReference = BBBB00000000000000000012 /* VoiceCatiOS.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
BBBB00000000000000000001 /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = 1;
|
||||||
|
LastSwiftUpdateCheck = 1500;
|
||||||
|
LastUpgradeCheck = 1500;
|
||||||
|
};
|
||||||
|
buildConfigurationList = BBBB00000000000000000009 /* Build configuration list for PBXProject "VoiceCatiOS" */;
|
||||||
|
compatibilityVersion = "Xcode 14.0";
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
en,
|
||||||
|
Base,
|
||||||
|
);
|
||||||
|
mainGroup = BBBB00000000000000000002 /* mainGroup */;
|
||||||
|
packageReferences = (
|
||||||
|
BBBB00000000000000000049 /* XCLocalSwiftPackageReference "../" */,
|
||||||
|
);
|
||||||
|
productRefGroup = BBBB00000000000000000007 /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
BBBB00000000000000000008 /* VoiceCatiOS */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
BBBB00000000000000000010 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
BBBB0000000000000000000F /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
BBBB00000000000000000030 /* VoiceCatiOSApp.swift in Sources */,
|
||||||
|
BBBB00000000000000000031 /* AppState.swift in Sources */,
|
||||||
|
BBBB00000000000000000032 /* SessionState.swift in Sources */,
|
||||||
|
BBBB00000000000000000033 /* AudioSessionManager.swift in Sources */,
|
||||||
|
BBBB00000000000000000034 /* ServerListStore.swift in Sources */,
|
||||||
|
BBBB00000000000000000035 /* SavedServer.swift in Sources */,
|
||||||
|
BBBB00000000000000000036 /* BroadcastCredentials.swift in Sources */,
|
||||||
|
BBBB00000000000000000037 /* ServerListView.swift in Sources */,
|
||||||
|
BBBB00000000000000000038 /* AddServerView.swift in Sources */,
|
||||||
|
BBBB00000000000000000039 /* ServerIdentityView.swift in Sources */,
|
||||||
|
BBBB0000000000000000003A /* PasswordPromptView.swift in Sources */,
|
||||||
|
BBBB0000000000000000003B /* MainView.swift in Sources */,
|
||||||
|
BBBB0000000000000000003C /* ChannelTreeView.swift in Sources */,
|
||||||
|
BBBB0000000000000000003D /* UserListView.swift in Sources */,
|
||||||
|
BBBB0000000000000000003E /* ChatView.swift in Sources */,
|
||||||
|
BBBB0000000000000000003F /* ActivityLogView.swift in Sources */,
|
||||||
|
BBBB00000000000000000040 /* VoiceControlsView.swift in Sources */,
|
||||||
|
BBBB00000000000000000041 /* PerUserTuningView.swift in Sources */,
|
||||||
|
BBBB00000000000000000042 /* ChannelEditView.swift in Sources */,
|
||||||
|
BBBB00000000000000000043 /* BanUserView.swift in Sources */,
|
||||||
|
BBBB00000000000000000044 /* MoveUserView.swift in Sources */,
|
||||||
|
BBBB00000000000000000045 /* PermissionsView.swift in Sources */,
|
||||||
|
BBBB00000000000000000046 /* AccountsView.swift in Sources */,
|
||||||
|
BBBB00000000000000000047 /* SettingsView.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
BBBB0000000000000000000B /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"DEBUG=1",
|
||||||
|
"$(inherited)",
|
||||||
|
);
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
BBBB0000000000000000000C /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
BBBB0000000000000000000D /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = VoiceCatiOS/VoiceCatiOS.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = "";
|
||||||
|
INFOPLIST_FILE = VoiceCatiOS/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 0.0.1;
|
||||||
|
OTHER_LDFLAGS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"-lc++",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = cat.voice.VoiceCatiOS;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.9;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
BBBB0000000000000000000E /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = VoiceCatiOS/VoiceCatiOS.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = "";
|
||||||
|
INFOPLIST_FILE = VoiceCatiOS/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 0.0.1;
|
||||||
|
OTHER_LDFLAGS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"-lc++",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = cat.voice.VoiceCatiOS;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.9;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
BBBB00000000000000000009 /* Build configuration list for PBXProject "VoiceCatiOS" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
BBBB0000000000000000000B /* Debug */,
|
||||||
|
BBBB0000000000000000000C /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
BBBB0000000000000000000A /* Build configuration list for PBXNativeTarget "VoiceCatiOS" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
BBBB0000000000000000000D /* Debug */,
|
||||||
|
BBBB0000000000000000000E /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
|
BBBB00000000000000000049 /* XCLocalSwiftPackageReference "../" */ = {
|
||||||
|
isa = XCLocalSwiftPackageReference;
|
||||||
|
relativePath = "../";
|
||||||
|
};
|
||||||
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
BBBB0000000000000000004A /* VoiceCatCore */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = BBBB00000000000000000049 /* XCLocalSwiftPackageReference "../" */;
|
||||||
|
productName = VoiceCatCore;
|
||||||
|
};
|
||||||
|
/* End XCSwiftPackageProductDependency section */
|
||||||
|
|
||||||
|
};
|
||||||
|
rootObject = BBBB00000000000000000001 /* Project object */;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1500"
|
||||||
|
version = "1.3">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "BBBB00000000000000000008"
|
||||||
|
BuildableName = "VoiceCatiOS.app"
|
||||||
|
BlueprintName = "VoiceCatiOS"
|
||||||
|
ReferencedContainer = "container:VoiceCatiOS.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
|
<Testables>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "BBBB00000000000000000008"
|
||||||
|
BuildableName = "VoiceCatiOS.app"
|
||||||
|
BlueprintName = "VoiceCatiOS"
|
||||||
|
ReferencedContainer = "container:VoiceCatiOS.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "BBBB00000000000000000008"
|
||||||
|
BuildableName = "VoiceCatiOS.app"
|
||||||
|
BlueprintName = "VoiceCatiOS"
|
||||||
|
ReferencedContainer = "container:VoiceCatiOS.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
174
clients/apple/iOS/VoiceCatiOS/AppState.swift
Normal file
174
clients/apple/iOS/VoiceCatiOS/AppState.swift
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import Foundation
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
struct PendingIdentity: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let displayText: String
|
||||||
|
let tofuStatus: VoiceCatTofuStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class AppState {
|
||||||
|
var servers: [SavedServer] = ServerListStore.shared.load()
|
||||||
|
var session: SessionState?
|
||||||
|
|
||||||
|
// Connect-flow state
|
||||||
|
var isConnecting = false
|
||||||
|
var connectStatus = ""
|
||||||
|
var showAddServer = false
|
||||||
|
var editingServer: SavedServer?
|
||||||
|
var showPasswordPrompt = false
|
||||||
|
var pendingIdentity: PendingIdentity?
|
||||||
|
|
||||||
|
private var connectingClient: VoiceCatClient?
|
||||||
|
private(set) var connectingServer: SavedServer?
|
||||||
|
private var identityHandled = false
|
||||||
|
|
||||||
|
// MARK: - Server list management
|
||||||
|
|
||||||
|
func addServer(_ server: SavedServer, password: String?) {
|
||||||
|
if let pw = password, !pw.isEmpty {
|
||||||
|
ServerListStore.shared.savePassword(pw, tag: server.keychainTag)
|
||||||
|
}
|
||||||
|
servers.append(server)
|
||||||
|
ServerListStore.shared.save(servers)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateServer(_ server: SavedServer, password: String?) {
|
||||||
|
if let pw = password, !pw.isEmpty {
|
||||||
|
ServerListStore.shared.savePassword(pw, tag: server.keychainTag)
|
||||||
|
}
|
||||||
|
if let idx = servers.firstIndex(where: { $0.id == server.id }) {
|
||||||
|
servers[idx] = server
|
||||||
|
}
|
||||||
|
ServerListStore.shared.save(servers)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeServer(_ server: SavedServer) {
|
||||||
|
ServerListStore.shared.deletePassword(tag: server.keychainTag)
|
||||||
|
servers.removeAll(where: { $0.id == server.id })
|
||||||
|
ServerListStore.shared.save(servers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Connect flow
|
||||||
|
|
||||||
|
func connectTo(_ server: SavedServer) {
|
||||||
|
guard !isConnecting else { return }
|
||||||
|
isConnecting = true
|
||||||
|
connectStatus = "Connecting…"
|
||||||
|
connectingServer = server
|
||||||
|
identityHandled = false
|
||||||
|
|
||||||
|
let config = VoiceCatConfig(
|
||||||
|
clientName: "VoiceCat-iOS",
|
||||||
|
clientVersion: "0.0.1",
|
||||||
|
logLevel: .info,
|
||||||
|
tofuStorePath: ServerListStore.shared.tofuStorePath)
|
||||||
|
let client = VoiceCatClient(config: config)
|
||||||
|
connectingClient = client
|
||||||
|
|
||||||
|
client.onEvent = { [weak self] ev in
|
||||||
|
Task { @MainActor [weak self] in self?.handleConnectEvent(ev, server: server) }
|
||||||
|
}
|
||||||
|
client.connect(host: server.host, port: server.port)
|
||||||
|
|
||||||
|
// Auth is queued immediately — the core serialises it behind TLS + TOFU.
|
||||||
|
switch server.authMode {
|
||||||
|
case .guest:
|
||||||
|
let nick = server.savedUsername.isEmpty ? "iOS User" : server.savedUsername
|
||||||
|
client.authenticateGuest(nick)
|
||||||
|
case .password:
|
||||||
|
let savedPw = ServerListStore.shared.loadPassword(tag: server.keychainTag)
|
||||||
|
if let pw = savedPw, !pw.isEmpty {
|
||||||
|
client.authenticateUser(server.savedUsername, password: pw)
|
||||||
|
} else {
|
||||||
|
showPasswordPrompt = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func disconnect() {
|
||||||
|
session?.client.disconnect()
|
||||||
|
session = nil
|
||||||
|
connectingClient?.disconnect()
|
||||||
|
connectingClient = nil
|
||||||
|
connectingServer = nil
|
||||||
|
isConnecting = false
|
||||||
|
connectStatus = ""
|
||||||
|
showPasswordPrompt = false
|
||||||
|
pendingIdentity = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Auth actions (called from prompt sheets)
|
||||||
|
|
||||||
|
func authenticateUser(username: String, password: String) {
|
||||||
|
connectingClient?.authenticateUser(username, password: password)
|
||||||
|
showPasswordPrompt = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func confirmServerIdentity(accept: Bool) {
|
||||||
|
connectingClient?.confirmServerIdentity(accept: accept)
|
||||||
|
pendingIdentity = nil
|
||||||
|
if !accept { cancelConnect() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelConnect() {
|
||||||
|
connectingClient?.disconnect()
|
||||||
|
connectingClient = nil
|
||||||
|
connectingServer = nil
|
||||||
|
isConnecting = false
|
||||||
|
connectStatus = ""
|
||||||
|
showPasswordPrompt = false
|
||||||
|
pendingIdentity = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Connect event handler
|
||||||
|
|
||||||
|
private func handleConnectEvent(_ ev: VoiceCatEvent, server: SavedServer) {
|
||||||
|
switch ev.type {
|
||||||
|
case .connectionState:
|
||||||
|
switch ev.connectionState {
|
||||||
|
case .connecting: connectStatus = "Connecting…"
|
||||||
|
case .tlsHandshake: connectStatus = "TLS handshake…"
|
||||||
|
case .authenticating: connectStatus = "Authenticating…"
|
||||||
|
case .verifyingIdentity: connectStatus = "Verifying server identity…"
|
||||||
|
case .connected: connectStatus = "Connected"
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
case .serverIdentity:
|
||||||
|
guard !identityHandled else { break }
|
||||||
|
let tofuStatus = ev.tofuStatus ?? .firstConnect
|
||||||
|
if tofuStatus == .matched {
|
||||||
|
connectingClient?.confirmServerIdentity(accept: true)
|
||||||
|
} else {
|
||||||
|
identityHandled = true
|
||||||
|
let displayText = connectingClient?.getServerIdentityDisplay() ?? ""
|
||||||
|
pendingIdentity = PendingIdentity(displayText: displayText, tofuStatus: tofuStatus)
|
||||||
|
}
|
||||||
|
case .authResult:
|
||||||
|
if ev.result == .ok {
|
||||||
|
guard let client = connectingClient else { break }
|
||||||
|
let perms = client.getPermissions()
|
||||||
|
let newSession = SessionState(client: client, selfUserId: ev.userId, permissions: perms)
|
||||||
|
ServerListStore.shared.writeBroadcastCredentials(server: server, nickname: nil)
|
||||||
|
connectingClient = nil
|
||||||
|
isConnecting = false
|
||||||
|
connectStatus = ""
|
||||||
|
showPasswordPrompt = false
|
||||||
|
self.session = newSession
|
||||||
|
} else {
|
||||||
|
connectStatus = "Auth failed: \(ev.result.description)"
|
||||||
|
showPasswordPrompt = true
|
||||||
|
}
|
||||||
|
case .disconnected:
|
||||||
|
if session == nil { cancelConnect() }
|
||||||
|
else { session = nil; isConnecting = false }
|
||||||
|
case .error:
|
||||||
|
connectStatus = ev.text ?? "Unknown error"
|
||||||
|
if session == nil { isConnecting = false }
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
64
clients/apple/iOS/VoiceCatiOS/AudioSessionManager.swift
Normal file
64
clients/apple/iOS/VoiceCatiOS/AudioSessionManager.swift
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AudioSessionManager {
|
||||||
|
static let shared = AudioSessionManager()
|
||||||
|
|
||||||
|
weak var client: VoiceCatClient?
|
||||||
|
|
||||||
|
func configure() {
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
do {
|
||||||
|
try session.setCategory(.playAndRecord, mode: .voiceChat,
|
||||||
|
options: [.allowBluetooth, .allowBluetoothA2DP,
|
||||||
|
.defaultToSpeaker, .mixWithOthers])
|
||||||
|
} catch {
|
||||||
|
print("[AudioSession] setCategory failed: \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self, selector: #selector(handleInterruption),
|
||||||
|
name: AVAudioSession.interruptionNotification, object: nil)
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self, selector: #selector(handleRouteChange),
|
||||||
|
name: AVAudioSession.routeChangeNotification, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func activateForStreaming() throws {
|
||||||
|
try AVAudioSession.sharedInstance().setActive(true, options: [])
|
||||||
|
}
|
||||||
|
|
||||||
|
func deactivateAfterStreaming() {
|
||||||
|
try? AVAudioSession.sharedInstance().setActive(false,
|
||||||
|
options: .notifyOthersOnDeactivation)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleInterruption(_ notification: Notification) {
|
||||||
|
guard let info = notification.userInfo,
|
||||||
|
let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||||
|
let type = AVAudioSession.InterruptionType(rawValue: typeValue)
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
switch type {
|
||||||
|
case .began:
|
||||||
|
client?.audioSuspend()
|
||||||
|
case .ended:
|
||||||
|
let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
|
||||||
|
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
||||||
|
if options.contains(.shouldResume) {
|
||||||
|
try? AVAudioSession.sharedInstance().setActive(true)
|
||||||
|
client?.audioResume()
|
||||||
|
}
|
||||||
|
@unknown default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleRouteChange(_ notification: Notification) {
|
||||||
|
NotificationCenter.default.post(name: .voiceCatDeviceListChanged, object: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
static let voiceCatDeviceListChanged = Notification.Name("cat.voice.deviceListChanged")
|
||||||
|
}
|
||||||
21
clients/apple/iOS/VoiceCatiOS/BroadcastCredentials.swift
Normal file
21
clients/apple/iOS/VoiceCatiOS/BroadcastCredentials.swift
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BroadcastCredentials: Codable {
|
||||||
|
var host: String
|
||||||
|
var port: Int
|
||||||
|
var authMode: String
|
||||||
|
var username: String
|
||||||
|
var tofuPinsPath: String
|
||||||
|
|
||||||
|
static func loadFromAppGroup() -> BroadcastCredentials? {
|
||||||
|
let groupId = "group.cat.voice.VoiceCat"
|
||||||
|
guard let container = FileManager.default.containerURL(
|
||||||
|
forSecurityApplicationGroupIdentifier: groupId)
|
||||||
|
else { return nil }
|
||||||
|
let url = container
|
||||||
|
.appendingPathComponent("voicecat", isDirectory: true)
|
||||||
|
.appendingPathComponent("broadcast_credentials.json")
|
||||||
|
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||||
|
return try? JSONDecoder().decode(BroadcastCredentials.self, from: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
47
clients/apple/iOS/VoiceCatiOS/Info.plist
Normal file
47
clients/apple/iOS/VoiceCatiOS/Info.plist
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>VoiceCat</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>0.0.1</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>Copyright © 2026 VoiceCat contributors. All rights reserved.</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>VoiceCat needs microphone access to transmit your voice in channels.</string>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>audio</string>
|
||||||
|
</array>
|
||||||
|
<key>UILaunchStoryboardName</key>
|
||||||
|
<string>LaunchScreen</string>
|
||||||
|
<key>UIRequiresFullScreen</key>
|
||||||
|
<false/>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
24
clients/apple/iOS/VoiceCatiOS/SavedServer.swift
Normal file
24
clients/apple/iOS/VoiceCatiOS/SavedServer.swift
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct SavedServer: Codable, Identifiable, Equatable {
|
||||||
|
let id: UUID
|
||||||
|
var host: String
|
||||||
|
var port: UInt16
|
||||||
|
var authMode: AuthMode
|
||||||
|
var savedUsername: String
|
||||||
|
var keychainTag: String
|
||||||
|
|
||||||
|
enum AuthMode: String, Codable { case guest, password }
|
||||||
|
|
||||||
|
init(id: UUID = UUID(), host: String, port: UInt16,
|
||||||
|
authMode: AuthMode = .guest, savedUsername: String = "", keychainTag: String = "") {
|
||||||
|
self.id = id
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.authMode = authMode
|
||||||
|
self.savedUsername = savedUsername
|
||||||
|
self.keychainTag = keychainTag.isEmpty ? id.uuidString : keychainTag
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayString: String { "\(host):\(port)" }
|
||||||
|
}
|
||||||
106
clients/apple/iOS/VoiceCatiOS/ServerListStore.swift
Normal file
106
clients/apple/iOS/VoiceCatiOS/ServerListStore.swift
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
final class ServerListStore {
|
||||||
|
static let shared = ServerListStore()
|
||||||
|
|
||||||
|
private let groupId = "group.cat.voice.VoiceCat"
|
||||||
|
private let keychainService = "cat.voice.VoiceCatiOS"
|
||||||
|
|
||||||
|
// MARK: - App Group Container
|
||||||
|
|
||||||
|
var appGroupContainer: URL {
|
||||||
|
guard let url = FileManager.default.containerURL(
|
||||||
|
forSecurityApplicationGroupIdentifier: groupId)
|
||||||
|
else {
|
||||||
|
// Fall back to app-only support dir if App Groups are unavailable (e.g. simulator
|
||||||
|
// without entitlements). TOFU pins won't be shared with the broadcast extension,
|
||||||
|
// but connect + auth still work.
|
||||||
|
return FileManager.default.urls(for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask).first!
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
private var voicecatDir: URL {
|
||||||
|
let dir = appGroupContainer.appendingPathComponent("voicecat", isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: dir,
|
||||||
|
withIntermediateDirectories: true)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
var tofuStorePath: String { voicecatDir.appendingPathComponent("tofu_pins.txt").path }
|
||||||
|
|
||||||
|
private var serversURL: URL { voicecatDir.appendingPathComponent("servers.json") }
|
||||||
|
|
||||||
|
// MARK: - Server list persistence
|
||||||
|
|
||||||
|
func load() -> [SavedServer] {
|
||||||
|
guard let data = try? Data(contentsOf: serversURL),
|
||||||
|
let list = try? JSONDecoder().decode([SavedServer].self, from: data)
|
||||||
|
else { return [] }
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
func save(_ servers: [SavedServer]) {
|
||||||
|
guard let data = try? JSONEncoder().encode(servers) else { return }
|
||||||
|
try? data.write(to: serversURL, options: .atomic)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Keychain
|
||||||
|
|
||||||
|
func savePassword(_ password: String, tag: String) {
|
||||||
|
let data = Data(password.utf8)
|
||||||
|
let query: [CFString: Any] = [
|
||||||
|
kSecClass: kSecClassGenericPassword,
|
||||||
|
kSecAttrService: keychainService,
|
||||||
|
kSecAttrAccount: tag,
|
||||||
|
kSecAttrAccessGroup: groupId,
|
||||||
|
]
|
||||||
|
SecItemDelete(query as CFDictionary)
|
||||||
|
var add = query
|
||||||
|
add[kSecValueData] = data
|
||||||
|
SecItemAdd(add as CFDictionary, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadPassword(tag: String) -> String? {
|
||||||
|
let query: [CFString: Any] = [
|
||||||
|
kSecClass: kSecClassGenericPassword,
|
||||||
|
kSecAttrService: keychainService,
|
||||||
|
kSecAttrAccount: tag,
|
||||||
|
kSecAttrAccessGroup: groupId,
|
||||||
|
kSecReturnData: true,
|
||||||
|
kSecMatchLimit: kSecMatchLimitOne,
|
||||||
|
]
|
||||||
|
var result: AnyObject?
|
||||||
|
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
||||||
|
let data = result as? Data
|
||||||
|
else { return nil }
|
||||||
|
return String(data: data, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deletePassword(tag: String) {
|
||||||
|
let query: [CFString: Any] = [
|
||||||
|
kSecClass: kSecClassGenericPassword,
|
||||||
|
kSecAttrService: keychainService,
|
||||||
|
kSecAttrAccount: tag,
|
||||||
|
kSecAttrAccessGroup: groupId,
|
||||||
|
]
|
||||||
|
SecItemDelete(query as CFDictionary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Broadcast credentials (shared with ReplayKit extension)
|
||||||
|
|
||||||
|
func writeBroadcastCredentials(server: SavedServer, nickname: String?) {
|
||||||
|
let creds = BroadcastCredentials(
|
||||||
|
host: server.host,
|
||||||
|
port: Int(server.port),
|
||||||
|
authMode: server.authMode.rawValue,
|
||||||
|
username: server.authMode == .password ? server.savedUsername : (nickname ?? ""),
|
||||||
|
tofuPinsPath: tofuStorePath
|
||||||
|
)
|
||||||
|
guard let data = try? JSONEncoder().encode(creds) else { return }
|
||||||
|
let url = voicecatDir.appendingPathComponent("broadcast_credentials.json")
|
||||||
|
try? data.write(to: url, options: .atomic)
|
||||||
|
}
|
||||||
|
}
|
||||||
252
clients/apple/iOS/VoiceCatiOS/SessionState.swift
Normal file
252
clients/apple/iOS/VoiceCatiOS/SessionState.swift
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
import Foundation
|
||||||
|
import AVFoundation
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
// MARK: - Helper types
|
||||||
|
|
||||||
|
struct ChatMessage: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let timestamp: Date
|
||||||
|
let senderName: String
|
||||||
|
let text: String
|
||||||
|
let scope: VoiceCatTextScope
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ActivityEntry: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let timestamp: Date
|
||||||
|
let text: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VoiceState {
|
||||||
|
var micActive = false
|
||||||
|
var selfMuted = false
|
||||||
|
var selfDeafened = false
|
||||||
|
var inputMode: VoiceCatInputMode = .voiceActivation
|
||||||
|
var vadThreshold: Float = 0.025
|
||||||
|
var level: Float = 0.0
|
||||||
|
var currentDeviceId: String?
|
||||||
|
var localStreamId: UInt32 = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SessionState
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class SessionState {
|
||||||
|
let client: VoiceCatClient
|
||||||
|
let selfUserId: UInt32
|
||||||
|
|
||||||
|
var channels: [Channel] = []
|
||||||
|
var users: [User] = []
|
||||||
|
var currentChannelId: UInt32 = 0
|
||||||
|
var messages: [ChatMessage] = []
|
||||||
|
var activityLog: [ActivityEntry] = []
|
||||||
|
var voiceState = VoiceState()
|
||||||
|
var permissions: Permissions
|
||||||
|
var accounts: [Account] = []
|
||||||
|
var devices: [Device] = []
|
||||||
|
|
||||||
|
init(client: VoiceCatClient, selfUserId: UInt32, permissions: Permissions) {
|
||||||
|
self.client = client
|
||||||
|
self.selfUserId = selfUserId
|
||||||
|
self.permissions = permissions
|
||||||
|
AudioSessionManager.shared.client = client
|
||||||
|
refreshChannels()
|
||||||
|
refreshUsers()
|
||||||
|
refreshDevices()
|
||||||
|
client.onEvent = { [weak self] ev in
|
||||||
|
Task { @MainActor [weak self] in self?.handleEvent(ev) }
|
||||||
|
}
|
||||||
|
client.onLevel = { [weak self] _, rms in
|
||||||
|
Task { @MainActor [weak self] in self?.voiceState.level = rms }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
MainActor.assumeIsolated {
|
||||||
|
AudioSessionManager.shared.client = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Event dispatch
|
||||||
|
|
||||||
|
func handleEvent(_ ev: VoiceCatEvent) {
|
||||||
|
switch ev.type {
|
||||||
|
case .channelList:
|
||||||
|
refreshChannels()
|
||||||
|
case .userJoined, .userLeft, .userUpdated:
|
||||||
|
refreshUsers()
|
||||||
|
case .textMessage:
|
||||||
|
let sender = users.first(where: { $0.id == ev.userId })?.nickname ?? "Unknown"
|
||||||
|
messages.append(ChatMessage(
|
||||||
|
timestamp: Date(timeIntervalSince1970: Double(ev.timestampUnixMs) / 1000),
|
||||||
|
senderName: sender,
|
||||||
|
text: ev.text ?? "",
|
||||||
|
scope: ev.textScope))
|
||||||
|
case .talkState:
|
||||||
|
let talking = ev.u32a != 0
|
||||||
|
let who = users.first(where: { $0.id == ev.userId })?.nickname ?? "user \(ev.userId)"
|
||||||
|
addActivity(talking ? "\(who) started talking" : "\(who) stopped talking")
|
||||||
|
case .streamStarted:
|
||||||
|
addActivity("Stream started (user \(ev.userId))")
|
||||||
|
case .streamStopped:
|
||||||
|
addActivity("Stream stopped (user \(ev.userId))")
|
||||||
|
case .joinResult:
|
||||||
|
if ev.result == .ok {
|
||||||
|
currentChannelId = ev.channelId
|
||||||
|
addActivity("Joined channel")
|
||||||
|
refreshUsers()
|
||||||
|
} else {
|
||||||
|
addActivity("Join failed: \(ev.result.description)")
|
||||||
|
}
|
||||||
|
case .error:
|
||||||
|
addActivity("Error: \(ev.text ?? ev.result.description)")
|
||||||
|
case .genericResult:
|
||||||
|
if ev.result != .ok {
|
||||||
|
addActivity("Operation failed: \(ev.result.description)")
|
||||||
|
}
|
||||||
|
case .accountList:
|
||||||
|
accounts = client.listAccounts()
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addActivity(_ text: String) {
|
||||||
|
activityLog.append(ActivityEntry(timestamp: Date(), text: text))
|
||||||
|
if activityLog.count > 500 { activityLog.removeFirst() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data refresh
|
||||||
|
|
||||||
|
func refreshChannels() { channels = client.listChannels() }
|
||||||
|
func refreshUsers() { users = client.listUsers() }
|
||||||
|
func refreshDevices() { devices = client.listDevices(.input) }
|
||||||
|
|
||||||
|
// MARK: - Voice controls
|
||||||
|
|
||||||
|
func joinChannel(_ channelId: UInt32, password: String = "") {
|
||||||
|
client.joinChannel(channelId, password: password.isEmpty ? nil : password)
|
||||||
|
}
|
||||||
|
|
||||||
|
func leaveChannel() {
|
||||||
|
client.leaveChannel()
|
||||||
|
currentChannelId = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func startMicStream() {
|
||||||
|
AVAudioApplication.requestRecordPermission { [weak self] granted in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard let self else { return }
|
||||||
|
if granted {
|
||||||
|
self.doStartMicStream()
|
||||||
|
} else {
|
||||||
|
self.addActivity("Microphone permission denied — grant in Settings > Privacy > Microphone")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func doStartMicStream() {
|
||||||
|
do {
|
||||||
|
try AudioSessionManager.shared.activateForStreaming()
|
||||||
|
} catch {
|
||||||
|
addActivity("AVAudioSession activate failed: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let desc = StreamDescriptor(kind: .mic, deviceId: voiceState.currentDeviceId, label: "Mic")
|
||||||
|
let (result, streamId) = client.startStream(desc)
|
||||||
|
if result == .ok {
|
||||||
|
voiceState.micActive = true
|
||||||
|
voiceState.localStreamId = streamId
|
||||||
|
} else {
|
||||||
|
addActivity("Failed to start mic: \(result.description)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopMicStream() {
|
||||||
|
if voiceState.localStreamId != 0 {
|
||||||
|
client.stopStream(voiceState.localStreamId)
|
||||||
|
voiceState.localStreamId = 0
|
||||||
|
}
|
||||||
|
voiceState.micActive = false
|
||||||
|
voiceState.level = 0
|
||||||
|
AudioSessionManager.shared.deactivateAfterStreaming()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setMute(_ muted: Bool, deafened: Bool) {
|
||||||
|
client.setSelfMute(micMuted: muted, deafened: deafened)
|
||||||
|
voiceState.selfMuted = muted
|
||||||
|
voiceState.selfDeafened = deafened
|
||||||
|
}
|
||||||
|
|
||||||
|
func setInputMode(_ mode: VoiceCatInputMode) {
|
||||||
|
client.setInputMode(mode)
|
||||||
|
voiceState.inputMode = mode
|
||||||
|
}
|
||||||
|
|
||||||
|
func setVadThreshold(_ threshold: Float) {
|
||||||
|
client.setVadThreshold(threshold)
|
||||||
|
voiceState.vadThreshold = threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
func setPushToTalk(_ active: Bool) {
|
||||||
|
client.setPushToTalk(active)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Text
|
||||||
|
|
||||||
|
func sendText(_ text: String, scope: VoiceCatTextScope, targetId: UInt32 = 0) {
|
||||||
|
client.sendText(scope: scope, targetId: targetId, text: text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Admin
|
||||||
|
|
||||||
|
func kickUser(_ userId: UInt32, reason: String) {
|
||||||
|
client.kickUser(userId, reason: reason.isEmpty ? nil : reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
func banUser(_ userId: UInt32, reason: String, expiresUnixMs: UInt64) {
|
||||||
|
client.banUser(userId, reason: reason.isEmpty ? nil : reason, expiresUnixMs: expiresUnixMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func moveUser(_ userId: UInt32, toChannel channelId: UInt32) {
|
||||||
|
client.moveUser(userId, toChannel: channelId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setPermissions(_ userId: UInt32, perms: Permissions) {
|
||||||
|
client.setPermission(userId, perms: perms)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setServerMute(_ userId: UInt32, muted: Bool, deafened: Bool) {
|
||||||
|
client.setServerMute(userId, muted: muted, deafened: deafened)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createChannel(name: String, topic: String) {
|
||||||
|
let info = ChannelEdit(id: 0, parentId: 0, name: name, topic: topic,
|
||||||
|
passwordProtected: false, password: nil,
|
||||||
|
maxUsers: 0, sortOrder: 0, audio: AudioConfig())
|
||||||
|
client.createChannel(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteChannel(_ channelId: UInt32) {
|
||||||
|
client.deleteChannel(channelId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchAccountList() {
|
||||||
|
client.requestAccountList()
|
||||||
|
}
|
||||||
|
|
||||||
|
func createAccount(username: String, password: String) {
|
||||||
|
client.createAccount(username, password: password)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteAccount(username: String) {
|
||||||
|
client.deleteAccount(username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetPassword(username: String, newPassword: String) {
|
||||||
|
client.resetPassword(username, newPassword: newPassword)
|
||||||
|
}
|
||||||
|
}
|
||||||
153
clients/apple/iOS/VoiceCatiOS/Views/AccountsView.swift
Normal file
153
clients/apple/iOS/VoiceCatiOS/Views/AccountsView.swift
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
struct AccountsView: View {
|
||||||
|
@Bindable var session: SessionState
|
||||||
|
@State private var showCreateAccount = false
|
||||||
|
@State private var newUsername = ""
|
||||||
|
@State private var newPassword = ""
|
||||||
|
@State private var accountToDelete: Account?
|
||||||
|
@State private var showResetPassword = false
|
||||||
|
@State private var resetForAccount: Account?
|
||||||
|
@State private var resetPassword = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
ForEach(session.accounts, id: \.username) { account in
|
||||||
|
AccountRowView(account: account)
|
||||||
|
.swipeActions(edge: .trailing) {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
accountToDelete = account
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
resetForAccount = account
|
||||||
|
showResetPassword = true
|
||||||
|
} label: {
|
||||||
|
Label("Reset PW", systemImage: "key")
|
||||||
|
}
|
||||||
|
.tint(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Accounts")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button {
|
||||||
|
showCreateAccount = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Create account")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
session.fetchAccountList()
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
session.fetchAccountList()
|
||||||
|
}
|
||||||
|
.confirmationDialog("Delete account?", isPresented: Binding(
|
||||||
|
get: { accountToDelete != nil },
|
||||||
|
set: { if !$0 { accountToDelete = nil } }
|
||||||
|
)) {
|
||||||
|
if let a = accountToDelete {
|
||||||
|
Button("Delete \(a.username)", role: .destructive) {
|
||||||
|
session.deleteAccount(username: a.username)
|
||||||
|
accountToDelete = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) { accountToDelete = nil }
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showCreateAccount) {
|
||||||
|
CreateAccountSheet(session: session)
|
||||||
|
}
|
||||||
|
.alert("Reset Password", isPresented: $showResetPassword) {
|
||||||
|
SecureField("New password", text: $resetPassword)
|
||||||
|
.accessibilityLabel("New password for account")
|
||||||
|
Button("Reset") {
|
||||||
|
if let a = resetForAccount, !resetPassword.isEmpty {
|
||||||
|
session.resetPassword(username: a.username, newPassword: resetPassword)
|
||||||
|
}
|
||||||
|
resetPassword = ""
|
||||||
|
resetForAccount = nil
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {
|
||||||
|
resetPassword = ""
|
||||||
|
resetForAccount = nil
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text("Enter a new password for \(resetForAccount?.username ?? "").")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AccountRowView: View {
|
||||||
|
let account: Account
|
||||||
|
|
||||||
|
private var joinedDate: String {
|
||||||
|
let date = Date(timeIntervalSince1970: Double(account.createdAtUnixMs) / 1000)
|
||||||
|
return date.formatted(.dateTime.year().month().day())
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack {
|
||||||
|
Text(account.username)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
if account.isAdmin {
|
||||||
|
Text("admin")
|
||||||
|
.font(.caption2)
|
||||||
|
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||||
|
.background(.orange.opacity(0.2), in: Capsule())
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("Created \(joinedDate)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityLabel("\(account.username)\(account.isAdmin ? ", administrator" : ""), created \(joinedDate)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CreateAccountSheet: View {
|
||||||
|
@Bindable var session: SessionState
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var username = ""
|
||||||
|
@State private var password = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
TextField("Username", text: $username)
|
||||||
|
.textContentType(.username)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.accessibilityLabel("Username")
|
||||||
|
SecureField("Password", text: $password)
|
||||||
|
.textContentType(.newPassword)
|
||||||
|
.accessibilityLabel("Password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Create Account")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Create") {
|
||||||
|
session.createAccount(username: username, password: password)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.disabled(username.isEmpty || password.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
clients/apple/iOS/VoiceCatiOS/Views/ActivityLogView.swift
Normal file
49
clients/apple/iOS/VoiceCatiOS/Views/ActivityLogView.swift
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ActivityLogView: View {
|
||||||
|
@Bindable var session: SessionState
|
||||||
|
|
||||||
|
private static let timeFormatter: DateFormatter = {
|
||||||
|
let fmt = DateFormatter()
|
||||||
|
fmt.dateStyle = .none
|
||||||
|
fmt.timeStyle = .medium
|
||||||
|
return fmt
|
||||||
|
}()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
List(session.activityLog) { entry in
|
||||||
|
HStack(alignment: .top, spacing: 8) {
|
||||||
|
Text(Self.timeFormatter.string(from: entry.timestamp))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.monospacedDigit()
|
||||||
|
.frame(width: 64, alignment: .leading)
|
||||||
|
Text(entry.text)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.id(entry.id)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityLabel("\(Self.timeFormatter.string(from: entry.timestamp)): \(entry.text)")
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.onChange(of: session.activityLog.count) { _, _ in
|
||||||
|
if let last = session.activityLog.last {
|
||||||
|
proxy.scrollTo(last.id, anchor: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Activity")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.overlay {
|
||||||
|
if session.activityLog.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No Activity",
|
||||||
|
systemImage: "bell.slash",
|
||||||
|
description: Text("Events will appear here as they happen.")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
94
clients/apple/iOS/VoiceCatiOS/Views/AddServerView.swift
Normal file
94
clients/apple/iOS/VoiceCatiOS/Views/AddServerView.swift
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AddServerView: View {
|
||||||
|
@Environment(AppState.self) private var appState
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
let editing: SavedServer?
|
||||||
|
|
||||||
|
@State private var host = ""
|
||||||
|
@State private var port = "7878"
|
||||||
|
@State private var authMode = SavedServer.AuthMode.guest
|
||||||
|
@State private var username = ""
|
||||||
|
@State private var password = ""
|
||||||
|
@State private var savePassword = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("Server") {
|
||||||
|
TextField("Hostname or IP", text: $host)
|
||||||
|
.textContentType(.URL)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.accessibilityLabel("Server hostname or IP address")
|
||||||
|
TextField("Port", text: $port)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
.accessibilityLabel("Port number")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Authentication") {
|
||||||
|
Picker("Mode", selection: $authMode) {
|
||||||
|
Text("Guest").tag(SavedServer.AuthMode.guest)
|
||||||
|
Text("Account").tag(SavedServer.AuthMode.password)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.accessibilityLabel("Authentication mode")
|
||||||
|
|
||||||
|
if authMode == .password {
|
||||||
|
TextField("Username", text: $username)
|
||||||
|
.textContentType(.username)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.accessibilityLabel("Username")
|
||||||
|
SecureField("Password (optional)", text: $password)
|
||||||
|
.textContentType(.password)
|
||||||
|
.accessibilityLabel("Password, optional, leave blank to enter at connect time")
|
||||||
|
Toggle("Save password in Keychain", isOn: $savePassword)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(editing == nil ? "Add Server" : "Edit Server")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Save") { save() }
|
||||||
|
.disabled(host.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
|| UInt16(port) == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
if let s = editing {
|
||||||
|
host = s.host
|
||||||
|
port = "\(s.port)"
|
||||||
|
authMode = s.authMode
|
||||||
|
username = s.savedUsername
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
let trimmedHost = host.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !trimmedHost.isEmpty, let portNum = UInt16(port) else { return }
|
||||||
|
|
||||||
|
let pw = (savePassword && authMode == .password && !password.isEmpty) ? password : nil
|
||||||
|
|
||||||
|
if var s = editing {
|
||||||
|
s.host = trimmedHost
|
||||||
|
s.port = portNum
|
||||||
|
s.authMode = authMode
|
||||||
|
s.savedUsername = authMode == .password ? username : ""
|
||||||
|
appState.updateServer(s, password: pw)
|
||||||
|
} else {
|
||||||
|
let s = SavedServer(host: trimmedHost, port: portNum,
|
||||||
|
authMode: authMode,
|
||||||
|
savedUsername: authMode == .password ? username : "")
|
||||||
|
appState.addServer(s, password: pw)
|
||||||
|
}
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
58
clients/apple/iOS/VoiceCatiOS/Views/BanUserView.swift
Normal file
58
clients/apple/iOS/VoiceCatiOS/Views/BanUserView.swift
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
struct BanUserView: View {
|
||||||
|
let user: User
|
||||||
|
@Bindable var session: SessionState
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var reason = ""
|
||||||
|
@State private var permanent = true
|
||||||
|
@State private var duration: Double = 60 // minutes
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("Ban \(user.nickname)") {
|
||||||
|
TextField("Reason (optional)", text: $reason)
|
||||||
|
.accessibilityLabel("Ban reason, optional")
|
||||||
|
Toggle("Permanent", isOn: $permanent)
|
||||||
|
.accessibilityLabel("Permanent ban")
|
||||||
|
if !permanent {
|
||||||
|
HStack {
|
||||||
|
Text("Duration")
|
||||||
|
Slider(value: $duration, in: 1...10080, step: 1)
|
||||||
|
.accessibilityLabel("Ban duration in minutes")
|
||||||
|
Text(formattedDuration)
|
||||||
|
.monospacedDigit()
|
||||||
|
.frame(width: 60, alignment: .trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Ban User")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Ban", role: .destructive) {
|
||||||
|
let expiresMs: UInt64 = permanent ? 0
|
||||||
|
: UInt64(Date().timeIntervalSince1970 * 1000) + UInt64(duration * 60 * 1000)
|
||||||
|
session.banUser(user.id, reason: reason, expiresUnixMs: expiresMs)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var formattedDuration: String {
|
||||||
|
let mins = Int(duration)
|
||||||
|
if mins < 60 { return "\(mins)m" }
|
||||||
|
let hours = mins / 60
|
||||||
|
if hours < 24 { return "\(hours)h" }
|
||||||
|
return "\(hours / 24)d"
|
||||||
|
}
|
||||||
|
}
|
||||||
38
clients/apple/iOS/VoiceCatiOS/Views/ChannelEditView.swift
Normal file
38
clients/apple/iOS/VoiceCatiOS/Views/ChannelEditView.swift
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ChannelEditView: View {
|
||||||
|
let channelId: UInt32?
|
||||||
|
@Bindable var session: SessionState
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var name = ""
|
||||||
|
@State private var topic = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("Channel Info") {
|
||||||
|
TextField("Name", text: $name)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.accessibilityLabel("Channel name")
|
||||||
|
TextField("Topic (optional)", text: $topic)
|
||||||
|
.accessibilityLabel("Channel topic, optional")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(channelId == nil ? "New Channel" : "Edit Channel")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Save") {
|
||||||
|
session.createChannel(name: name, topic: topic)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
131
clients/apple/iOS/VoiceCatiOS/Views/ChannelTreeView.swift
Normal file
131
clients/apple/iOS/VoiceCatiOS/Views/ChannelTreeView.swift
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
// ChannelNode wraps Channel for OutlineGroup; childrenOrNil must be nil (not empty [])
|
||||||
|
// for leaf channels so OutlineGroup doesn't render expand buttons.
|
||||||
|
struct ChannelNode: Identifiable {
|
||||||
|
let channel: Channel
|
||||||
|
let children: [ChannelNode]?
|
||||||
|
var id: UInt32 { channel.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChannelTreeView: View {
|
||||||
|
@Bindable var session: SessionState
|
||||||
|
@State private var showCreateChannel = false
|
||||||
|
@State private var channelPassword = ""
|
||||||
|
@State private var passwordChannelId: UInt32?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List(channelTree, children: \.children) { node in
|
||||||
|
ChannelRowView(node: node, session: session)
|
||||||
|
.onTapGesture {
|
||||||
|
if node.channel.passwordProtected {
|
||||||
|
passwordChannelId = node.channel.id
|
||||||
|
} else {
|
||||||
|
session.joinChannel(node.channel.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.swipeActions(edge: .trailing) {
|
||||||
|
if session.permissions.isAdmin {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
session.deleteChannel(node.channel.id)
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.sidebar)
|
||||||
|
.toolbar {
|
||||||
|
if session.permissions.canCreateTempChannel || session.permissions.isAdmin {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button {
|
||||||
|
showCreateChannel = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Create channel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if session.currentChannelId != 0 {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button("Leave", systemImage: "arrow.left.circle") {
|
||||||
|
session.leaveChannel()
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Leave current channel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showCreateChannel) {
|
||||||
|
ChannelEditView(channelId: nil, session: session)
|
||||||
|
}
|
||||||
|
.alert("Channel Password", isPresented: Binding(
|
||||||
|
get: { passwordChannelId != nil },
|
||||||
|
set: { if !$0 { passwordChannelId = nil; channelPassword = "" } }
|
||||||
|
)) {
|
||||||
|
SecureField("Password", text: $channelPassword)
|
||||||
|
.accessibilityLabel("Channel password")
|
||||||
|
Button("Join") {
|
||||||
|
if let cid = passwordChannelId {
|
||||||
|
session.joinChannel(cid, password: channelPassword)
|
||||||
|
}
|
||||||
|
passwordChannelId = nil
|
||||||
|
channelPassword = ""
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {
|
||||||
|
passwordChannelId = nil
|
||||||
|
channelPassword = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var channelTree: [ChannelNode] {
|
||||||
|
buildTree(parentId: 0, channels: session.channels)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildTree(parentId: UInt32, channels: [Channel]) -> [ChannelNode] {
|
||||||
|
channels
|
||||||
|
.filter { $0.parentId == parentId }
|
||||||
|
.map { ch in
|
||||||
|
let kids = buildTree(parentId: ch.id, channels: channels)
|
||||||
|
return ChannelNode(channel: ch, children: kids.isEmpty ? nil : kids)
|
||||||
|
}
|
||||||
|
.sorted { $0.channel.name < $1.channel.name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ChannelRowView: View {
|
||||||
|
let node: ChannelNode
|
||||||
|
let session: SessionState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let ch = node.channel
|
||||||
|
let isCurrent = session.currentChannelId == ch.id
|
||||||
|
let usersHere = session.users.filter { $0.channelId == ch.id }
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: ch.passwordProtected ? "lock.fill" : "number")
|
||||||
|
.foregroundStyle(isCurrent ? .blue : .secondary)
|
||||||
|
.imageScale(.small)
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(ch.name)
|
||||||
|
.fontWeight(isCurrent ? .semibold : .regular)
|
||||||
|
if !ch.topic.isEmpty {
|
||||||
|
Text(ch.topic)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if !usersHere.isEmpty {
|
||||||
|
Text("\(usersHere.count)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityLabel("\(usersHere.count) users")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityLabel("\(ch.name)\(isCurrent ? ", current" : "")\(ch.passwordProtected ? ", password protected" : "")\(!usersHere.isEmpty ? ", \(usersHere.count) users" : "")")
|
||||||
|
}
|
||||||
|
}
|
||||||
94
clients/apple/iOS/VoiceCatiOS/Views/ChatView.swift
Normal file
94
clients/apple/iOS/VoiceCatiOS/Views/ChatView.swift
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
struct ChatView: View {
|
||||||
|
@Bindable var session: SessionState
|
||||||
|
@State private var composeText = ""
|
||||||
|
@State private var scope: VoiceCatTextScope = .channel
|
||||||
|
@State private var privateTargetId: UInt32 = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Message list
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
|
ForEach(session.messages) { msg in
|
||||||
|
ChatBubble(message: msg)
|
||||||
|
.id(msg.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.onChange(of: session.messages.count) { _, _ in
|
||||||
|
if let last = session.messages.last {
|
||||||
|
proxy.scrollTo(last.id, anchor: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Compose bar
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
TextField("Message…", text: $composeText, axis: .vertical)
|
||||||
|
.lineLimit(1...5)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.accessibilityLabel("Message text field")
|
||||||
|
.onSubmit { sendMessage() }
|
||||||
|
|
||||||
|
Button {
|
||||||
|
sendMessage()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.up.circle.fill")
|
||||||
|
.imageScale(.large)
|
||||||
|
}
|
||||||
|
.disabled(composeText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
|| session.currentChannelId == 0)
|
||||||
|
.accessibilityLabel("Send message")
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
.navigationTitle("Chat")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendMessage() {
|
||||||
|
let text = composeText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !text.isEmpty else { return }
|
||||||
|
session.sendText(text, scope: .channel)
|
||||||
|
composeText = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ChatBubble: View {
|
||||||
|
let message: ChatMessage
|
||||||
|
|
||||||
|
private var timeString: String {
|
||||||
|
let fmt = DateFormatter()
|
||||||
|
fmt.dateStyle = .none
|
||||||
|
fmt.timeStyle = .short
|
||||||
|
return fmt.string(from: message.timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(message.senderName)
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(timeString)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
Text(message.text)
|
||||||
|
.font(.body)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityLabel("\(message.senderName) at \(timeString): \(message.text)")
|
||||||
|
}
|
||||||
|
}
|
||||||
72
clients/apple/iOS/VoiceCatiOS/Views/MainView.swift
Normal file
72
clients/apple/iOS/VoiceCatiOS/Views/MainView.swift
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MainView: View {
|
||||||
|
@Environment(AppState.self) private var appState
|
||||||
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let session = appState.session {
|
||||||
|
if sizeClass == .regular {
|
||||||
|
iPadMainView(session: session)
|
||||||
|
} else {
|
||||||
|
iPhoneMainView(session: session)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ServerListView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - iPhone layout: TabView
|
||||||
|
|
||||||
|
private struct iPhoneMainView: View {
|
||||||
|
let session: SessionState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TabView {
|
||||||
|
ChannelTreeView(session: session)
|
||||||
|
.tabItem {
|
||||||
|
Label("Channels", systemImage: "list.bullet.indent")
|
||||||
|
}
|
||||||
|
ChatView(session: session)
|
||||||
|
.tabItem {
|
||||||
|
Label("Chat", systemImage: "message")
|
||||||
|
}
|
||||||
|
ActivityLogView(session: session)
|
||||||
|
.tabItem {
|
||||||
|
Label("Activity", systemImage: "bell")
|
||||||
|
}
|
||||||
|
SettingsView(session: session)
|
||||||
|
.tabItem {
|
||||||
|
Label("Settings", systemImage: "gear")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
VoiceControlsView(session: session)
|
||||||
|
.padding(.bottom, 56) // above tab bar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - iPad layout: NavigationSplitView
|
||||||
|
|
||||||
|
private struct iPadMainView: View {
|
||||||
|
let session: SessionState
|
||||||
|
@State private var columnVisibility = NavigationSplitViewVisibility.all
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||||
|
ChannelTreeView(session: session)
|
||||||
|
.navigationTitle("Channels")
|
||||||
|
} content: {
|
||||||
|
UserListView(session: session)
|
||||||
|
.navigationTitle("Users")
|
||||||
|
} detail: {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ChatView(session: session)
|
||||||
|
VoiceControlsView(session: session)
|
||||||
|
}
|
||||||
|
.navigationTitle("Chat")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
clients/apple/iOS/VoiceCatiOS/Views/MoveUserView.swift
Normal file
48
clients/apple/iOS/VoiceCatiOS/Views/MoveUserView.swift
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
struct MoveUserView: View {
|
||||||
|
let user: User
|
||||||
|
@Bindable var session: SessionState
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var selectedChannelId: UInt32 = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List(session.channels) { channel in
|
||||||
|
HStack {
|
||||||
|
Text(channel.name)
|
||||||
|
Spacer()
|
||||||
|
if channel.id == selectedChannelId {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture { selectedChannelId = channel.id }
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityLabel("\(channel.name)\(channel.id == selectedChannelId ? ", selected" : "")")
|
||||||
|
.accessibilityAddTraits(channel.id == selectedChannelId ? .isSelected : [])
|
||||||
|
}
|
||||||
|
.navigationTitle("Move \(user.nickname)")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Move") {
|
||||||
|
session.moveUser(user.id, toChannel: selectedChannelId)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.disabled(selectedChannelId == 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
selectedChannelId = user.channelId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
65
clients/apple/iOS/VoiceCatiOS/Views/PasswordPromptView.swift
Normal file
65
clients/apple/iOS/VoiceCatiOS/Views/PasswordPromptView.swift
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct PasswordPromptView: View {
|
||||||
|
@Environment(AppState.self) private var appState
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var username = ""
|
||||||
|
@State private var password = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
if let server = appState.connectingServer {
|
||||||
|
Text("Connecting to \(server.displayString)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
if appState.connectingServer?.authMode == .password {
|
||||||
|
TextField("Username", text: $username)
|
||||||
|
.textContentType(.username)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.accessibilityLabel("Username")
|
||||||
|
}
|
||||||
|
SecureField("Password", text: $password)
|
||||||
|
.textContentType(.password)
|
||||||
|
.accessibilityLabel("Password")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !appState.connectStatus.isEmpty && appState.connectStatus.lowercased().contains("failed") {
|
||||||
|
Section {
|
||||||
|
Text(appState.connectStatus)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.accessibilityLabel("Error: \(appState.connectStatus)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Sign In")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") {
|
||||||
|
dismiss()
|
||||||
|
appState.cancelConnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Connect") {
|
||||||
|
dismiss()
|
||||||
|
let uname = appState.connectingServer?.savedUsername.isEmpty == false
|
||||||
|
? appState.connectingServer!.savedUsername
|
||||||
|
: username
|
||||||
|
appState.authenticateUser(username: uname, password: password)
|
||||||
|
}
|
||||||
|
.disabled(password.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
username = appState.connectingServer?.savedUsername ?? ""
|
||||||
|
}
|
||||||
|
.interactiveDismissDisabled()
|
||||||
|
}
|
||||||
|
}
|
||||||
71
clients/apple/iOS/VoiceCatiOS/Views/PerUserTuningView.swift
Normal file
71
clients/apple/iOS/VoiceCatiOS/Views/PerUserTuningView.swift
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
struct PerUserTuningView: View {
|
||||||
|
let user: User
|
||||||
|
@Bindable var session: SessionState
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var gain: Float = 1.0
|
||||||
|
@State private var muted = false
|
||||||
|
@State private var noiseReduction = false
|
||||||
|
|
||||||
|
private var streamsForUser: [StreamSummary] {
|
||||||
|
session.client.listUserStreams(user.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("Volume") {
|
||||||
|
HStack {
|
||||||
|
Text("Gain")
|
||||||
|
Slider(value: $gain, in: 0...2, step: 0.05) { _ in
|
||||||
|
applyToAllStreams()
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Volume gain for \(user.nickname)")
|
||||||
|
Text(String(format: "%.0f%%", gain * 100))
|
||||||
|
.monospacedDigit()
|
||||||
|
.frame(width: 44, alignment: .trailing)
|
||||||
|
}
|
||||||
|
Toggle("Mute", isOn: $muted)
|
||||||
|
.onChange(of: muted) { _, _ in applyToAllStreams() }
|
||||||
|
.accessibilityLabel("Mute \(user.nickname)")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Audio Processing") {
|
||||||
|
Toggle("Noise Reduction", isOn: $noiseReduction)
|
||||||
|
.onChange(of: noiseReduction) { _, _ in applyToAllStreams() }
|
||||||
|
.accessibilityLabel("Noise reduction for \(user.nickname)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(user.nickname)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
// Load from first stream if available
|
||||||
|
let streams = streamsForUser
|
||||||
|
if let first = streams.first {
|
||||||
|
let (_, state) = session.client.getRemoteStream(userId: user.id, streamId: first.id)
|
||||||
|
if let s = state {
|
||||||
|
gain = s.gain
|
||||||
|
muted = s.muted
|
||||||
|
noiseReduction = s.noiseReduction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyToAllStreams() {
|
||||||
|
for stream in streamsForUser {
|
||||||
|
session.client.setRemoteStream(
|
||||||
|
userId: user.id, streamId: stream.id,
|
||||||
|
gain: gain, muted: muted, noiseReduction: noiseReduction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
63
clients/apple/iOS/VoiceCatiOS/Views/PermissionsView.swift
Normal file
63
clients/apple/iOS/VoiceCatiOS/Views/PermissionsView.swift
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
struct PermissionsView: View {
|
||||||
|
let user: User
|
||||||
|
@Bindable var session: SessionState
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var canCreateTempChannel = false
|
||||||
|
@State private var canKick = false
|
||||||
|
@State private var canBan = false
|
||||||
|
@State private var canMoveUsers = false
|
||||||
|
@State private var canAdminAccounts = false
|
||||||
|
@State private var isAdmin = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("Permissions for \(user.nickname)") {
|
||||||
|
Toggle("Create Temp Channels", isOn: $canCreateTempChannel)
|
||||||
|
.accessibilityLabel("Can create temporary channels")
|
||||||
|
Toggle("Kick Users", isOn: $canKick)
|
||||||
|
.accessibilityLabel("Can kick users")
|
||||||
|
Toggle("Ban Users", isOn: $canBan)
|
||||||
|
.accessibilityLabel("Can ban users")
|
||||||
|
Toggle("Move Users", isOn: $canMoveUsers)
|
||||||
|
.accessibilityLabel("Can move users between channels")
|
||||||
|
Toggle("Manage Accounts", isOn: $canAdminAccounts)
|
||||||
|
.accessibilityLabel("Can manage server accounts")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Toggle("Administrator", isOn: $isAdmin)
|
||||||
|
.foregroundStyle(isAdmin ? .orange : .primary)
|
||||||
|
.accessibilityLabel("Full administrator access")
|
||||||
|
} footer: {
|
||||||
|
Text("Administrators bypass all permission checks.")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Permissions")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Save") {
|
||||||
|
let perms = Permissions(
|
||||||
|
canCreateTempChannel: canCreateTempChannel,
|
||||||
|
canKick: canKick,
|
||||||
|
canBan: canBan,
|
||||||
|
canMoveUsers: canMoveUsers,
|
||||||
|
canAdminAccounts: canAdminAccounts,
|
||||||
|
isAdmin: isAdmin)
|
||||||
|
session.setPermissions(user.id, perms: perms)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
clients/apple/iOS/VoiceCatiOS/Views/ServerIdentityView.swift
Normal file
75
clients/apple/iOS/VoiceCatiOS/Views/ServerIdentityView.swift
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
struct ServerIdentityView: View {
|
||||||
|
@Environment(AppState.self) private var appState
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
let identity: PendingIdentity
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
if identity.tofuStatus == .mismatch {
|
||||||
|
Label("Server Identity Mismatch", systemImage: "exclamationmark.triangle.fill")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.accessibilityLabel("Warning: server identity mismatch")
|
||||||
|
Text("The server's identity has changed since your last connection. This may indicate a man-in-the-middle attack, or that the server was reinstalled. Do NOT accept unless you know why the identity changed.")
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
} else {
|
||||||
|
Label("New Server Identity", systemImage: "lock.badge.questionmark")
|
||||||
|
.font(.headline)
|
||||||
|
.accessibilityLabel("New server identity")
|
||||||
|
Text("This is the first time you are connecting to this server. Verify the fingerprint below with the server administrator before accepting.")
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Server fingerprint")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(identity.displayText.isEmpty ? "(not available)" : identity.displayText)
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.accessibilityLabel("Server fingerprint: \(identity.displayText)")
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 8))
|
||||||
|
|
||||||
|
Spacer(minLength: 24)
|
||||||
|
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Button {
|
||||||
|
dismiss()
|
||||||
|
appState.confirmServerIdentity(accept: true)
|
||||||
|
} label: {
|
||||||
|
Text("Accept")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(identity.tofuStatus == .mismatch ? .orange : .blue)
|
||||||
|
.accessibilityLabel("Accept server identity and continue")
|
||||||
|
|
||||||
|
Button(role: .destructive) {
|
||||||
|
dismiss()
|
||||||
|
appState.confirmServerIdentity(accept: false)
|
||||||
|
} label: {
|
||||||
|
Text("Reject — Disconnect")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.accessibilityLabel("Reject server identity and disconnect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.navigationTitle("Server Identity")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
.interactiveDismissDisabled()
|
||||||
|
}
|
||||||
|
}
|
||||||
120
clients/apple/iOS/VoiceCatiOS/Views/ServerListView.swift
Normal file
120
clients/apple/iOS/VoiceCatiOS/Views/ServerListView.swift
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ServerListView: View {
|
||||||
|
@Environment(AppState.self) private var appState
|
||||||
|
@State private var serverToDelete: SavedServer?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
@Bindable var state = appState
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if appState.servers.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No Servers",
|
||||||
|
systemImage: "server.rack",
|
||||||
|
description: Text("Tap + to add a server.")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
ForEach(appState.servers) { server in
|
||||||
|
Button {
|
||||||
|
appState.connectTo(server)
|
||||||
|
} label: {
|
||||||
|
ServerRowView(server: server)
|
||||||
|
}
|
||||||
|
.swipeActions(edge: .trailing) {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
serverToDelete = server
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
appState.editingServer = server
|
||||||
|
} label: {
|
||||||
|
Label("Edit", systemImage: "pencil")
|
||||||
|
}
|
||||||
|
.tint(.blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Servers")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button {
|
||||||
|
appState.showAddServer = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Add server")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $state.showAddServer) {
|
||||||
|
AddServerView(editing: nil)
|
||||||
|
}
|
||||||
|
.sheet(item: $state.editingServer) { server in
|
||||||
|
AddServerView(editing: server)
|
||||||
|
}
|
||||||
|
.sheet(item: $state.pendingIdentity) { identity in
|
||||||
|
ServerIdentityView(identity: identity)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $state.showPasswordPrompt) {
|
||||||
|
PasswordPromptView()
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
if appState.isConnecting {
|
||||||
|
ConnectingOverlay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.confirmationDialog("Delete server?", isPresented: Binding(
|
||||||
|
get: { serverToDelete != nil },
|
||||||
|
set: { if !$0 { serverToDelete = nil } }
|
||||||
|
)) {
|
||||||
|
if let s = serverToDelete {
|
||||||
|
Button("Delete \(s.displayString)", role: .destructive) {
|
||||||
|
appState.removeServer(s)
|
||||||
|
serverToDelete = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) { serverToDelete = nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ServerRowView: View {
|
||||||
|
let server: SavedServer
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(server.displayString)
|
||||||
|
.font(.body)
|
||||||
|
Text(server.authMode == .guest
|
||||||
|
? "Guest"
|
||||||
|
: "Account: \(server.savedUsername)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityLabel("\(server.displayString), \(server.authMode == .guest ? "guest" : "account \(server.savedUsername)")")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ConnectingOverlay: View {
|
||||||
|
@Environment(AppState.self) private var appState
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.black.opacity(0.3).ignoresSafeArea()
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
ProgressView()
|
||||||
|
Text(appState.connectStatus)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
Button("Cancel") { appState.cancelConnect() }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(.white)
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
94
clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift
Normal file
94
clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
struct SettingsView: View {
|
||||||
|
@Environment(AppState.self) private var appState
|
||||||
|
@Bindable var session: SessionState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
// Voice input section
|
||||||
|
Section("Voice Input") {
|
||||||
|
Picker("Input Mode", selection: Binding(
|
||||||
|
get: { session.voiceState.inputMode },
|
||||||
|
set: { session.setInputMode($0) }
|
||||||
|
)) {
|
||||||
|
Text("Voice Activation").tag(VoiceCatInputMode.voiceActivation)
|
||||||
|
Text("Push to Talk").tag(VoiceCatInputMode.pushToTalk)
|
||||||
|
Text("Always On").tag(VoiceCatInputMode.alwaysOn)
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Voice input mode")
|
||||||
|
|
||||||
|
if session.voiceState.inputMode == .voiceActivation {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("VAD Threshold: \(String(format: "%.3f", session.voiceState.vadThreshold))")
|
||||||
|
.font(.caption)
|
||||||
|
Slider(
|
||||||
|
value: Binding(
|
||||||
|
get: { Double(session.voiceState.vadThreshold) },
|
||||||
|
set: { session.setVadThreshold(Float($0)) }
|
||||||
|
),
|
||||||
|
in: 0.001...0.1, step: 0.001
|
||||||
|
)
|
||||||
|
.accessibilityLabel("Voice activation threshold")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio device section
|
||||||
|
if !session.devices.isEmpty {
|
||||||
|
Section("Input Device") {
|
||||||
|
Picker("Microphone", selection: Binding(
|
||||||
|
get: { session.voiceState.currentDeviceId ?? "" },
|
||||||
|
set: { id in
|
||||||
|
session.voiceState.currentDeviceId = id.isEmpty ? nil : id
|
||||||
|
if session.voiceState.localStreamId != 0 {
|
||||||
|
session.client.setInputDevice(
|
||||||
|
streamId: session.voiceState.localStreamId,
|
||||||
|
deviceId: id.isEmpty ? nil : id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)) {
|
||||||
|
Text("Default").tag("")
|
||||||
|
ForEach(session.devices) { dev in
|
||||||
|
Text(dev.name).tag(dev.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Microphone selection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin section
|
||||||
|
if session.permissions.canAdminAccounts || session.permissions.isAdmin {
|
||||||
|
Section("Administration") {
|
||||||
|
NavigationLink("Manage Accounts") {
|
||||||
|
AccountsView(session: session)
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Manage server accounts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server info
|
||||||
|
Section("Server") {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
session.stopMicStream()
|
||||||
|
appState.disconnect()
|
||||||
|
} label: {
|
||||||
|
Label("Disconnect", systemImage: "phone.down")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Disconnect from server")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("About") {
|
||||||
|
Text(VoiceCatClient.versionString)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityLabel("Version: \(VoiceCatClient.versionString)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
144
clients/apple/iOS/VoiceCatiOS/Views/UserListView.swift
Normal file
144
clients/apple/iOS/VoiceCatiOS/Views/UserListView.swift
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
struct UserListView: View {
|
||||||
|
@Bindable var session: SessionState
|
||||||
|
@State private var selectedUser: User?
|
||||||
|
@State private var showPerUserTuning = false
|
||||||
|
@State private var showBanUser = false
|
||||||
|
@State private var showMoveUser = false
|
||||||
|
@State private var showPermissions = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let channelUsers = session.currentChannelId == 0
|
||||||
|
? session.users
|
||||||
|
: session.users.filter { $0.channelId == session.currentChannelId }
|
||||||
|
|
||||||
|
List(channelUsers) { user in
|
||||||
|
UserRowView(user: user, isSelf: user.id == session.selfUserId)
|
||||||
|
.contextMenu {
|
||||||
|
userContextMenu(user: user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.overlay {
|
||||||
|
if channelUsers.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No Users",
|
||||||
|
systemImage: "person.slash",
|
||||||
|
description: Text(session.currentChannelId == 0 ? "Join a channel to see users." : "No one else here yet.")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: $selectedUser) { user in
|
||||||
|
if showPerUserTuning {
|
||||||
|
PerUserTuningView(user: user, session: session)
|
||||||
|
} else if showBanUser {
|
||||||
|
BanUserView(user: user, session: session)
|
||||||
|
} else if showMoveUser {
|
||||||
|
MoveUserView(user: user, session: session)
|
||||||
|
} else if showPermissions {
|
||||||
|
PermissionsView(user: user, session: session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func userContextMenu(user: User) -> some View {
|
||||||
|
if user.id != session.selfUserId {
|
||||||
|
Button {
|
||||||
|
selectedUser = user
|
||||||
|
showPerUserTuning = true
|
||||||
|
showBanUser = false; showMoveUser = false; showPermissions = false
|
||||||
|
} label: {
|
||||||
|
Label("Volume / NR", systemImage: "speaker.wave.2")
|
||||||
|
}
|
||||||
|
if session.permissions.canKick || session.permissions.isAdmin {
|
||||||
|
Divider()
|
||||||
|
Button {
|
||||||
|
session.kickUser(user.id, reason: "")
|
||||||
|
} label: {
|
||||||
|
Label("Kick", systemImage: "person.fill.xmark")
|
||||||
|
}
|
||||||
|
if session.permissions.canBan || session.permissions.isAdmin {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
selectedUser = user
|
||||||
|
showBanUser = true
|
||||||
|
showPerUserTuning = false; showMoveUser = false; showPermissions = false
|
||||||
|
} label: {
|
||||||
|
Label("Ban…", systemImage: "nosign")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if session.permissions.canMoveUsers || session.permissions.isAdmin {
|
||||||
|
Button {
|
||||||
|
selectedUser = user
|
||||||
|
showMoveUser = true
|
||||||
|
showPerUserTuning = false; showBanUser = false; showPermissions = false
|
||||||
|
} label: {
|
||||||
|
Label("Move to channel…", systemImage: "arrow.right.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if session.permissions.isAdmin {
|
||||||
|
Divider()
|
||||||
|
let muted = user.serverMuted
|
||||||
|
Button {
|
||||||
|
session.setServerMute(user.id, muted: !muted, deafened: user.serverDeafened)
|
||||||
|
} label: {
|
||||||
|
Label(muted ? "Unmute" : "Server Mute", systemImage: muted ? "mic" : "mic.slash")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
selectedUser = user
|
||||||
|
showPermissions = true
|
||||||
|
showPerUserTuning = false; showBanUser = false; showMoveUser = false
|
||||||
|
} label: {
|
||||||
|
Label("Permissions…", systemImage: "lock.shield")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct UserRowView: View {
|
||||||
|
let user: User
|
||||||
|
let isSelf: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: user.selfMicMuted || user.serverMuted ? "mic.slash.fill" : "mic.fill")
|
||||||
|
.foregroundStyle(user.selfMicMuted || user.serverMuted ? .red : .green)
|
||||||
|
.imageScale(.small)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(user.nickname)
|
||||||
|
.fontWeight(isSelf ? .semibold : .regular)
|
||||||
|
if isSelf {
|
||||||
|
Text("(you)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
if user.isGuest {
|
||||||
|
Text("guest")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if user.serverMuted || user.serverDeafened {
|
||||||
|
Text(user.serverDeafened ? "server deafened" : "server muted")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if user.selfDeafened {
|
||||||
|
Image(systemName: "headphones.slash")
|
||||||
|
.imageScale(.small)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityLabel("\(user.nickname)\(isSelf ? ", you" : "")\(user.isGuest ? ", guest" : "")\(user.selfMicMuted ? ", muted" : "")\(user.serverMuted ? ", server muted" : "")")
|
||||||
|
}
|
||||||
|
}
|
||||||
130
clients/apple/iOS/VoiceCatiOS/Views/VoiceControlsView.swift
Normal file
130
clients/apple/iOS/VoiceCatiOS/Views/VoiceControlsView.swift
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
struct VoiceControlsView: View {
|
||||||
|
@Bindable var session: SessionState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
// Mic toggle / PTT button
|
||||||
|
if session.voiceState.inputMode == .pushToTalk {
|
||||||
|
PTTButton(session: session)
|
||||||
|
} else {
|
||||||
|
Button {
|
||||||
|
if session.voiceState.micActive {
|
||||||
|
session.stopMicStream()
|
||||||
|
} else {
|
||||||
|
session.startMicStream()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: session.voiceState.micActive ? "mic.fill" : "mic.slash.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.background(session.voiceState.micActive ? Color.green : Color(.systemGray4), in: Circle())
|
||||||
|
.foregroundStyle(session.voiceState.micActive ? .white : .primary)
|
||||||
|
}
|
||||||
|
.disabled(session.currentChannelId == 0)
|
||||||
|
.accessibilityLabel(session.voiceState.micActive ? "Stop microphone" : "Start microphone")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Level meter
|
||||||
|
LevelMeterView(level: session.voiceState.level)
|
||||||
|
.frame(width: 80, height: 8)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Self mute
|
||||||
|
Button {
|
||||||
|
session.setMute(!session.voiceState.selfMuted, deafened: session.voiceState.selfDeafened)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: session.voiceState.selfMuted ? "mic.slash" : "mic")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(session.voiceState.selfMuted ? .red : .primary)
|
||||||
|
}
|
||||||
|
.accessibilityLabel(session.voiceState.selfMuted ? "Unmute microphone" : "Mute microphone")
|
||||||
|
|
||||||
|
// Self deafen
|
||||||
|
Button {
|
||||||
|
session.setMute(session.voiceState.selfMuted, deafened: !session.voiceState.selfDeafened)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: session.voiceState.selfDeafened ? "headphones.slash" : "headphones")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(session.voiceState.selfDeafened ? .red : .primary)
|
||||||
|
}
|
||||||
|
.accessibilityLabel(session.voiceState.selfDeafened ? "Undeafen" : "Deafen")
|
||||||
|
|
||||||
|
// Disconnect
|
||||||
|
Button(role: .destructive) {
|
||||||
|
session.stopMicStream()
|
||||||
|
session.client.disconnect()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "phone.down.fill")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Disconnect from server")
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(.bar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PTT Button (DragGesture instead of NSEvent on iOS)
|
||||||
|
|
||||||
|
private struct PTTButton: View {
|
||||||
|
@Bindable var session: SessionState
|
||||||
|
@GestureState private var isPressing = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Circle()
|
||||||
|
.fill(isPressing ? Color.blue : Color(.systemGray4))
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.overlay {
|
||||||
|
Image(systemName: "mic.fill")
|
||||||
|
.foregroundStyle(isPressing ? .white : .primary)
|
||||||
|
}
|
||||||
|
.gesture(
|
||||||
|
DragGesture(minimumDistance: 0)
|
||||||
|
.updating($isPressing) { _, state, _ in state = true }
|
||||||
|
.onChanged { _ in
|
||||||
|
if !isPressing { return }
|
||||||
|
if !session.voiceState.micActive { session.startMicStream() }
|
||||||
|
session.setPushToTalk(true)
|
||||||
|
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||||
|
}
|
||||||
|
.onEnded { _ in
|
||||||
|
session.setPushToTalk(false)
|
||||||
|
session.stopMicStream()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.accessibilityLabel("Push to talk, hold to transmit")
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Level Meter
|
||||||
|
|
||||||
|
private struct LevelMeterView: View {
|
||||||
|
let level: Float
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geo in
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(Color(.systemGray5))
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(levelColor)
|
||||||
|
.frame(width: geo.size.width * CGFloat(min(level * 10, 1.0)))
|
||||||
|
.animation(.linear(duration: 0.05), value: level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var levelColor: Color {
|
||||||
|
if level > 0.15 { return .orange }
|
||||||
|
if level > 0.05 { return .green }
|
||||||
|
return .green.opacity(0.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
10
clients/apple/iOS/VoiceCatiOS/VoiceCatiOS.entitlements
Normal file
10
clients/apple/iOS/VoiceCatiOS/VoiceCatiOS.entitlements
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.cat.voice.VoiceCat</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
18
clients/apple/iOS/VoiceCatiOS/VoiceCatiOSApp.swift
Normal file
18
clients/apple/iOS/VoiceCatiOS/VoiceCatiOSApp.swift
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct VoiceCatiOSApp: App {
|
||||||
|
@State private var appState = AppState()
|
||||||
|
|
||||||
|
init() {
|
||||||
|
AudioSessionManager.shared.configure()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
MainView()
|
||||||
|
.environment(appState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,7 +73,7 @@ echo "[build-xcframework] VCPKG_ROOT=$VCPKG_ROOT"
|
|||||||
|
|
||||||
# ── Build each requested slice ────────────────────────────────────────────────────
|
# ── Build each requested slice ────────────────────────────────────────────────────
|
||||||
build_slice() {
|
build_slice() {
|
||||||
local preset="$1" slice_name="$2"
|
local preset="$1" slice_name="$2" vcpkg_triplet="$3"
|
||||||
echo "[build-xcframework] === $slice_name: configure + build ($preset) ==="
|
echo "[build-xcframework] === $slice_name: configure + build ($preset) ==="
|
||||||
if $DO_CONFIGURE; then
|
if $DO_CONFIGURE; then
|
||||||
cmake --preset "$preset"
|
cmake --preset "$preset"
|
||||||
@@ -97,25 +97,29 @@ build_slice() {
|
|||||||
# Without this, the final executable (test runner / app) would get undefined-symbol
|
# Without this, the final executable (test runner / app) would get undefined-symbol
|
||||||
# linker errors for protobuf/mbedtls/sodium/opus/… symbols that libvoicecat.a references
|
# linker errors for protobuf/mbedtls/sodium/opus/… symbols that libvoicecat.a references
|
||||||
# but doesn't contain. See clients/apple/README.md "Fat static library" section.
|
# but doesn't contain. See clients/apple/README.md "Fat static library" section.
|
||||||
local vcpkg_libs_dir="$REPO_ROOT/build/$preset/vcpkg_installed"
|
#
|
||||||
|
# IMPORTANT: search only the TARGET triplet's lib dir, not all of vcpkg_installed/.
|
||||||
|
# Cross-compile builds install a HOST triplet directory alongside the target (e.g.
|
||||||
|
# arm64-osx/ next to arm64-ios/) — that host directory contains macOS binaries needed
|
||||||
|
# to build protoc/etc at configure time. Merging those macOS .a files into the iOS
|
||||||
|
# fat library triggers xcodebuild's "binaries with multiple platforms" rejection.
|
||||||
|
local vcpkg_target_lib_dir="$REPO_ROOT/build/$preset/vcpkg_installed/$vcpkg_triplet/lib"
|
||||||
local fat_lib="$REPO_ROOT/build/$preset/lib/libvoicecat-fat.a"
|
local fat_lib="$REPO_ROOT/build/$preset/lib/libvoicecat-fat.a"
|
||||||
echo "[build-xcframework] $slice_name: merging vcpkg deps into fat static lib"
|
echo "[build-xcframework] $slice_name: merging vcpkg deps into fat static lib (triplet: $vcpkg_triplet)"
|
||||||
# Collect all .a files (libvoicecat.a + every vcpkg .a). libtool -static concatenates
|
# Collect all .a files (libvoicecat.a + every vcpkg target .a). libtool -static concatenates
|
||||||
# object files from all input archives; duplicate-object warnings are benign (the linker
|
# object files from all input archives; duplicate-object warnings are benign (the linker
|
||||||
# resolves duplicates at final link time). "no symbols" warnings are for empty AVX2/AVX512
|
# resolves duplicates at final link time). "no symbols" warnings are for empty AVX2/AVX512
|
||||||
# objects on arm64 — also benign.
|
# objects on arm64 — also benign.
|
||||||
local all_libs=( "$lib" )
|
local all_libs=( "$lib" )
|
||||||
# Only release libs (arm64-osx/lib/*.a), NOT debug libs (arm64-osx/debug/lib/*.a) —
|
while IFS= read -r f; do all_libs+=( "$f" ); done < <(find "$vcpkg_target_lib_dir" -name '*.a' -not -name 'libvoicecat*' | sort)
|
||||||
# vcpkg installs both; the debug libs are ~10x larger and not needed for a Release build.
|
|
||||||
while IFS= read -r f; do all_libs+=( "$f" ); done < <(find "$vcpkg_libs_dir" -name '*.a' -not -name 'libvoicecat*' -not -path '*/debug/*' | sort)
|
|
||||||
libtool -static -o "$fat_lib" "${all_libs[@]}" 2>&1 | grep -v 'has no symbols' || true
|
libtool -static -o "$fat_lib" "${all_libs[@]}" 2>&1 | grep -v 'has no symbols' || true
|
||||||
echo "[build-xcframework] $slice_name -> $fat_lib ($(stat -f%z "$fat_lib") bytes, fat)"
|
echo "[build-xcframework] $slice_name -> $fat_lib ($(stat -f%z "$fat_lib") bytes, fat)"
|
||||||
}
|
}
|
||||||
|
|
||||||
args=()
|
args=()
|
||||||
if $BUILD_MACOS; then build_slice apple-dev "macOS (arm64-osx)"; args+=( -library "$REPO_ROOT/build/apple-dev/lib/libvoicecat-fat.a" -headers "$APPLE_DIR/.staged-headers/macos" ); fi
|
if $BUILD_MACOS; then build_slice apple-dev "macOS (arm64-osx)" "arm64-osx"; args+=( -library "$REPO_ROOT/build/apple-dev/lib/libvoicecat-fat.a" -headers "$APPLE_DIR/.staged-headers/macos" ); fi
|
||||||
if $BUILD_IOS_DEVICE; then build_slice apple-ios "iOS device (arm64-ios)"; args+=( -library "$REPO_ROOT/build/apple-ios/lib/libvoicecat-fat.a" -headers "$APPLE_DIR/.staged-headers/ios" ); fi
|
if $BUILD_IOS_DEVICE; then build_slice apple-ios "iOS device (arm64-ios)" "arm64-ios"; args+=( -library "$REPO_ROOT/build/apple-ios/lib/libvoicecat-fat.a" -headers "$APPLE_DIR/.staged-headers/ios" ); fi
|
||||||
if $BUILD_IOS_SIM; then build_slice apple-ios-sim "iOS sim (arm64-ios-sim)"; args+=( -library "$REPO_ROOT/build/apple-ios-sim/lib/libvoicecat-fat.a" -headers "$APPLE_DIR/.staged-headers/ios-sim" ); fi
|
if $BUILD_IOS_SIM; then build_slice apple-ios-sim "iOS sim (arm64-ios-sim)" "arm64-ios-simulator"; args+=( -library "$REPO_ROOT/build/apple-ios-sim/lib/libvoicecat-fat.a" -headers "$APPLE_DIR/.staged-headers/ios-sim" ); fi
|
||||||
|
|
||||||
# ── Stage headers + module map ───────────────────────────────────────────────────
|
# ── Stage headers + module map ───────────────────────────────────────────────────
|
||||||
# Each slice gets its own headers dir (xcodebuild -create-xcframework requires a -headers
|
# Each slice gets its own headers dir (xcodebuild -create-xcframework requires a -headers
|
||||||
|
|||||||
8
cmake/toolchains/ios-device.cmake
Normal file
8
cmake/toolchains/ios-device.cmake
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Chainload toolchain for iOS device cross-compilation (arm64-ios vcpkg triplet).
|
||||||
|
# Loaded by vcpkg when building all dependencies for the arm64-ios target, ensuring
|
||||||
|
# every dep is compiled against the iPhone OS SDK (not the macOS SDK).
|
||||||
|
set(CMAKE_SYSTEM_NAME iOS)
|
||||||
|
set(CMAKE_SYSTEM_PROCESSOR arm64)
|
||||||
|
set(CMAKE_OSX_ARCHITECTURES arm64)
|
||||||
|
set(CMAKE_OSX_SYSROOT iphoneos)
|
||||||
|
set(CMAKE_OSX_DEPLOYMENT_TARGET 17.0)
|
||||||
8
cmake/toolchains/ios-simulator.cmake
Normal file
8
cmake/toolchains/ios-simulator.cmake
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Chainload toolchain for iOS simulator cross-compilation (arm64-ios-simulator vcpkg triplet).
|
||||||
|
# Loaded by vcpkg when building all dependencies for the arm64-ios-simulator target, ensuring
|
||||||
|
# every dep is compiled against the iphonesimulator SDK (platform tag IOSSIMULATOR, not IOS).
|
||||||
|
set(CMAKE_SYSTEM_NAME iOS)
|
||||||
|
set(CMAKE_SYSTEM_PROCESSOR arm64)
|
||||||
|
set(CMAKE_OSX_ARCHITECTURES arm64)
|
||||||
|
set(CMAKE_OSX_SYSROOT iphonesimulator)
|
||||||
|
set(CMAKE_OSX_DEPLOYMENT_TARGET 17.0)
|
||||||
13
cmake/vcpkg-overlays/triplets/arm64-ios-simulator.cmake
Normal file
13
cmake/vcpkg-overlays/triplets/arm64-ios-simulator.cmake
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
set(VCPKG_TARGET_ARCHITECTURE arm64)
|
||||||
|
set(VCPKG_CRT_LINKAGE dynamic)
|
||||||
|
set(VCPKG_LIBRARY_LINKAGE static)
|
||||||
|
set(VCPKG_CMAKE_SYSTEM_NAME iOS)
|
||||||
|
# Release-only: debug iOS binaries are never shipped and skipping them halves build time.
|
||||||
|
set(VCPKG_BUILD_TYPE release)
|
||||||
|
# Override the autoconf --host triple (same rationale as arm64-ios.cmake — prevents autoconf from
|
||||||
|
# treating cross-compilation as a native build and trying to run arm64-ios binaries on macOS).
|
||||||
|
set(VCPKG_MAKE_BUILD_TRIPLET "--host=aarch64-apple-ios17.0-simulator")
|
||||||
|
# Chainload toolchain: forces every vcpkg dep to compile against the iphonesimulator SDK so all
|
||||||
|
# objects carry the platform IOSSIMULATOR tag (not IOS or MACOS). Critical for xcodebuild's
|
||||||
|
# -create-xcframework which rejects fat libs that mix platform tags.
|
||||||
|
set(VCPKG_CHAINLOAD_TOOLCHAIN_FILE "${CMAKE_CURRENT_LIST_DIR}/../../toolchains/ios-simulator.cmake")
|
||||||
14
cmake/vcpkg-overlays/triplets/arm64-ios.cmake
Normal file
14
cmake/vcpkg-overlays/triplets/arm64-ios.cmake
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
set(VCPKG_TARGET_ARCHITECTURE arm64)
|
||||||
|
set(VCPKG_CRT_LINKAGE dynamic)
|
||||||
|
set(VCPKG_LIBRARY_LINKAGE static)
|
||||||
|
set(VCPKG_CMAKE_SYSTEM_NAME iOS)
|
||||||
|
# Release-only: debug iOS binaries are never shipped and skipping them halves build time.
|
||||||
|
set(VCPKG_BUILD_TYPE release)
|
||||||
|
# Override the autoconf --host triple. Without this, vcpkg uses arm64-apple-darwin (same as the
|
||||||
|
# macOS build machine), which makes autoconf think it's a native build and try to run compiled
|
||||||
|
# programs — those are iOS arm64 binaries and can't execute on macOS. An ios-suffixed triple
|
||||||
|
# signals cross-compilation and disables all run-time configure checks.
|
||||||
|
set(VCPKG_MAKE_BUILD_TRIPLET "--host=aarch64-apple-ios17.0")
|
||||||
|
# Chainload toolchain: forces every vcpkg dep to compile against the iphoneos SDK so all objects
|
||||||
|
# carry the platform IOS tag (not MACOS). Without this the fat library mixes platforms.
|
||||||
|
set(VCPKG_CHAINLOAD_TOOLCHAIN_FILE "${CMAKE_CURRENT_LIST_DIR}/../../toolchains/ios-device.cmake")
|
||||||
@@ -75,6 +75,16 @@ if(VOICECAT_USE_VCPKG_DEPS)
|
|||||||
|
|
||||||
target_compile_definitions(voicecat PUBLIC VOICECAT_HAS_OPUS VOICECAT_HAS_AUDIO)
|
target_compile_definitions(voicecat PUBLIC VOICECAT_HAS_OPUS VOICECAT_HAS_AUDIO)
|
||||||
|
|
||||||
|
# On iOS, miniaudio's AVFoundation backend includes Objective-C headers (AVFoundation.h →
|
||||||
|
# Foundation.h). Compiling those as plain C++ fails; setting LANGUAGE OBJCXX for
|
||||||
|
# audio_engine.cpp (the only file that includes miniaudio.h directly) fixes this.
|
||||||
|
if(CMAKE_SYSTEM_NAME STREQUAL "iOS")
|
||||||
|
set_source_files_properties(
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/src/audio/audio_engine.cpp
|
||||||
|
PROPERTIES LANGUAGE OBJCXX
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
|
||||||
if(WIN32)
|
if(WIN32)
|
||||||
# AcceptEx / GetAcceptExSockaddrs live in mswsock; ws2_32 covers the base Winsock API.
|
# AcceptEx / GetAcceptExSockaddrs live in mswsock; ws2_32 covers the base Winsock API.
|
||||||
target_link_libraries(voicecat PRIVATE ws2_32 mswsock)
|
target_link_libraries(voicecat PRIVATE ws2_32 mswsock)
|
||||||
|
|||||||
@@ -467,6 +467,13 @@ VC_API void vc_free_account_list(vc_account_list* list);
|
|||||||
/* Pull the caller's own permissions (from the last AuthResult). */
|
/* Pull the caller's own permissions (from the last AuthResult). */
|
||||||
VC_API vc_result vc_get_permissions(vc_client* c, vc_permissions* out);
|
VC_API vc_result vc_get_permissions(vc_client* c, vc_permissions* out);
|
||||||
|
|
||||||
|
/* AVAudioSession interruption hooks (iOS M6). Pause/resume miniaudio device I/O for
|
||||||
|
* AVAudioSession interruptions (phone call, Siri, etc.) and backgrounding. Call
|
||||||
|
* vc_audio_suspend() when an interruption begins; call vc_audio_resume() after the
|
||||||
|
* session is re-activated. No-op if the audio engine is not running. */
|
||||||
|
VC_API vc_result vc_audio_suspend(vc_client* c);
|
||||||
|
VC_API vc_result vc_audio_resume(vc_client* c);
|
||||||
|
|
||||||
#if defined(__cplusplus)
|
#if defined(__cplusplus)
|
||||||
} /* extern "C" */
|
} /* extern "C" */
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -274,6 +274,30 @@ void AudioEngine::stop() {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool AudioEngine::suspend() {
|
||||||
|
#ifdef VOICECAT_HAS_AUDIO
|
||||||
|
if (!running_.load(std::memory_order_acquire)) return true;
|
||||||
|
bool ok = true;
|
||||||
|
if (capture_started_) ok &= (ma_device_stop(&capture_device_) == MA_SUCCESS);
|
||||||
|
if (playback_started_) ok &= (ma_device_stop(&playback_device_) == MA_SUCCESS);
|
||||||
|
return ok;
|
||||||
|
#else
|
||||||
|
return true;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AudioEngine::resume() {
|
||||||
|
#ifdef VOICECAT_HAS_AUDIO
|
||||||
|
if (!running_.load(std::memory_order_acquire)) return true;
|
||||||
|
bool ok = true;
|
||||||
|
if (capture_started_) ok &= (ma_device_start(&capture_device_) == MA_SUCCESS);
|
||||||
|
if (playback_started_) ok &= (ma_device_start(&playback_device_) == MA_SUCCESS);
|
||||||
|
return ok;
|
||||||
|
#else
|
||||||
|
return true;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
void AudioEngine::inject_capture(int kind, const int16_t* pcm, size_t n) {
|
void AudioEngine::inject_capture(int kind, const int16_t* pcm, size_t n) {
|
||||||
InjectTap* tap;
|
InjectTap* tap;
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -127,6 +127,12 @@ class AudioEngine {
|
|||||||
|
|
||||||
bool running() const { return running_.load(std::memory_order_acquire); }
|
bool running() const { return running_.load(std::memory_order_acquire); }
|
||||||
|
|
||||||
|
// Pause/resume miniaudio device I/O (called from vc_audio_suspend/resume for
|
||||||
|
// AVAudioSession interruptions on iOS). Thread-safe via ma_device_stop/start.
|
||||||
|
// Returns true on success; false if a device stop/start fails.
|
||||||
|
bool suspend();
|
||||||
|
bool resume();
|
||||||
|
|
||||||
// Enumerate input or output devices (for UI pickers / vc_list_devices). Static: works
|
// Enumerate input or output devices (for UI pickers / vc_list_devices). Static: works
|
||||||
// before any AudioEngine instance is running (device pickers need to populate pre-connect).
|
// before any AudioEngine instance is running (device pickers need to populate pre-connect).
|
||||||
// Inits a throwaway ma_context if VOICECAT_HAS_AUDIO; returns {} otherwise. See DeviceInfo
|
// Inits a throwaway ma_context if VOICECAT_HAS_AUDIO; returns {} otherwise. See DeviceInfo
|
||||||
|
|||||||
@@ -1399,6 +1399,14 @@ vc_result vc_client::get_remote_stream(uint32_t user_id, uint32_t stream_id,
|
|||||||
return VC_OK;
|
return VC_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
vc_result vc_client::audio_suspend() {
|
||||||
|
return audio_engine_.suspend() ? VC_OK : VC_ERR_AUDIO;
|
||||||
|
}
|
||||||
|
|
||||||
|
vc_result vc_client::audio_resume() {
|
||||||
|
return audio_engine_.resume() ? VC_OK : VC_ERR_AUDIO;
|
||||||
|
}
|
||||||
|
|
||||||
vc_result vc_client::get_stream_audio_config(uint32_t user_id, uint32_t stream_id,
|
vc_result vc_client::get_stream_audio_config(uint32_t user_id, uint32_t stream_id,
|
||||||
vc_audio_config* out) {
|
vc_audio_config* out) {
|
||||||
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
|
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
|
||||||
@@ -1863,5 +1871,7 @@ vc_result vc_client::delete_account(const char*) { return VC_ERR_NOT_IMPLEMENTED
|
|||||||
vc_result vc_client::list_accounts() { return VC_ERR_NOT_IMPLEMENTED; }
|
vc_result vc_client::list_accounts() { return VC_ERR_NOT_IMPLEMENTED; }
|
||||||
vc_result vc_client::get_account_list(vc_account_list*) { return VC_ERR_NOT_IMPLEMENTED; }
|
vc_result vc_client::get_account_list(vc_account_list*) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||||
vc_result vc_client::get_permissions(vc_permissions*) { return VC_ERR_NOT_IMPLEMENTED; }
|
vc_result vc_client::get_permissions(vc_permissions*) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||||
|
vc_result vc_client::audio_suspend() { return VC_ERR_NOT_IMPLEMENTED; }
|
||||||
|
vc_result vc_client::audio_resume() { return VC_ERR_NOT_IMPLEMENTED; }
|
||||||
|
|
||||||
#endif // VOICECAT_HAS_NET
|
#endif // VOICECAT_HAS_NET
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ struct vc_client {
|
|||||||
bool noise_reduction);
|
bool noise_reduction);
|
||||||
vc_result get_remote_stream(uint32_t user_id, uint32_t stream_id,
|
vc_result get_remote_stream(uint32_t user_id, uint32_t stream_id,
|
||||||
vc_remote_stream_state* out);
|
vc_remote_stream_state* out);
|
||||||
|
vc_result audio_suspend();
|
||||||
|
vc_result audio_resume();
|
||||||
|
|
||||||
vc_result send_text(vc_text_scope scope, uint32_t target_id, const char* utf8);
|
vc_result send_text(vc_text_scope scope, uint32_t target_id, const char* utf8);
|
||||||
|
|
||||||
|
|||||||
@@ -295,4 +295,14 @@ vc_result vc_get_permissions(vc_client* c, vc_permissions* out) {
|
|||||||
return c->get_permissions(out);
|
return c->get_permissions(out);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
vc_result vc_audio_suspend(vc_client* c) {
|
||||||
|
if (c == nullptr) return VC_ERR_INVALID_ARG;
|
||||||
|
return c->audio_suspend();
|
||||||
|
}
|
||||||
|
|
||||||
|
vc_result vc_audio_resume(vc_client* c) {
|
||||||
|
if (c == nullptr) return VC_ERR_INVALID_ARG;
|
||||||
|
return c->audio_resume();
|
||||||
|
}
|
||||||
|
|
||||||
} // extern "C"
|
} // extern "C"
|
||||||
|
|||||||
Reference in New Issue
Block a user