Compare commits
51 Commits
feat/m3-mu
...
ios-iphone
| Author | SHA1 | Date | |
|---|---|---|---|
| b07362e525 | |||
| c5ad080692 | |||
| ef88af14c2 | |||
| 8c90e250f0 | |||
| 6ab78fa792 | |||
| 615d2a8e5f | |||
| 540ec13a63 | |||
| 97fa659422 | |||
| fdcd8d1427 | |||
| de2c253199 | |||
| f3c1172a3e | |||
| f1e1ef59ed | |||
| dcb7e6eeca | |||
| fdcc84fb42 | |||
| 1a1c8a1dfe | |||
| d6352627e9 | |||
| 3e80af2f3f | |||
| ab973940df | |||
| 9fc51cffc4 | |||
| a10a18aebe | |||
| cd530db024 | |||
| e2616b60b4 | |||
| bf37fe8f0f | |||
| e26e7db5b1 | |||
| dbf732ca91 | |||
| 06a68b441a | |||
| 56a6e4fab5 | |||
| 75c2782860 | |||
| c684824b10 | |||
| 69cd7d80ad | |||
| 33169b01fa | |||
| b4766d2f24 | |||
| b2af1a3001 | |||
| bcb7ae8ccb | |||
| d397731db9 | |||
| 1dfe3c95ed | |||
| 487a561963 | |||
| cccf085a87 | |||
| a88656f2fa | |||
| 2185d9d15c | |||
| 118ca5129f | |||
| 9b321d0d4f | |||
| 3990f63f0f | |||
| a2f159e971 | |||
| 7da0a02b3a | |||
| 45d87bde67 | |||
| 2f643e4293 | |||
| 63b241cc2e | |||
| 5be869c61a | |||
| 845f995826 | |||
| 5f6c223526 |
13
.gitignore
vendored
13
.gitignore
vendored
@@ -1,6 +1,9 @@
|
|||||||
# Build output
|
# Build output
|
||||||
/build/
|
/build/
|
||||||
/out/
|
/out/
|
||||||
|
|
||||||
|
# Staged distribution artifacts (scripts/build-*.sh output)
|
||||||
|
/dist/
|
||||||
*.o
|
*.o
|
||||||
*.obj
|
*.obj
|
||||||
*.a
|
*.a
|
||||||
@@ -36,5 +39,15 @@ Thumbs.db
|
|||||||
# Apple / Windows client build artifacts (added in M4)
|
# Apple / Windows client build artifacts (added in M4)
|
||||||
clients/apple/**/build/
|
clients/apple/**/build/
|
||||||
clients/apple/**/*.xcodeproj/xcuserdata/
|
clients/apple/**/*.xcodeproj/xcuserdata/
|
||||||
|
clients/apple/**/*.xcodeproj/project.xcworkspace/
|
||||||
|
clients/apple/**/*.xcframework/
|
||||||
clients/windows/**/bin/
|
clients/windows/**/bin/
|
||||||
clients/windows/**/obj/
|
clients/windows/**/obj/
|
||||||
|
# SwiftPM build artifacts
|
||||||
|
clients/apple/.build/
|
||||||
|
clients/apple/.swiftpm/
|
||||||
|
clients/apple/Package.resolved
|
||||||
|
|
||||||
|
# Test artifacts: TOFU pin store written by vc_client during headless tests
|
||||||
|
# (core/src/core/client.cpp falls back to this relative path when tofu_store_path is unset).
|
||||||
|
voicecat_tofu_pins.txt
|
||||||
|
|||||||
35
AGENTS.md
35
AGENTS.md
@@ -11,9 +11,11 @@ Read those, then use the method below.
|
|||||||
|
|
||||||
## What this repo is right now
|
## What this repo is right now
|
||||||
|
|
||||||
A complete **design** ([`docs/`](docs/)) plus an **M0 skeleton**: it compiles and links, but
|
A complete **design** ([`docs/`](docs/)) plus a working implementation through M5: real TLS
|
||||||
`libvoicecat`'s subsystems are stubs that return `VC_ERR_NOT_IMPLEMENTED`. Your job is to turn
|
control plane, encrypted UDP voice (Opus), multi-stream, TOFU identity pinning, channel tree,
|
||||||
the design into working software, one milestone at a time.
|
permissions, moderation, disconnect/keepalive/reaper. The Windows WinForms C# client is
|
||||||
|
shipped (M4). The macOS/iOS Swift client is next. The `skeleton` preset still links a
|
||||||
|
no-deps stub path (`VC_ERR_NOT_IMPLEMENTED`) for smoke-check builds.
|
||||||
|
|
||||||
## The working method (important)
|
## The working method (important)
|
||||||
|
|
||||||
@@ -36,24 +38,37 @@ making progress.
|
|||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
Skeleton (no third-party deps — works immediately):
|
Default development preset (real deps via vcpkg — works on Windows/Linux/macOS):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
export VCPKG_ROOT=/path/to/vcpkg # bootstrap vcpkg first; cross-platform
|
||||||
cmake --preset dev
|
cmake --preset dev
|
||||||
cmake --build --preset dev
|
cmake --build --preset dev
|
||||||
ctest --preset dev
|
ctest --preset dev
|
||||||
```
|
```
|
||||||
|
|
||||||
When a subsystem needs real libraries, turn on vcpkg deps:
|
Skeleton (no third-party deps — works immediately, no vcpkg needed):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export VCPKG_ROOT=/path/to/vcpkg # bootstrap vcpkg first; cross-platform
|
cmake --preset skeleton
|
||||||
cmake --preset server-release # installs deps pinned in vcpkg.json
|
cmake --build --preset skeleton
|
||||||
cmake --build --preset server-release
|
ctest --preset skeleton
|
||||||
```
|
```
|
||||||
|
|
||||||
`vcpkg.json` currently has a placeholder `builtin-baseline` — set it to a real vcpkg commit
|
> **Windows gotcha — always run `ctest` and built binaries via PowerShell, not Git Bash.**
|
||||||
SHA the first time you enable `VOICECAT_USE_VCPKG_DEPS`.
|
> MinGW-built executables fail in Git Bash with exit code `0xc0000139`
|
||||||
|
> (STATUS_ENTRYPOINT_NOT_FOUND) even though the file exists and is marked executable.
|
||||||
|
> PowerShell runs them correctly. Use the PowerShell tool (not Bash) for any `ctest`,
|
||||||
|
> `voicecat-server.exe`, or `vccli.exe` invocation on Windows.
|
||||||
|
|
||||||
|
Other presets: `release` (optimized + tests), `server-release` (optimized + stripped,
|
||||||
|
deployment-shaped), `windows-client` (DLL for C# app), `apple-dev`/`apple-ios`/
|
||||||
|
`apple-ios-sim` (Apple platform scaffolding). See [`docs/building.md`](docs/building.md)
|
||||||
|
for the full matrix.
|
||||||
|
|
||||||
|
`vcpkg.json` pins all deps to a fixed vcpkg baseline — `cmake --preset dev` resolves them
|
||||||
|
automatically on first configure. The vcpkg triplet is auto-resolved from the host platform
|
||||||
|
by [`cmake/voicecat-toolchain.cmake`](cmake/voicecat-toolchain.cmake).
|
||||||
|
|
||||||
## Where each subsystem lives (and its doc)
|
## Where each subsystem lives (and its doc)
|
||||||
|
|
||||||
|
|||||||
47
CLAUDE.md
47
CLAUDE.md
@@ -4,11 +4,14 @@ Auto-loaded each session. This is the **map**: build commands, architecture at a
|
|||||||
where everything is. For the *working method* read [`AGENTS.md`](AGENTS.md); for *what's done
|
where everything is. For the *working method* read [`AGENTS.md`](AGENTS.md); for *what's done
|
||||||
and what's next* read [`PROGRESS.md`](PROGRESS.md); for *design* read [`docs/`](docs/).
|
and what's next* read [`PROGRESS.md`](PROGRESS.md); for *design* read [`docs/`](docs/).
|
||||||
|
|
||||||
> **One-line status:** M2 voice/media plane is complete and verified through the real client
|
> **One-line status:** M5 (moderation & admin UI) is complete — permissions, kick/ban/move,
|
||||||
> library, not just a raw-socket harness (`ctest --test-dir build/m1-dev` green — 10/10 tests,
|
> server-mute, channel CRUD, in-app account management, disconnect/keepalive/reaper. Windows
|
||||||
> including `test_voice_client_abi` driving two real `vc_client`s end-to-end, and `vccli
|
> WinForms C# client shipped (M4). **macOS AppKit client shipped** — `VoiceCatMac.xcodeproj`
|
||||||
> --voice` manually verified live). Next up is **M3** (multi-stream, per-channel tuning,
|
> at `clients/apple/macOS/`. **iOS SwiftUI client shipped** — `VoiceCatiOS.xcodeproj` at
|
||||||
> listener-side NR). See [`PROGRESS.md`](PROGRESS.md).
|
> `clients/apple/iOS/`. `ctest --preset dev` green — 23/23 tests.
|
||||||
|
> External PCM feed/tap API (`vc_stream_feed_pcm` + `vc_set_pcm_sink`) shipped.
|
||||||
|
> **Screen-audio sharing shipped on macOS (ScreenCaptureKit) and iOS (ReplayKit Broadcast
|
||||||
|
> Upload Extension → host App Group ring → `vc_stream_feed_pcm`).** 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
|
||||||
@@ -18,18 +21,21 @@ native clients (Swift on macOS/iOS, C# on Windows) and the server.
|
|||||||
|
|
||||||
## Build & test commands
|
## Build & test commands
|
||||||
|
|
||||||
The **M0 skeleton builds with no third-party dependencies** — just CMake + Ninja + a C++20
|
The default development preset is **`dev`** — it builds everything (server + tools + tests)
|
||||||
compiler. Deps (vcpkg) are off until a subsystem needs them.
|
with real vcpkg deps. The `skeleton` preset (no deps, stubs only) is a fast smoke check; see
|
||||||
|
[`docs/building.md`](docs/building.md) for the full preset matrix.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Configure + build the skeleton (default; no vcpkg needed)
|
# Configure + build (default development preset; needs VCPKG_ROOT)
|
||||||
cmake --preset dev
|
cmake --preset dev
|
||||||
cmake --build --preset dev
|
cmake --build --preset dev
|
||||||
|
|
||||||
# Run the tests (behavior smoke test today; grows per milestone)
|
# Run the tests (21 behavior tests — grows per milestone)
|
||||||
|
# NOTE on Windows: run ctest via PowerShell, NOT Git Bash — MinGW binaries fail in Git Bash
|
||||||
|
# with exit 0xc0000139 (STATUS_ENTRYPOINT_NOT_FOUND). PowerShell runs them correctly.
|
||||||
ctest --preset dev # or: ctest --test-dir build/dev --output-on-failure
|
ctest --preset dev # or: ctest --test-dir build/dev --output-on-failure
|
||||||
|
|
||||||
# Run the binaries (Windows adds .exe; Linux/macOS no extension)
|
# Run the binaries — same Windows rule: use PowerShell, not Git Bash
|
||||||
./build/dev/bin/vccli # headless test client
|
./build/dev/bin/vccli # headless test client
|
||||||
./build/dev/bin/voicecat-server --help
|
./build/dev/bin/voicecat-server --help
|
||||||
./build/dev/bin/voicecat-server --name "My Server"
|
./build/dev/bin/voicecat-server --name "My Server"
|
||||||
@@ -43,14 +49,26 @@ rm -rf build/dev # nuke; or:
|
|||||||
cmake --build --preset dev --target clean
|
cmake --build --preset dev --target clean
|
||||||
```
|
```
|
||||||
|
|
||||||
When you start a subsystem that needs real libraries (mbedTLS, libsodium, opus, protobuf, …),
|
Other presets (see [`docs/building.md`](docs/building.md) for full detail):
|
||||||
turn vcpkg deps on:
|
|
||||||
|
```bash
|
||||||
|
cmake --preset skeleton # no-deps stub smoke (no VCPKG_ROOT needed) — 2 tests
|
||||||
|
cmake --preset release # optimized + tests on, symbols kept (profile/debug-friendly)
|
||||||
|
cmake --preset server-release # optimized + stripped, no tests (deployment-shaped)
|
||||||
|
cmake --preset windows-client # voicecat.dll for the C# WinForms client (Windows only)
|
||||||
|
cmake --preset apple-dev # libvoicecat.a for macOS Swift Package (scaffolding, macOS only)
|
||||||
|
```
|
||||||
|
|
||||||
|
Vcpkg triplet is auto-resolved from the host platform by
|
||||||
|
[`cmake/voicecat-toolchain.cmake`](cmake/voicecat-toolchain.cmake) — `x64-mingw-static` on
|
||||||
|
Windows, `x64-linux` on Linux, `arm64-osx` on Apple Silicon. See docs/building.md §1
|
||||||
|
"Platform matrix" for details.
|
||||||
|
|
||||||
|
One-time vcpkg setup:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# one-time: git clone https://github.com/microsoft/vcpkg && ./vcpkg/bootstrap-vcpkg.sh (.bat on Windows)
|
# one-time: git clone https://github.com/microsoft/vcpkg && ./vcpkg/bootstrap-vcpkg.sh (.bat on Windows)
|
||||||
export VCPKG_ROOT=/path/to/vcpkg # works on Linux / macOS / Windows
|
export VCPKG_ROOT=/path/to/vcpkg # works on Linux / macOS / Windows
|
||||||
cmake --preset server-release # auto-installs deps pinned in vcpkg.json
|
|
||||||
cmake --build --preset server-release
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Other useful toggles (pass with `-D` at configure time):
|
Other useful toggles (pass with `-D` at configure time):
|
||||||
@@ -119,6 +137,7 @@ Read [`docs/`](docs/) before changing behavior. Order:
|
|||||||
6. [docs/tech-stack.md](docs/tech-stack.md) — libraries, permissive-license rule, tooling
|
6. [docs/tech-stack.md](docs/tech-stack.md) — libraries, permissive-license rule, tooling
|
||||||
7. [docs/deployment.md](docs/deployment.md) — zero-config self-host (Docker / binary / source)
|
7. [docs/deployment.md](docs/deployment.md) — zero-config self-host (Docker / binary / source)
|
||||||
8. [docs/roadmap.md](docs/roadmap.md) — milestones + resolved decisions
|
8. [docs/roadmap.md](docs/roadmap.md) — milestones + resolved decisions
|
||||||
|
9. [docs/building.md](docs/building.md) — what each CMake preset is for + manual server/`vccli` testing
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -3,75 +3,145 @@
|
|||||||
"cmakeMinimumRequired": { "major": 3, "minor": 25, "patch": 0 },
|
"cmakeMinimumRequired": { "major": 3, "minor": 25, "patch": 0 },
|
||||||
"configurePresets": [
|
"configurePresets": [
|
||||||
{
|
{
|
||||||
"name": "dev",
|
"name": "vcpkg-common",
|
||||||
"displayName": "Dev (skeleton, no third-party deps)",
|
"hidden": true,
|
||||||
"description": "Builds the stub skeleton with just a compiler. Works out of the box; no vcpkg required.",
|
"description": "Shared base for all presets that link real deps via vcpkg. Uses cmake/voicecat-toolchain.cmake, which auto-resolves VCPKG_TARGET_TRIPLET / VCPKG_HOST_TRIPLET from the host platform (x64-mingw-static on Windows, x64-linux on Linux, arm64-osx on Apple Silicon). Cross-compile presets override VCPKG_TARGET_TRIPLET in their cacheVariables. Requires VCPKG_ROOT in the environment.",
|
||||||
"generator": "Ninja",
|
"generator": "Ninja",
|
||||||
"binaryDir": "${sourceDir}/build/dev",
|
"toolchainFile": "${sourceDir}/cmake/voicecat-toolchain.cmake",
|
||||||
|
"cacheVariables": {
|
||||||
|
"VOICECAT_USE_VCPKG_DEPS": "ON"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "skeleton",
|
||||||
|
"displayName": "Skeleton (no third-party deps)",
|
||||||
|
"description": "Builds the stub skeleton with just a C++20 compiler — no vcpkg needed. Subsystems return VC_ERR_NOT_IMPLEMENTED. Good for 'does the repo even build' smoke checks. Runs 2 tests (smoke + frame_codec).",
|
||||||
|
"generator": "Ninja",
|
||||||
|
"binaryDir": "${sourceDir}/build/skeleton",
|
||||||
"cacheVariables": {
|
"cacheVariables": {
|
||||||
"CMAKE_BUILD_TYPE": "Debug",
|
"CMAKE_BUILD_TYPE": "Debug",
|
||||||
"VOICECAT_USE_VCPKG_DEPS": "OFF"
|
"VOICECAT_USE_VCPKG_DEPS": "OFF"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "vcpkg-base",
|
"name": "dev",
|
||||||
"hidden": true,
|
"displayName": "Dev (full real-deps build, vcpkg)",
|
||||||
"description": "Shared base for presets that link real deps via vcpkg. Requires VCPKG_ROOT in the environment.",
|
"description": "Day-to-day development preset. Real protocol, crypto, voice, server — everything from M1 onward. Builds server + tools + tests (21 tests). Auto-triplet: x64-mingw-static on Windows, x64-linux on Linux, arm64-osx on Apple Silicon. Requires VCPKG_ROOT.",
|
||||||
"generator": "Ninja",
|
"inherits": "vcpkg-common",
|
||||||
"toolchainFile": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake",
|
"binaryDir": "${sourceDir}/build/dev",
|
||||||
"cacheVariables": { "VOICECAT_USE_VCPKG_DEPS": "ON" }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "m1-dev",
|
|
||||||
"inherits": "vcpkg-base",
|
|
||||||
"displayName": "M1 Dev (TLS control plane, deps via vcpkg)",
|
|
||||||
"description": "Active development preset for M1+. Requires VCPKG_ROOT env var pointing to a bootstrapped vcpkg. Set VCPKG_ROOT=D:\\code\\nvgt\\vcpkg\\bin (or wherever your vcpkg is).",
|
|
||||||
"binaryDir": "${sourceDir}/build/m1-dev",
|
|
||||||
"cacheVariables": {
|
"cacheVariables": {
|
||||||
"CMAKE_BUILD_TYPE": "Debug",
|
"CMAKE_BUILD_TYPE": "Debug",
|
||||||
"VOICECAT_USE_VCPKG_DEPS": "ON",
|
|
||||||
"VOICECAT_BUILD_TOOLS": "ON",
|
"VOICECAT_BUILD_TOOLS": "ON",
|
||||||
"VOICECAT_BUILD_TESTS": "ON",
|
"VOICECAT_BUILD_TESTS": "ON"
|
||||||
"VCPKG_TARGET_TRIPLET": "x64-mingw-static",
|
|
||||||
"VCPKG_HOST_TRIPLET": "x64-mingw-static"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "m2-dev",
|
"name": "release",
|
||||||
"inherits": "vcpkg-base",
|
"displayName": "Release (optimized, tests on, symbols kept)",
|
||||||
"displayName": "M2 Dev (voice + media, deps via vcpkg)",
|
"description": "Optimized build with the full test suite enabled. Use to run tests against optimized code, profile, or catch optimizer-sensitive bugs. Symbols are kept (not stripped) so stack traces and profiling remain useful. Auto-triplet. Requires VCPKG_ROOT.",
|
||||||
"description": "Active development preset for M2+. Requires VCPKG_ROOT env var pointing to a bootstrapped vcpkg. Set VCPKG_ROOT=D:\\code\\nvgt\\vcpkg\\bin (or wherever your vcpkg is).",
|
"inherits": "vcpkg-common",
|
||||||
"binaryDir": "${sourceDir}/build/m2-dev",
|
"binaryDir": "${sourceDir}/build/release",
|
||||||
"cacheVariables": {
|
"cacheVariables": {
|
||||||
"CMAKE_BUILD_TYPE": "Debug",
|
"CMAKE_BUILD_TYPE": "Release",
|
||||||
"VOICECAT_USE_VCPKG_DEPS": "ON",
|
|
||||||
"VOICECAT_BUILD_TOOLS": "ON",
|
"VOICECAT_BUILD_TOOLS": "ON",
|
||||||
"VOICECAT_BUILD_TESTS": "ON",
|
"VOICECAT_BUILD_TESTS": "ON"
|
||||||
"VCPKG_TARGET_TRIPLET": "x64-mingw-static",
|
|
||||||
"VCPKG_HOST_TRIPLET": "x64-mingw-static"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "server-release",
|
"name": "server-release",
|
||||||
"inherits": "vcpkg-base",
|
"displayName": "Server Release (optimized, stripped, no tests)",
|
||||||
"displayName": "Server (release, real deps)",
|
"description": "Production-shaped build for deployment. Optimized (Release) with stripped binaries (-s linker flag), no tests. This is what you'd ship/run — see docs/deployment.md. Auto-triplet. Requires VCPKG_ROOT.",
|
||||||
|
"inherits": "vcpkg-common",
|
||||||
"binaryDir": "${sourceDir}/build/server-release",
|
"binaryDir": "${sourceDir}/build/server-release",
|
||||||
"cacheVariables": {
|
"cacheVariables": {
|
||||||
"CMAKE_BUILD_TYPE": "Release",
|
"CMAKE_BUILD_TYPE": "Release",
|
||||||
"VOICECAT_BUILD_TOOLS": "ON",
|
"VOICECAT_BUILD_TOOLS": "ON",
|
||||||
"VCPKG_TARGET_TRIPLET": "x64-mingw-static"
|
"VOICECAT_BUILD_TESTS": "OFF",
|
||||||
|
"CMAKE_EXE_LINKER_FLAGS": "-s",
|
||||||
|
"CMAKE_SHARED_LINKER_FLAGS": "-s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "windows-client",
|
||||||
|
"displayName": "Windows client (voicecat.dll for C# WinForms, M4)",
|
||||||
|
"description": "Produces a redistributable Release voicecat.dll with no MinGW runtime DLL dependencies (see core/CMakeLists.txt's static-runtime link flags and clients/windows/README.md). Server/tools/tests are off — this preset exists only to build the DLL. Windows only.",
|
||||||
|
"inherits": "vcpkg-common",
|
||||||
|
"binaryDir": "${sourceDir}/build/windows-client",
|
||||||
|
"cacheVariables": {
|
||||||
|
"CMAKE_BUILD_TYPE": "Release",
|
||||||
|
"VOICECAT_BUILD_SHARED": "ON",
|
||||||
|
"VOICECAT_BUILD_SERVER": "OFF",
|
||||||
|
"VOICECAT_BUILD_TOOLS": "OFF",
|
||||||
|
"VOICECAT_BUILD_TESTS": "OFF"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "apple-dev",
|
||||||
|
"displayName": "Apple macOS (libvoicecat.a for Swift Package, scaffolding)",
|
||||||
|
"description": "SCAFFOLDING — not yet CI-validated; build on macOS to verify. Produces a static libvoicecat.a for macOS (arm64-osx on Apple Silicon, x64-osx on Intel) for consumption by the Swift Package / XCFramework. Server/tools/tests off. Requires VCPKG_ROOT.",
|
||||||
|
"inherits": "vcpkg-common",
|
||||||
|
"binaryDir": "${sourceDir}/build/apple-dev",
|
||||||
|
"cacheVariables": {
|
||||||
|
"CMAKE_BUILD_TYPE": "Release",
|
||||||
|
"VOICECAT_BUILD_SERVER": "OFF",
|
||||||
|
"VOICECAT_BUILD_TOOLS": "OFF",
|
||||||
|
"VOICECAT_BUILD_TESTS": "OFF"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "apple-ios",
|
||||||
|
"displayName": "Apple iOS device (XCFramework slice)",
|
||||||
|
"description": "Cross-compiles a static libvoicecat.a for iOS device (arm64). One slice of the XCFramework. Server/tools/tests off. Requires VCPKG_ROOT and a macOS host with iOS SDK. Uses cmake/vcpkg-overlays/triplets/arm64-ios.cmake (release-only, correct autoconf host triple).",
|
||||||
|
"inherits": "vcpkg-common",
|
||||||
|
"binaryDir": "${sourceDir}/build/apple-ios",
|
||||||
|
"cacheVariables": {
|
||||||
|
"CMAKE_BUILD_TYPE": "Release",
|
||||||
|
"CMAKE_SYSTEM_NAME": "iOS",
|
||||||
|
"CMAKE_SYSTEM_PROCESSOR": "arm64",
|
||||||
|
"CMAKE_OSX_ARCHITECTURES": "arm64",
|
||||||
|
"CMAKE_OSX_SYSROOT": "iphoneos",
|
||||||
|
"CMAKE_OSX_DEPLOYMENT_TARGET": "17.0",
|
||||||
|
"VCPKG_TARGET_TRIPLET": "arm64-ios",
|
||||||
|
"VCPKG_OVERLAY_TRIPLETS": "${sourceDir}/cmake/vcpkg-overlays/triplets",
|
||||||
|
"VOICECAT_BUILD_SERVER": "OFF",
|
||||||
|
"VOICECAT_BUILD_TOOLS": "OFF",
|
||||||
|
"VOICECAT_BUILD_TESTS": "OFF"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "apple-ios-sim",
|
||||||
|
"displayName": "Apple iOS simulator (XCFramework slice)",
|
||||||
|
"description": "Cross-compiles a static libvoicecat.a for iOS simulator (arm64-ios-simulator). One slice of the XCFramework. Server/tools/tests off. Requires VCPKG_ROOT and a macOS host with iOS simulator SDK. Uses cmake/vcpkg-overlays/triplets/arm64-ios-simulator.cmake (release-only, correct autoconf host triple).",
|
||||||
|
"inherits": "vcpkg-common",
|
||||||
|
"binaryDir": "${sourceDir}/build/apple-ios-sim",
|
||||||
|
"cacheVariables": {
|
||||||
|
"CMAKE_BUILD_TYPE": "Release",
|
||||||
|
"CMAKE_SYSTEM_NAME": "iOS",
|
||||||
|
"CMAKE_SYSTEM_PROCESSOR": "arm64",
|
||||||
|
"CMAKE_OSX_ARCHITECTURES": "arm64",
|
||||||
|
"CMAKE_OSX_SYSROOT": "iphonesimulator",
|
||||||
|
"CMAKE_OSX_DEPLOYMENT_TARGET": "17.0",
|
||||||
|
"VCPKG_TARGET_TRIPLET": "arm64-ios-simulator",
|
||||||
|
"VCPKG_OVERLAY_TRIPLETS": "${sourceDir}/cmake/vcpkg-overlays/triplets",
|
||||||
|
"VOICECAT_BUILD_SERVER": "OFF",
|
||||||
|
"VOICECAT_BUILD_TOOLS": "OFF",
|
||||||
|
"VOICECAT_BUILD_TESTS": "OFF"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"buildPresets": [
|
"buildPresets": [
|
||||||
|
{ "name": "skeleton", "configurePreset": "skeleton" },
|
||||||
{ "name": "dev", "configurePreset": "dev" },
|
{ "name": "dev", "configurePreset": "dev" },
|
||||||
{ "name": "m1-dev", "configurePreset": "m1-dev" },
|
{ "name": "release", "configurePreset": "release" },
|
||||||
{ "name": "m2-dev", "configurePreset": "m2-dev" },
|
{ "name": "server-release", "configurePreset": "server-release" },
|
||||||
{ "name": "server-release", "configurePreset": "server-release" }
|
{ "name": "windows-client", "configurePreset": "windows-client" },
|
||||||
|
{ "name": "apple-dev", "configurePreset": "apple-dev" },
|
||||||
|
{ "name": "apple-ios", "configurePreset": "apple-ios" },
|
||||||
|
{ "name": "apple-ios-sim", "configurePreset": "apple-ios-sim" }
|
||||||
],
|
],
|
||||||
"testPresets": [
|
"testPresets": [
|
||||||
{ "name": "dev", "configurePreset": "dev", "output": { "outputOnFailure": true } },
|
{ "name": "skeleton", "configurePreset": "skeleton", "output": { "outputOnFailure": true } },
|
||||||
{ "name": "m1-dev", "configurePreset": "m1-dev", "output": { "outputOnFailure": true } },
|
{ "name": "dev", "configurePreset": "dev", "output": { "outputOnFailure": true } },
|
||||||
{ "name": "m2-dev", "configurePreset": "m2-dev", "output": { "outputOnFailure": true } }
|
{ "name": "release", "configurePreset": "release", "output": { "outputOnFailure": true } }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
670
PROGRESS.md
670
PROGRESS.md
@@ -10,20 +10,317 @@ up instantly. Newest status at the top.
|
|||||||
|
|
||||||
## ▶ Where we left off / next action
|
## ▶ Where we left off / next action
|
||||||
|
|
||||||
- **Done:** **M3 — multi-stream & per-channel tuning** ✓ complete (2026-06-16). See the M3
|
- **Done (2026-06-21):** **iOS iPhone-layout UX fixes.** (1) Channels are now a **drill-down**
|
||||||
section below for the full file-by-file change list. `ctest --test-dir build/m1-dev` —
|
on iPhone — new `ChannelBrowserView` (root list of top-level channels) → `ChannelDetailView`
|
||||||
**11/11 tests** green (3 consecutive full-suite runs), including the new
|
(people in the channel + sub-channels + an explicit "Join Channel" button with password
|
||||||
`test_m3_multistream` (real `vc_client`s, not raw sockets — same lesson as M2: ABI-level
|
prompt). The iPad 3-column `NavigationSplitView` is unchanged. (2) Extracted a self-contained
|
||||||
coverage is what proves the client library, not just the wire protocol).
|
`UserRow` (context menu + sheets) from `UserListView` so admin actions are reused in the
|
||||||
**Two items intentionally still open** (carried forward, not silently dropped):
|
drill-down. (3) Fixed the **off-screen chat compose box**: `MainView` now places
|
||||||
- `vc_set_input_device`/`vc_set_input_mode`/`vc_set_push_to_talk`/`vc_list_devices`
|
`VoiceControlsView` via `.safeAreaInset(edge: .bottom)` instead of a floating `.overlay`, so
|
||||||
(device enumeration + VAD/PTT input gate) remain `VC_ERR_NOT_IMPLEMENTED` — explicitly
|
it reserves layout space above the tab bar and cooperates with keyboard avoidance. (4)
|
||||||
scoped out of this M3 pass; revisit in a future milestone.
|
**Collapsed Activity into Chat** like macOS/Windows: `ChatView` renders a merged, time-sorted
|
||||||
- Stereo Opus is now wire-correct end-to-end (a channel configured `MODE_STEREO` really
|
timeline of `messages` + `activityLog` (activity rows in gray); the separate Activity tab and
|
||||||
encodes/decodes 2-channel Opus packets), but `AudioEngine`'s playback mixer/output device
|
`ActivityLogView.swift` are removed. `xcodebuild` Debug for `generic/platform=iOS` BUILD
|
||||||
stays mono internally — stereo streams are downmixed (avg L/R) immediately after decode,
|
SUCCEEDED (sim slice still arm64-only → simulator run N/A). Next: on-device check of the
|
||||||
before mixing. True stereo *playback output* is a follow-up, not part of M3.
|
drill-down + compose box + unified timeline.
|
||||||
- **Next:** **M4 — native clients** (Windows C#, macOS/iOS Swift). See `docs/roadmap.md §M4`.
|
|
||||||
|
- **Done (2026-06-21):** **Screen-audio sharing on macOS + iOS.** macOS uses ScreenCaptureKit
|
||||||
|
(`ScreenAudioCapture.swift`) → `vc_stream_feed_pcm`; iOS uses a ReplayKit Broadcast Upload
|
||||||
|
Extension (`VoiceCatBroadcast`) that forwards captured `.audioApp` PCM through a shared App
|
||||||
|
Group SPSC ring (`BroadcastAudioRing.swift`) to the host's `BroadcastAudioPump`, which owns
|
||||||
|
the `SCREEN_AUDIO` stream and feeds it — single session, no creds on disk. No C++ changes
|
||||||
|
(the core was already ready). macOS `xcodebuild` Debug BUILD SUCCEEDED; iOS app + extension
|
||||||
|
build for device (the xcframework sim slice is arm64-only, so x86_64-simulator link is N/A).
|
||||||
|
Next: on-device end-to-end verification (two clients hear the shared audio; iOS broadcast
|
||||||
|
start/stop). NOTE: `ctest --preset dev` is 22/23 — `external_pcm` passes its assertions but
|
||||||
|
aborts at shutdown (`mutex lock failed`), a **pre-existing** teardown crash unrelated to this
|
||||||
|
change (no C++ was modified).
|
||||||
|
|
||||||
|
- **Done (2026-06-20):** **macOS client UI overhaul** — mirrors the Windows client's UI
|
||||||
|
overhaul (commit 97fa659 + 540ec13), adapted to Mac-native conventions. Also fixed and
|
||||||
|
verified the previously-uncompiled Swift changes from the external PCM feed/tap commit
|
||||||
|
(615d2a8). The main window is now just toolbar + channels + users + chat; audio device
|
||||||
|
settings (input mode, VAD, PTT key, device picker, level meter) moved to a modeless
|
||||||
|
Settings window (⌘,). Details in M5 section below. `swift test` 10/10; `xcodebuild` Debug
|
||||||
|
+ Release BUILD SUCCEEDED with 0 Swift warnings.
|
||||||
|
Next: live manual verification (toolbar toggles, unified log colors, PM windows, channel
|
||||||
|
counts, volume slider, settings window); then iOS ReplayKit and macOS ScreenCaptureKit
|
||||||
|
consumers of `vc_stream_feed_pcm`.
|
||||||
|
|
||||||
|
- **Awaiting on-device verification:** **iOS stereo mic kills headphone/A2DP output — REAL
|
||||||
|
root cause found & fixed** (2026-06-20, on Windows; verify on Mac). All prior "fixes" (the
|
||||||
|
2026-06-19 entries below) targeted the Swift `IOSAudioRouter` on the false premise that
|
||||||
|
"miniaudio does NOT touch AVAudioSession on iOS." **It does.** The core opened its miniaudio
|
||||||
|
devices with `ma_device_init(nullptr, ...)`; with a NULL context, miniaudio (0.11.25) runs an
|
||||||
|
iOS "hack" (`miniaudio.h` ~44057) that picks a session category by device type, then
|
||||||
|
`ma_context_init__coreaudio` (~36552) calls `setCategory()` + `setActive()` on **every device
|
||||||
|
open** — capture → `AVAudioSessionCategoryRecord` with **zero options**. That wiped the
|
||||||
|
`.playAndRecord` category, the mode, and `.allowBluetoothA2DP`/`.mixWithOthers`/`.allowAirPlay`
|
||||||
|
that `IOSAudioRouter` had just configured → headphone/A2DP (and even wired) output died. The
|
||||||
|
stereo presets broke worst because they depend on the A2DP output route the wipe removed.
|
||||||
|
TeamTalk never hits this: its SDK opens RemoteIO/VPIO AudioUnits directly and leaves the
|
||||||
|
session entirely to the app (`UtilSound.swift`); miniaudio insists on managing it.
|
||||||
|
- **Fix (core, cross-platform safe):** `AudioEngine` now owns a `ma_context` built by
|
||||||
|
`make_context_config()` with `coreaudio.sessionCategory = ma_ios_session_category_none` +
|
||||||
|
`noAudioSessionActivate`/`noAudioSessionDeactivate = MA_TRUE`, and passes it to **all**
|
||||||
|
`ma_device_init` calls (playback, capture, loopback) and to `enumerate_devices`'s context.
|
||||||
|
miniaudio now never touches AVAudioSession; the Swift `IOSAudioRouter` is the sole owner
|
||||||
|
(session is already activated on connect in `AppState.swift:authResult`, before any device
|
||||||
|
opens, so removing miniaudio's self-activation is safe). Context is lazily inited in
|
||||||
|
`start()`, reused across restarts, uninited in `~AudioEngine`.
|
||||||
|
Files: `core/src/audio/audio_engine.{h,cpp}`.
|
||||||
|
- **TEMP diagnostics (remove after verification):** `AudioSessionManager.logSessionState(_:)`
|
||||||
|
logs category/mode/options/route; called after `ensureSessionActive`, on every route change,
|
||||||
|
and on `.streamStarted` (right after the core opens its devices). On Mac, watch the log when
|
||||||
|
joining voice with the Stereo Mic preset: category must stay `…PlayAndRecord` with
|
||||||
|
`allowBluetoothA2DP` and the output route must remain the headphones/A2DP device — NOT flip
|
||||||
|
to `…Record`. If confirmed, delete the `logSessionState` calls + method and the prior
|
||||||
|
band-aid comments in `IOSAudioRouter`/`audio_engine.cpp` can be trimmed.
|
||||||
|
- **Verified on Windows:** `cmake --build --preset dev` clean, `ctest --preset dev` 23/23
|
||||||
|
(22/22 prior + `test_external_pcm` new binary). iOS build & on-device run still to be done by the user on the Mac.
|
||||||
|
|
||||||
|
- **Done (2026-06-20):** **External PCM feed/tap API (`vc_stream_feed_pcm` +
|
||||||
|
`vc_set_pcm_sink`)** — see detail in M5 section below. `ctest --preset dev` 23/23 (was 22/22 + 1 new test binary with 3 sub-tests).
|
||||||
|
Next: iOS ReplayKit and macOS ScreenCaptureKit consumers of this API. A public,
|
||||||
|
documented API for driving audio streams with externally-provided PCM instead of (or in
|
||||||
|
addition to) miniaudio's hardware device. Motivated by four concrete use cases — all in our
|
||||||
|
roadmap — that the current "miniaudio owns the device" model can't serve:
|
||||||
|
1. **ReplayKit Broadcast Upload Extension (iOS `SCREEN_AUDIO`)** — the extension is a
|
||||||
|
*separate process* with a ~50 MB memory cap and can't link the full `AudioEngine`
|
||||||
|
(`ma_device`, capture/playback threads). It needs to feed `CMSampleBuffer` audio (system
|
||||||
|
app audio) into the encode path without any audio hardware. The current plan in
|
||||||
|
`docs/voice.md §9` says the extension links "a minimal slice of the core (Opus encode +
|
||||||
|
media send only)" — a public feed-PCM API *is* that minimal slice. The extension links
|
||||||
|
Opus + the feed entry point, no `ma_device` needed.
|
||||||
|
2. **ScreenCaptureKit (macOS `SCREEN_AUDIO`)** — `SCStream` delivers `CMSampleBuffer` in a
|
||||||
|
callback; convert to int16 and feed. No need to route through miniaudio's device layer.
|
||||||
|
**This is how macOS screen-audio actually gets implemented** — today it does NOT work:
|
||||||
|
`VOICECAT_HAS_LOOPBACK` is Windows-only (`core/CMakeLists.txt:88-95`), so on macOS
|
||||||
|
`AudioEngine::start_loopback_capture()` hits the `#else` stub (`audio_engine.cpp:647-649`)
|
||||||
|
and returns `false`. The macOS client's "Share Screen Audio" button
|
||||||
|
(`MainWindowController.swift:800-816`) calls `startStream(.screenAudio)` which announces
|
||||||
|
the stream to peers but captures **zero audio** — peers hear silence. The button is left
|
||||||
|
in place (not touched per user request); it'll work once this API + a ScreenCaptureKit
|
||||||
|
tap ship on Mac.
|
||||||
|
3. **Bots** — music bot, TTS bot, radio relay, transcription bot. They create a
|
||||||
|
`SCREEN_AUDIO`/`AUX_DEVICE` stream and feed synthesized or decoded PCM via the feed API.
|
||||||
|
No audio hardware required — runs headless on a server. Today the only way to feed
|
||||||
|
external PCM is `vc_test_inject_capture` (TEST-ONLY, name signals "don't ship this") or
|
||||||
|
re-implementing Opus encode + AEAD + UDP framing yourself (~500 lines of duplicated
|
||||||
|
crypto/codec code per consumer).
|
||||||
|
4. **Custom clients / accessibility** — soundboard, DAW integration, TTS of incoming chat,
|
||||||
|
recording/transcription of remote audio. Need either feed (send) or tap (receive) or
|
||||||
|
both.
|
||||||
|
|
||||||
|
**What we already have (input half, gated as test-only):** `vc_test_inject_capture
|
||||||
|
(stream_id, pcm, samples)` (`voicecat.h`, `client.cpp:1452`) feeds raw int16 PCM into the
|
||||||
|
encode pipeline via `AudioEngine::inject_capture(kind, pcm, n)`. It works for any stream
|
||||||
|
kind, supports multiple concurrent injection taps (one ring buffer per local kind), and
|
||||||
|
goes through the full encode → AEAD → UDP path. The encode path already handles
|
||||||
|
`channels == 1 || 2` (proven by the WASAPI stereo loopback work, 2026-06-17 entry below).
|
||||||
|
The only problems: it's marked TEST-ONLY in the header, the name signals "don't use this in
|
||||||
|
production," and it hardcodes mono (no `channels` parameter).
|
||||||
|
|
||||||
|
**What's missing (output half):** today decoded remote audio is mixed and pushed to the
|
||||||
|
miniaudio playback device (`on_playback`). There's no way for an external consumer to
|
||||||
|
intercept the decoded PCM of a specific remote stream — it all goes to the hardware device.
|
||||||
|
A bot that wants to record, transcribe, or re-broadcast remote audio has no hook.
|
||||||
|
|
||||||
|
**Plan (API design — clean, append-only, no struct changes, ABI-stable):**
|
||||||
|
- **`vc_stream_feed_pcm`** — promote `vc_test_inject_capture` to a public, documented API
|
||||||
|
and add a `channels` parameter:
|
||||||
|
```c
|
||||||
|
/* External PCM feed — replaces the hardware capture device for this stream. Caller
|
||||||
|
provides interleaved int16 PCM at the stream's sample rate. The core frames it,
|
||||||
|
encodes (Opus), seals (AEAD), and sends (UDP). Works for any stream kind
|
||||||
|
(MIC/SCREEN_AUDIO/AUX_DEVICE). The stream must be started first (vc_stream_start);
|
||||||
|
this just replaces the capture source. channels = 1 (mono) or 2 (stereo interleaved).
|
||||||
|
Thread-safe; may be called from any thread including audio callbacks. */
|
||||||
|
vc_result vc_stream_feed_pcm(vc_client* c, uint32_t stream_id,
|
||||||
|
const int16_t* pcm, size_t samples_per_channel,
|
||||||
|
uint32_t channels);
|
||||||
|
```
|
||||||
|
- **`vc_set_pcm_sink`** — symmetric output side: receive decoded remote audio as int16 PCM
|
||||||
|
instead of (or in addition to) the hardware playback device:
|
||||||
|
```c
|
||||||
|
/* External PCM tap — receive decoded, mixed remote audio as int16 PCM. The callback
|
||||||
|
fires on the audio thread with the mixed output for a specific remote stream. Pass
|
||||||
|
cb=NULL to disable (default: disabled, hardware playback only). When enabled, PCM is
|
||||||
|
delivered to the sink AND the hardware device (dual output) so a bot can record
|
||||||
|
without disabling local monitoring. user_id+stream_id identify the source stream.
|
||||||
|
The callback MUST NOT block — copy what you need and return (same contract as
|
||||||
|
vc_callbacks.on_event). */
|
||||||
|
typedef void (*vc_pcm_sink_cb)(void* user, uint32_t user_id, uint32_t stream_id,
|
||||||
|
const int16_t* pcm, size_t samples_per_channel,
|
||||||
|
uint32_t channels, uint32_t sample_rate);
|
||||||
|
vc_result vc_set_pcm_sink(vc_client* c, vc_pcm_sink_cb cb, void* user);
|
||||||
|
```
|
||||||
|
- **Core changes:**
|
||||||
|
- `core/include/voicecat.h` — add `vc_pcm_sink_cb` typedef + the two function
|
||||||
|
declarations (append-only, after `vc_test_inject_capture`). Full doc comments on both
|
||||||
|
(contract, thread-safety, lifetime, use cases).
|
||||||
|
- `core/src/voicecat.cpp` — thin C trampolines → `vc_client::stream_feed_pcm` /
|
||||||
|
`set_pcm_sink`.
|
||||||
|
- `core/src/core/client.{h,cpp}` — `stream_feed_pcm`: validates `stream_id`, looks up
|
||||||
|
the `LocalStream`'s kind, calls `audio_engine_.inject_capture(kind, pcm, n)` (existing
|
||||||
|
path) with the channel count forwarded. `set_pcm_sink`: stores the callback + user
|
||||||
|
pointer; `on_playback` (or a new fan-out in the mixer) invokes it per remote stream
|
||||||
|
alongside the existing hardware write. Keep `vc_test_inject_capture` as a deprecated
|
||||||
|
alias calling `stream_feed_pcm(..., channels=1)` for source compatibility.
|
||||||
|
- `core/src/audio/audio_engine.{h,cpp}` — `inject_capture` already exists per-kind; add
|
||||||
|
a `channels` parameter to the ring-buffer write path (or a parallel stereo-aware
|
||||||
|
variant). The encode path in `client.cpp::on_capture_frame` already handles
|
||||||
|
`channels==2` via the stereo encode branch — just plumb the value through. For the
|
||||||
|
sink: add a `pcm_sink_` member (callback + user); in `on_playback` after mixing, if the
|
||||||
|
sink is set, copy the mixed PCM for the current stream and invoke the callback. The
|
||||||
|
copy must stay off the RT-critical path — document the non-blocking contract.
|
||||||
|
- **Skeleton stub path:** update `client.cpp`'s `#else` (no-deps) stub section to add
|
||||||
|
`vc_stream_feed_pcm`/`vc_set_pcm_sink` returning `VC_ERR_NOT_IMPLEMENTED` — keeps the
|
||||||
|
skeleton preset green.
|
||||||
|
- **Swift `VoiceCatCore`:** add `feedPcm(streamId:pcm:samplesPerChannel:channels:)` and
|
||||||
|
`setPcmSink(_:user:)` (the Swift wrapper around `vc_pcm_sink_cb` — a
|
||||||
|
`@convention(c)` closure + `Unmanaged` context, mirroring `Callbacks.swift`). Wraps both
|
||||||
|
new ABI functions.
|
||||||
|
- **C# `VoiceCat.Interop`:** add `StreamFeedPcm(streamId, pcm, samples, channels)` (with
|
||||||
|
`int16[]` marshaling) and `SetPcmSink` (delegates via `[UnmanagedCallersOnly]` thunk,
|
||||||
|
mirroring the event-callback pattern). Wraps both new ABI functions.
|
||||||
|
- **Tests:**
|
||||||
|
- `tests/test_external_pcm.cpp` (new) — `test_feed_pcm_round_trip`: two clients, A feeds
|
||||||
|
a known mono sine wave via `vc_stream_feed_pcm` on a MIC stream, B receives via the
|
||||||
|
normal decode path and asserts energy matches. `test_feed_pcm_stereo`: same with
|
||||||
|
`channels=2`, assert L≠R end-to-end (mirrors the WASAPI loopback stereo test).
|
||||||
|
`test_pcm_sink`: B sets a `vc_pcm_sink_cb`, A feeds PCM, assert the sink callback
|
||||||
|
receives the decoded PCM with matching energy. All headless, no audio hardware.
|
||||||
|
- `clients/apple/Tests/VoiceCatCoreTests/` — Swift wrapper round-trip for `feedPcm`.
|
||||||
|
- `clients/windows/VoiceCat.Interop.Tests/` — C# wrapper round-trip.
|
||||||
|
- **Docs:**
|
||||||
|
- `docs/architecture.md §4` — new subsection on external PCM feed/tap: the contract
|
||||||
|
(caller provides interleaved int16 at the stream's sample rate; core frames/encodes/
|
||||||
|
seals/sends for feed; core decodes/mixes/delivers for sink; sink callback must not
|
||||||
|
block), the use cases (ReplayKit, ScreenCaptureKit, bots, custom clients), and the
|
||||||
|
relationship to `vc_test_inject_capture` (deprecated alias).
|
||||||
|
- `docs/voice.md §9` — update the iOS ReplayKit and macOS ScreenCaptureKit rows: both
|
||||||
|
now consume `vc_stream_feed_pcm` instead of a "minimal slice of the core." Update the
|
||||||
|
iOS detail bullets: the extension links Opus + `vc_stream_feed_pcm` (not a parallel
|
||||||
|
media stack). Add a macOS ScreenCaptureKit note: convert `CMSampleBuffer` → int16,
|
||||||
|
feed via `vc_stream_feed_pcm` — this is how macOS screen-audio actually ships.
|
||||||
|
- `docs/protocol.md` — no protocol changes (the feed/sink are client-local; the wire
|
||||||
|
format is identical whether PCM came from miniaudio or an external source). Note this
|
||||||
|
explicitly.
|
||||||
|
- `docs/roadmap.md` — add a milestone entry; update the iOS ReplayKit and macOS
|
||||||
|
ScreenCaptureKit pending items to reference `vc_stream_feed_pcm`.
|
||||||
|
- **Implementation order:**
|
||||||
|
1. C ABI + core (`voicecat.h`, `voicecat.cpp`, `client.{h,cpp}`, `audio_engine.{h,cpp}`) +
|
||||||
|
skeleton stub. Verify `ctest --preset dev` green.
|
||||||
|
2. `tests/test_external_pcm.cpp` — the three behavior tests. Verify green.
|
||||||
|
3. Swift `VoiceCatCore` wrapper + `VoiceCatCoreTests` round-trip.
|
||||||
|
4. C# `VoiceCat.Interop` wrapper + `VoiceCatClientSmokeTests` round-trip.
|
||||||
|
5. Docs (`architecture.md`, `voice.md`, `protocol.md`, `roadmap.md`, header comments).
|
||||||
|
6. **Then** ReplayKit (iOS) and ScreenCaptureKit (macOS) become ~100-line consumers of
|
||||||
|
this API instead of parallel media stacks.
|
||||||
|
- **Verification:** `ctest --preset dev` green (3 new tests); `swift test` green; `dotnet
|
||||||
|
test` green; `xcodebuild` (skeleton) green. The feed/sink tests are fully headless — no
|
||||||
|
audio hardware, no simulator, no device — so they run in CI on every platform.
|
||||||
|
- **Files to touch:**
|
||||||
|
- Core C++: `core/include/voicecat.h`, `core/src/voicecat.cpp`,
|
||||||
|
`core/src/core/client.{h,cpp}`, `core/src/audio/audio_engine.{h,cpp}`.
|
||||||
|
- Tests: `tests/test_external_pcm.cpp` (new), `tests/CMakeLists.txt`.
|
||||||
|
- Swift: `clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift`,
|
||||||
|
`clients/apple/Sources/VoiceCatCore/Callbacks.swift`,
|
||||||
|
`clients/apple/Tests/VoiceCatCoreTests/ExternalPcmTests.swift` (new).
|
||||||
|
- C#: `clients/windows/VoiceCat.Interop/VoiceCatClient.cs`,
|
||||||
|
`clients/windows/VoiceCat.Interop/NativeMethods.cs`,
|
||||||
|
`clients/windows/VoiceCat.Interop.Tests/ExternalPcmTests.cs` (new).
|
||||||
|
- Docs: `docs/architecture.md`, `docs/voice.md`, `docs/protocol.md`, `docs/roadmap.md`.
|
||||||
|
- **ABI stability:** append-only — two new functions + one new typedef, no existing
|
||||||
|
structs/enums changed. `vc_test_inject_capture` stays as a deprecated alias for source
|
||||||
|
compatibility. Treat as a deliberate, versioned ABI event per `docs/protocol.md §8`.
|
||||||
|
- **Relationship to the iOS audio routing plan:** orthogonal. The iOS routing layer controls
|
||||||
|
*which hardware route* miniaudio opens (AVAudioSession config in Swift). This plan is about
|
||||||
|
*bypassing miniaudio's hardware entirely* (external PCM feed/tap). Both ship; they don't
|
||||||
|
conflict. ReplayKit/ScreenCaptureKit consume this API; the iOS routing layer controls the
|
||||||
|
*mic* path which still uses miniaudio's device.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recent completed work
|
||||||
|
|
||||||
|
All items below are `[x]` done; `ctest --preset dev` 23/23 on Windows after all.
|
||||||
|
|
||||||
|
- **External PCM feed/tap API** (2026-06-20): `vc_stream_feed_pcm` + `vc_set_pcm_sink` shipped.
|
||||||
|
Promotes `vc_test_inject_capture` (mono-only, TEST-ONLY) to a public, stereo-capable API.
|
||||||
|
Adds symmetric PCM sink on the playback thread. Swift wrapper (`feedPcm`/`setPcmSink` in
|
||||||
|
`VoiceCatClient.swift`, 4 XCTest smoke tests). C# wrapper (`StreamFeedPcm`/`SetPcmSink` in
|
||||||
|
`VoiceCatClient.cs` + `NativeMethods.cs`, 4 xUnit smoke tests in `ExternalPcmTests.cs`).
|
||||||
|
Three new headless C++ ctests. Docs: architecture.md §4 new subsection, voice.md §9 updated,
|
||||||
|
protocol.md §8 explicit no-protocol-change note, roadmap.md M5 entry. Files: `voicecat.h`,
|
||||||
|
`voicecat.cpp`, `client.{h,cpp}`, `audio_engine.{h,cpp}`, `tests/test_external_pcm.cpp`,
|
||||||
|
`tests/CMakeLists.txt`, Swift + C# wrappers.
|
||||||
|
|
||||||
|
- **iOS A2DP + stereo root cause fix** (2026-06-20): miniaudio's NULL-context `ma_device_init`
|
||||||
|
was calling `AVAudioSession setCategory(Record)` on every device open, wiping the session
|
||||||
|
config `IOSAudioRouter` had set. Fixed by sharing a `ma_context` with
|
||||||
|
`sessionCategory=none` + `noAudioSessionActivate/Deactivate=MA_TRUE` — miniaudio never
|
||||||
|
touches `AVAudioSession`; `IOSAudioRouter` is the sole owner. Files: `audio_engine.{h,cpp}`.
|
||||||
|
|
||||||
|
- **iOS audio routing overhaul** (2026-06-19): Full `IOSAudioRouter` singleton drives all
|
||||||
|
`AVAudioSession` config before miniaudio opens devices. Fixed stereo mic polar-pattern setup
|
||||||
|
(WWDC20 recipe: `setPreferredInput` + `setInputDataSource` + `.stereo` polar pattern + no
|
||||||
|
`setPreferredInputNumberOfChannels`). Added `vc_audio_restart` ABI (full stop+reinit for
|
||||||
|
close→reconfigure→reopen ordering). Added `vc_set_capture_channels` ABI (core stereo-mic
|
||||||
|
support). AVAudioSession activated proactively on `.authResult`, not lazily on
|
||||||
|
`.streamStarted`. Join/Leave Voice button added (parity with macOS). Channel-id sync fixed
|
||||||
|
(mic button was permanently dimmed). iOS deployment target raised to 18.0.
|
||||||
|
|
||||||
|
- **iOS SwiftUI client** (2026-06-19): `VoiceCatiOS.xcodeproj` at `clients/apple/iOS/`.
|
||||||
|
Full feature parity with macOS/Windows: saved server list (JSON + Keychain, App Group
|
||||||
|
`group.cat.voice.VoiceCat`), TOFU, connect flow, channel tree, user list with context menus,
|
||||||
|
chat, admin sheets, voice controls, settings. `xcodebuild` → BUILD SUCCEEDED.
|
||||||
|
|
||||||
|
- **macOS AppKit client** (2026-06-18): `VoiceCatMac.xcodeproj` at `clients/apple/macOS/`.
|
||||||
|
Fixed compile errors (`NSAccessibility` call-site arg order, `StreamSummary.id` vs
|
||||||
|
`.streamId`) and linker issues (`OTHER_LDFLAGS = -lc++`, `ONLY_ACTIVE_ARCH = YES` for
|
||||||
|
Release). Debug + Release both BUILD SUCCEEDED.
|
||||||
|
|
||||||
|
- **Swift `VoiceCatCore` package + XCFramework** (2026-06-18): Shared Swift wrapper at
|
||||||
|
`clients/apple/`. `build-xcframework.sh` merges `libvoicecat.a` + 107 vcpkg static deps into
|
||||||
|
a fat `.a` via `libtool -static`. 6/6 Swift tests green (real server, mirrors C# Interop
|
||||||
|
tests). Supports macOS-arm64 + iOS-arm64 + iOS-sim slices.
|
||||||
|
|
||||||
|
- **macOS port validated** (2026-06-18): 21/21 on macOS. Three cross-platform bugs fixed:
|
||||||
|
missing `<netdb.h>` in POSIX test branch; SIGPIPE kills (added `SIG_IGN`); use-after-free of
|
||||||
|
Asio kqueue reactor on server shutdown (fixed `TcpAcceptor` shutdown/connection-drain
|
||||||
|
sequence).
|
||||||
|
|
||||||
|
- **CMake preset cleanup** (2026-06-18): `m1-dev`→`dev`, `dev`→`skeleton`, `m2-dev` dropped.
|
||||||
|
New `release`, `server-release` (stripped), `apple-dev`/`apple-ios`/`apple-ios-sim`. Cross-
|
||||||
|
platform triplet auto-resolved by `cmake/voicecat-toolchain.cmake`.
|
||||||
|
|
||||||
|
- **Disconnect, keepalive & reaper** (2026-06-18): Client sends `Ping` every 15 s; server
|
||||||
|
reaper drops sessions after 45 s; UDP `KEEPALIVE` every 5 s keeps NAT alive. `vc_disconnect`
|
||||||
|
sends graceful `Disconnect` proto. Stale-user LEFT broadcast on drop. PLC capped at ~2 s.
|
||||||
|
Three new tests: `test_disconnect_left`, `test_plc_cap`, `test_reaper_timeout`.
|
||||||
|
|
||||||
|
- **Stereo screen-audio loopback** (2026-06-17): WASAPI loopback opens in channel's
|
||||||
|
stereo/mono mode (was hardcoded mono). Real stereo flows end-to-end through loopback → encode
|
||||||
|
→ decode → mixer. New `test_loopback_stereo_capture`.
|
||||||
|
|
||||||
|
- **Windows screen-audio UI wired** (2026-06-17): `btnScreenShareToggle` in `MainForm.cs`.
|
||||||
|
No core/proto/ABI changes — all the plumbing was already there. `dotnet test` 4/4 green.
|
||||||
|
|
||||||
|
- **Bug fixes** (2026-06-16 – 2026-06-17):
|
||||||
|
- *AEAD nonce desync in SFU relay* — relay forwarded sender's `seq` verbatim; recipient
|
||||||
|
nonce reconstruction used the wrong counter. Fixed by rewriting the outgoing `seq` field
|
||||||
|
to the recipient's `peek_send_counter()`.
|
||||||
|
- *Playout clock free-ran* — `playout_ts` advanced even during VAD/PTT silence gaps,
|
||||||
|
eventually dropping all frames as too-late. Fixed with resync in `on_playback` via
|
||||||
|
`JitterBuffer::peek_front_ts()`.
|
||||||
|
- *Stale users after disconnect* — `ConnSession::close()` didn't broadcast `UserEvent::LEFT`
|
||||||
|
before erasing. Fixed; PLC cap added as defense-in-depth.
|
||||||
|
- *"Randomly bumped to Lobby"* — server excluded the actor from its own state-change
|
||||||
|
broadcasts. Fixed: `UserEvent::UPDATED` now goes to all clients including the actor.
|
||||||
|
- *Silent playback after join* — `opus_decode` received hardware callback frame count as
|
||||||
|
`max_samples` instead of the Opus frame size. Fixed with a decode ring buffer.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -33,203 +330,202 @@ 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)
|
||||||
- [ ] **M4 — Native clients** (Windows C#, macOS/iOS Swift) ← next
|
- [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, …)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## M0 — Scaffolding ✓ (completed)
|
## M0 — Scaffolding ✓
|
||||||
|
|
||||||
- [x] Repo layout (`core/ server/ tools/ clients/ tests/`), CMake + presets, vcpkg manifest.
|
Repo layout (`core/ server/ tools/ clients/ tests/`), CMake + vcpkg manifest, C ABI header
|
||||||
- [x] C ABI header `core/include/voicecat.h` (full surface, stubbed).
|
(`voicecat.h`), proto source of truth, core stubs for all six subsystems, `voicecat-server` +
|
||||||
- [x] Protocol source-of-truth `core/proto/voicecat.proto` (matches docs/protocol.md).
|
`vccli` skeletons, smoke CTest, `.clang-format`/`.gitattributes`/`.gitignore`.
|
||||||
- [x] Core stubs for all six subsystems (net/crypto/codec/protocol/session/audio) + `vc_client`.
|
|
||||||
- [x] `voicecat-server` (arg parsing, config, stub run) and `vccli` (drives the C ABI).
|
|
||||||
- [x] CTest **smoke test** asserting the C ABI contract (not just "it compiles").
|
|
||||||
- [x] `.gitattributes` (LF), `.gitignore`, `.clang-format`, onboarding docs.
|
|
||||||
- **Verified:** `cmake --preset dev && cmake --build --preset dev && ctest --preset dev` → green.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## M1 — Control plane ✓ (completed 2026-06-15)
|
## M1 — Control plane ✓ (completed 2026-06-15)
|
||||||
|
|
||||||
**Exit criterion:** ✓ `test_m1_integration` — two clients authenticate over TLS 1.3 (guest
|
**Exit criterion:** `test_m1_integration` — two clients authenticate over TLS 1.3 (guest +
|
||||||
+ Argon2id password), exchange channel and private text messages. Passes in ~1 s.
|
Argon2id), exchange channel + private text. ~1 s.
|
||||||
|
|
||||||
- [x] vcpkg baseline + `m1-dev` preset; `find_package` for protobuf/mbedTLS/libsodium/asio/sqlite3.
|
`FrameCodec`, `TlsContext` (mbedTLS 1.3, ECDSA-P256 self-signed, TOFU pins TLS leaf-cert
|
||||||
- [x] `FrameCodec` feed + emit; `encode_envelope` / `decode_envelope`.
|
SHA-256), `WorkerPool`, `Database` (SQLite + Argon2id), `ServerIdentityManager`,
|
||||||
- [x] Asio TCP acceptor + `TcpServerConn` (TLS path: blocking handshake thread + `tls_read_loop`).
|
`ConnSession` state machine, `SessionRegistry`, `vc_client` full M1 C ABI, `voicecat-admin`
|
||||||
- [x] `TlsContext` (mbedTLS 1.3, server cert/identity, ECDSA-P256 self-signed, TOFU on client).
|
CLI, dual-stack `TcpAcceptor`. **Key bug fixed:** `send_frame` double-framing — `encode_envelope`
|
||||||
- [x] `WorkerPool` (3 threads, used for Argon2id).
|
was pre-framing the protobuf; fixed by passing raw protobuf bytes.
|
||||||
- [x] `Database` — SQLite, Argon2id via libsodium, `create_account` / `authenticate` / bootstrap admin.
|
|
||||||
- [x] `voicecat-admin` — account add/reset/del/list against live DB file.
|
|
||||||
- [x] `ServerIdentityManager` — generate/persist Ed25519 key + cert; fingerprint display.
|
|
||||||
- [x] `ConnSession` — WaitingHello → WaitingAuth → Authenticated state machine; full protocol relay.
|
|
||||||
- [x] `SessionRegistry` — channel tree, user map, broadcast, text routing.
|
|
||||||
- [x] `vc_client` (`client.cpp`) — full M1 C ABI: connect/TLS/ClientHello/AuthRequest/text/disconnect.
|
|
||||||
- [x] `Server::run()` — io_context, acceptor, worker pool, signal handling, `on_ready` callback.
|
|
||||||
- [x] `test_m1_integration` — M1 exit criterion. Verified green 2026-06-15.
|
|
||||||
|
|
||||||
**Key bug fixed:** double-framing in `ConnSession::send_envelope` — `encode_envelope` was
|
|
||||||
pre-framing the protobuf, then `TcpServerConn::send_frame` re-framed it. Fixed by serializing
|
|
||||||
raw protobuf bytes directly and letting `send_frame` add the single `[4-byte len]` prefix.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## M2 — Voice, single stream ✓ (completed 2026-06-16)
|
## M2 — Voice, single stream ✓ (completed 2026-06-16)
|
||||||
|
|
||||||
**Exit criterion:** ✓ `test_m2_voice` — two headless clients authenticate over TLS, bind UDP,
|
**Exit criterion:** `test_m2_voice` + `test_voice_client_abi` — two headless clients auth, bind
|
||||||
announce a MIC stream, send 50 encrypted Opus frames; server SFU relay re-encrypts + forwards
|
UDP, 50 Opus frames relayed + re-encrypted by SFU, B receives ≥25 and decrypts. ~4 s.
|
||||||
to the second client; B receives ≥ 25 frames and all decrypt correctly. Passes in ~4 s.
|
|
||||||
|
|
||||||
- [x] `m2-dev` preset (inherits `vcpkg-base`, binaryDir `build/m2-dev`); `m1-dev` also builds all M2 code.
|
14-byte UDP voice header, `SodiumMediaCrypto` (ChaCha20-Poly1305 + 64-bit anti-replay),
|
||||||
- [x] `core/CMakeLists.txt` — `find_package(Opus)`, `find_path(MINIAUDIO_INCLUDE_DIR)`.
|
`OpusEncoder`/`OpusDecoder` (FEC, PLC), `UdpMediaChannel`, `JitterBuffer`, `AudioEngine`
|
||||||
- [x] `core/src/net/voice_frame.h` — 14-byte UDP header (type/flags/codec/ssrc/seq/ts), serialize/parse, `make_udp_binding_packet`.
|
(miniaudio), `MediaRelay` SFU. **Key bug fixed:** `on_playback` passed hardware callback frame
|
||||||
- [x] `SodiumMediaCrypto` — ChaCha20-Poly1305 AEAD; counter-nonce; 64-bit sliding-window anti-replay; `derive_send/recv` from TLS RFC 5705 exporter.
|
count as `opus_decode` max_samples; fixed with a per-stream decode ring buffer.
|
||||||
- [x] `OpusEncoder` / `OpusDecoder` — libopus 1.6, FEC, DTX, PLC (free; nullptr → decoder extrapolates).
|
|
||||||
- [x] `UdpMediaChannel` — async UDP socket (asio); thread-safe `send_to`; async recv loop.
|
|
||||||
- [x] `JitterBuffer` — per-ssrc, EWMA jitter estimation, adaptive depth 20–200 ms, late-drop at 500 ms.
|
|
||||||
- [x] `AudioEngine` — miniaudio capture+playback; `inject_capture()` bypass for headless tests; per-ssrc RemoteStream with OpusDecoder + JitterBuffer.
|
|
||||||
- [x] `ApmProcessor` — `ApmPassthrough` stub (VAD always open); WebRTC APM deferred until M3.
|
|
||||||
- [x] `on_tls_ready` callback in `TcpChannelCallbacks` — server derives and stores media AEAD keys immediately after TLS handshake.
|
|
||||||
- [x] `ConnSession` M2 — `udp_token` generated at construction; included in `AuthResult`; `handle_udp_binding` (verifies token, TCP ack); `handle_stream_announce` (assigns SSRC via registry); `udp_media_port` in `ServerHello`.
|
|
||||||
- [x] `SessionRegistry` M2 — `register_udp_token`, `find_by_udp_token`, `register_udp_endpoint`, `find_by_udp_endpoint`, `assign_ssrc`, `find_channel_sessions`, `user_channel`.
|
|
||||||
- [x] `MediaRelay` — SFU UDP relay; `kFrameUdpBinding` → endpoint binding; `kFrameVoice` → decrypt/re-encrypt/forward to channel members.
|
|
||||||
- [x] `Server::run()` — creates and binds `MediaRelay`; passes media port to `ConnSession`; wires `on_tls_ready` to derive per-connection media AEAD keys.
|
|
||||||
- [x] `test_voice_frame` — header round-trip, big-endian layout, binding packet format.
|
|
||||||
- [x] `test_media_aead` — seal/open round-trip, anti-replay, tamper detection, multi-packet sequence.
|
|
||||||
- [x] `test_opus_codec` — encode/decode round-trip energy check (within 3 dB), PLC, frame-samples helper.
|
|
||||||
- [x] `test_m2_voice` — M2 exit criterion (raw-socket harness). Verified green 2026-06-16.
|
|
||||||
|
|
||||||
**Follow-up (same day):** the above made `test_m2_voice` pass, but `vc_client`'s public voice
|
|
||||||
methods were still stubs — the *actual* M2 exit criterion ("two vccli/early-GUI clients talk")
|
|
||||||
wasn't met. Closed the gap:
|
|
||||||
|
|
||||||
- [x] `core/src/core/client.cpp` — real `stream_start`/`stream_stop`/`set_self_mute`/
|
|
||||||
`set_remote_stream`; UDP-binding handshake (`start_udp_binding`/`handle_udp_binding_ack`/
|
|
||||||
`finish_udp_binding`); media key derivation from `tls_` (RFC 5705 exporter); `run_udp_recv`
|
|
||||||
(AEAD-open → `JitterBuffer::Frame` → `audio_engine_.push_recv_frame`); `on_capture_frame`
|
|
||||||
(encode → seal → `sendto`); `sync_remote_streams` (diffs a `User` proto's `streams` against
|
|
||||||
`remote_streams_`, wiring up `OpusDecoder`s and emitting `STREAM_STARTED`/`STOPPED`).
|
|
||||||
`set_input_device`/`set_input_mode`/`set_push_to_talk`/`list_devices` remain
|
|
||||||
`VC_ERR_NOT_IMPLEMENTED` — no device-enumeration backend yet; scoped to M3 (VAD/PTT).
|
|
||||||
- [x] `core/src/session/session.cpp/h` — `SessionModel::find_user`, `find_user_by_ssrc`,
|
|
||||||
`Stream{stream_id, ssrc, kind, label, sample_rate, frame_ms}`.
|
|
||||||
- [x] `server/src/conn_session.cpp/h` — `handle_stream_announce`/`handle_stream_stop` now
|
|
||||||
broadcast via `SessionRegistry::set_user_stream`/`clear_user_stream` → `UserEvent::UPDATED`.
|
|
||||||
- [x] `server/src/session_registry.cpp/h` — `set_user_stream`/`clear_user_stream` (mutate a
|
|
||||||
user's `StreamInfo` list, return the updated `User` proto for broadcast).
|
|
||||||
- [x] `tests/test_voice_client_abi.cpp` — drives two real `vc_client` instances through
|
|
||||||
`vc_connect`/`vc_authenticate_guest`/`vc_stream_start`/`vc_stream_stop`; asserts client B
|
|
||||||
observes client A's `STREAM_STARTED`/`STOPPED` events. Verified green 2026-06-16.
|
|
||||||
- [x] `tools/vccli/src/main.cpp` — argv parsing (`--host/--port/--nick/--channel/--voice/
|
|
||||||
--mute/--text`); `--voice` starts a MIC stream and blocks on SIGINT, printing `on_event`
|
|
||||||
callbacks live (unbuffered stdout — MinGW/MSVCRT treat `_IOLBF` as full buffering for
|
|
||||||
non-console streams). Dropped the originally-planned `--voice-loopback` and the
|
|
||||||
`tx=N rx=M lost=K jitter=J` stats line: `voicecat.h` exposes no PCM-injection hook or
|
|
||||||
jitter/loss stats getter publicly, only `on_event` + `on_level` (RMS). Manually verified:
|
|
||||||
two `vccli --voice` instances see each other's stream start in real time.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## M3 — Multi-stream & per-channel tuning ✓ (completed 2026-06-16)
|
## M3 — Multi-stream & per-channel tuning ✓ (completed 2026-06-16)
|
||||||
|
|
||||||
**Exit criterion:** ✓ `test_m3_multistream` — a real `vc_client` (A) runs two concurrent local
|
**Exit criterion:** `test_m3_multistream` — client A runs two concurrent streams (MIC +
|
||||||
streams (MIC + SCREEN_AUDIO) with distinct stream ids; a second client (B) sees both as
|
SCREEN_AUDIO); B sees both; per-stream gain/mute/NR independent; effective Opus config matches
|
||||||
separate `STREAM_STARTED` events and a `VC_EVENT_TALK_STATE` talking edge for A's MIC stream;
|
channel's server-enforced settings. ~2.4 s.
|
||||||
B independently sets gain/mute/noise-reduction on each of A's streams without one call
|
|
||||||
affecting the other; A then joins "Music Room" (channel 2: stereo/128kbps/`OPUS_AUDIO`/no
|
|
||||||
DTX) and announces a fresh MIC stream there, while B stays in "Lobby" (channel 1: mono/24kbps/
|
|
||||||
`OPUS_VOIP`/DTX on) — `vc_get_stream_audio_config` shows their effective Opus config differs
|
|
||||||
exactly as the server enforces per channel. Passes in ~2.4s; verified across 8 consecutive
|
|
||||||
standalone runs + 3 consecutive full-suite runs with no flakes.
|
|
||||||
|
|
||||||
Exploration before implementing turned up several bugs/gaps where the wire format already
|
Fixed server `stream_id` counter bug (always wrote `1`). Per-channel `AudioConfig` populated
|
||||||
supported this milestone but the client/server logic didn't — these were fixed as part of M3,
|
(Lobby: mono/24kbps/VOIP + DTX; Music Room: stereo/128kbps/AUDIO). `LocalStream` map,
|
||||||
not treated as pre-existing-and-out-of-scope:
|
`pending_announce_kind_`, `run_talk_timer()`, thread-join race in `teardown_voice()` fixed.
|
||||||
|
New C ABI: `vc_get_stream_audio_config`, `vc_test_inject_capture`.
|
||||||
|
|
||||||
- [x] **Server `stream_id` bug** — `handle_stream_announce` always wrote `stream_id=1`, so a
|
---
|
||||||
second stream from the same user silently overwrote the first in
|
|
||||||
`SessionRegistry::set_user_stream`'s replace-by-id logic. Fixed with a per-session counter
|
|
||||||
(`ConnSession::next_stream_id_`) + `announced_stream_ids_` (also now validated in
|
|
||||||
`handle_stream_stop`, rejecting stops for ids the session never announced).
|
|
||||||
- [x] **Per-channel `AudioConfig` was modeled but never populated/enforced.**
|
|
||||||
`SessionRegistry::init_default_channels()` now seeds Lobby (id=1: mono, 24kbps, `OPUS_VOIP`,
|
|
||||||
FEC+DTX on) and a new "Music Room" (id=2: stereo, 128kbps, `OPUS_AUDIO`, FEC+DTX off) with
|
|
||||||
real `AudioConfig`s; new `SessionRegistry::channel_audio_config(channel_id)` accessor (there
|
|
||||||
was no per-id channel getter before, only `channel_snapshot()`). `handle_stream_announce`
|
|
||||||
now treats the channel's config as authoritative (mode/frame_ms/application/fec/dtx/
|
|
||||||
complexity), clamping (not overriding) `bitrate_bps` to the channel's ceiling.
|
|
||||||
- [x] **Client silently dropped `mode`/`dtx`/`complexity`/`application` from `effective_audio`**
|
|
||||||
even for the single M2 stream — `handle_stream_announce_result` and `sync_remote_streams`
|
|
||||||
only copied `sample_rate`/`bitrate_bps`/`frame_ms`/`fec` into `OpusParams`. New shared
|
|
||||||
`opus_params_from_audio_config()` helper (`client.cpp`) fixes both the send and receive
|
|
||||||
paths.
|
|
||||||
- [x] `core/src/codec/opus_codec.h/.cpp` — new `OpusApplication` enum + `OpusParams::application`
|
|
||||||
field; `OpusEncoder::init` now honors it instead of hardcoding `OPUS_APPLICATION_VOIP`.
|
|
||||||
- [x] `core/src/session/session.h/.cpp` — `Stream` struct extended with the full `AudioConfig`
|
|
||||||
(mode/bitrate_bps/application/fec/expected_packet_loss/dtx/complexity), not just
|
|
||||||
sample_rate/frame_ms; `copy_streams()` now copies all of it.
|
|
||||||
- [x] `core/src/core/client.h/.cpp` — local-stream state is now a `std::unordered_map<int,
|
|
||||||
LocalStream>` keyed by `vc_stream_kind` (one active stream per kind — MIC/SCREEN_AUDIO/
|
|
||||||
AUX_DEVICE are each singletons for a client), replacing the M2 single-stream fields.
|
|
||||||
`StreamAnnounce`/`StreamAnnounceResult` round-trips are now correlated by `request_id`
|
|
||||||
(already round-tripped on the wire; just wasn't read) via `pending_announce_kind_`, so
|
|
||||||
multiple concurrent announces from one client resolve to the right `LocalStream`.
|
|
||||||
`on_capture_frame` takes a `kind` parameter and upmixes mono capture to stereo (duplicate
|
|
||||||
L=R) when a stream's channel config calls for it. `vc_set_self_mute`'s `mic_muted` only
|
|
||||||
gates the `MIC` kind — a concurrent `SCREEN_AUDIO` share keeps playing while muted.
|
|
||||||
`set_remote_stream` now actually wires `noise_reduction` through (previously parsed and
|
|
||||||
discarded). New `run_talk_timer()` (a small dedicated thread, started alongside the UDP
|
|
||||||
media path, never the miniaudio callback thread) polls both remote talk-state edges
|
|
||||||
(`AudioEngine::poll_talk_transitions()`) and local capture-activity edges, emitting
|
|
||||||
`VC_EVENT_TALK_STATE`.
|
|
||||||
- [x] **Fixed a thread-join race in `teardown_voice()`** — it's called both from `run_io()`'s
|
|
||||||
own cleanup and from `disconnect()`, on different threads; without serialization both could
|
|
||||||
see `udp_thread_`/`talk_timer_thread_` as `joinable()` simultaneously and race to `join()`
|
|
||||||
the same `std::thread` (UB; surfaced as an intermittent `std::system_error: No such process`
|
|
||||||
under `ctest`). Added a `teardown_mu_` guard around the whole function. This pre-existed for
|
|
||||||
`udp_thread_` alone (likely the same root cause as the `test_m1_integration`/`test_m2_voice`
|
|
||||||
cleanup-path flake noted in the M2 section above) — adding `talk_timer_thread_`'s join just
|
|
||||||
made it surface more often, so it was fixed properly here rather than carried forward again.
|
|
||||||
- [x] `core/src/audio/audio_engine.h/.cpp` — `CaptureCallback` gained a `kind` parameter
|
|
||||||
(the real miniaudio capture device is always tagged `kind=0`/MIC; a second concurrent local
|
|
||||||
stream is fed via its own `inject_capture(kind, ...)` ring buffer — `inject_taps_`, keyed by
|
|
||||||
kind — since there is only one real hardware capture device in M3). Fixed a buffer-sizing
|
|
||||||
bug in `on_playback`'s per-stream decode (`opus_decode`'s `frame_size` parameter is
|
|
||||||
samples-*per-channel*, not total samples — the old code passed `frames * params_.channels`,
|
|
||||||
which would have overflowed the decode buffer for any stereo stream). Stereo decoder output
|
|
||||||
is downmixed (avg L/R) into the engine's mono mix accumulator immediately after decode.
|
|
||||||
`RemoteStream` gained `recv_ns`/`noise_reduction_enabled` (lazy `ApmProcessor` instantiation
|
|
||||||
— freed on disable, so no separate instance cap is needed per the roadmap's guidance) and
|
|
||||||
`last_voice_ms`/`talking` (talk-indicator edge state, updated in `push_recv_frame`); new
|
|
||||||
`set_stream_noise_reduction()` and `poll_talk_transitions()`. Note: until `VOICECAT_HAS_APM`
|
|
||||||
is wired to a real WebRTC APM build, the NS toggle is plumbed end-to-end but behaviorally a
|
|
||||||
passthrough no-op (`ApmPassthrough` doesn't touch PCM) — same situation send-side APM has
|
|
||||||
been in since M2; M3's job was the plumbing, not the DSP backend.
|
|
||||||
- [x] **New C ABI surface** (`core/include/voicecat.h`, additive only):
|
|
||||||
`vc_audio_config` struct + `vc_get_stream_audio_config(c, user_id, stream_id, out)` — the
|
|
||||||
effective Opus config for a stream you own or a peer's, reading from the (now richer)
|
|
||||||
`LocalStream`/`session::Stream`. `vc_test_inject_capture(c, stream_id, pcm, samples)` —
|
|
||||||
clearly-marked **test-only**, forwards to `AudioEngine::inject_capture`, so
|
|
||||||
`test_m3_multistream` can drive two concurrent synthetic-audio streams through the real ABI
|
|
||||||
without a microphone.
|
|
||||||
- [x] `tests/test_m3_multistream.cpp` — the M3 exit criterion (ABI-level, mirrors
|
|
||||||
`test_voice_client_abi.cpp`'s approach per the M2 lesson). Registered in `tests/CMakeLists.txt`.
|
|
||||||
|
|
||||||
**Explicitly out of scope for this pass** (confirmed with the user before implementing):
|
## Post-M3 follow-up ✓ (completed 2026-06-16)
|
||||||
- `vc_set_input_device`/`vc_set_input_mode`/`vc_set_push_to_talk`/`vc_list_devices` (device
|
|
||||||
enumeration + VAD/PTT input gate) — still `VC_ERR_NOT_IMPLEMENTED`. These were mentioned as
|
- **Device enumeration** — `vc_list_devices`/`vc_set_input_device`; opaque hex device ids;
|
||||||
"scoped to M3" in the M2 follow-up notes above, but docs/roadmap.md's M3 bullets never
|
`vc_free_device_list` now frees. Works pre-connect.
|
||||||
actually listed them — deferred again, now tracked explicitly rather than implicitly.
|
- **VAD/PTT gate** — `EnergyVadProcessor` (RMS threshold ~0.025, 300 ms hang-time);
|
||||||
- Real WASAPI desktop-audio loopback capture for `SCREEN_AUDIO` — the engine now supports
|
`vc_set_input_mode`/`vc_set_push_to_talk`; MIC-only (SCREEN_AUDIO/AUX_DEVICE bypass).
|
||||||
feeding a second concurrent local stream via `inject_capture`, but only synthetic PCM is
|
- **True stereo playback** — `playback_channels=2`; stereo decoded L→L R→R in mixer; mono
|
||||||
wired up; a real loopback capture device is a follow-up.
|
upmixed L=R; hardware fallback to mono on failure.
|
||||||
- True stereo *playback output* — `AudioEngine`'s mixer/output device stays mono; stereo
|
- **WASAPI loopback** — `loopback_device_` with `ma_device_type_loopback`;
|
||||||
streams are downmixed after decode (see above). The Opus wire format itself is fully
|
`VOICECAT_HAS_LOOPBACK` macro (Windows-only). `vccli --share-screen-audio`.
|
||||||
stereo-correct.
|
|
||||||
|
**Known deferred (still open):** AEC/NS/AGC (no working Windows/MSVC WebRTC APM build);
|
||||||
|
process-specific WASAPI loopback; RT-thread rule violation in `on_capture_frame` (mutex lock
|
||||||
|
on audio callback thread — pre-existing, needs lock-free ring-buffer refactor).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M4 — Native clients ✓ (completed 2026-06-17 – 2026-06-19)
|
||||||
|
|
||||||
|
**Exit criterion:** `ctest --preset dev` 21/21 green; `dotnet build` 0 warnings; `xcodebuild`
|
||||||
|
BUILD SUCCEEDED (macOS + iOS); manually verified: connect, TOFU, channel tree, join, voice,
|
||||||
|
text, device pickers, level meter on each platform.
|
||||||
|
|
||||||
|
**New C ABI (additive):** `vc_list_channels`/`vc_list_users`/`vc_list_user_streams`,
|
||||||
|
`vc_join_channel`, `VC_EVENT_SERVER_IDENTITY` + `vc_confirm_server_identity`,
|
||||||
|
`vc_config::tofu_store_path`, `VC_INPUT_ALWAYS_ON`, `vc_set_vad_threshold`,
|
||||||
|
`vc_audio_suspend`/`vc_audio_resume`, `vc_audio_restart`, `vc_set_capture_channels`.
|
||||||
|
|
||||||
|
**Windows** (`clients/windows/`): `VoiceCat.Interop` (P/Invoke, `[UnmanagedCallersOnly]`),
|
||||||
|
`VoiceCat.App` (ConnectDialog, ServerIdentityDialog, MainForm with full M5 moderation UI,
|
||||||
|
PerUserTuningDialog, PttKeyCaptureDialog), `VoiceCat.Interop.Tests`. PTT is focus-scoped.
|
||||||
|
|
||||||
|
**macOS** (`clients/apple/macOS/VoiceCatMac.xcodeproj`): NSOutlineView channel tree,
|
||||||
|
NSTableView user list, NSTextView chat, voice controls, full VoiceOver accessibility, admin
|
||||||
|
menu, 17 Swift source files. `build-xcframework.sh` produces `VoiceCatCore.xcframework`.
|
||||||
|
|
||||||
|
**iOS** (`clients/apple/iOS/VoiceCatiOS.xcodeproj`): SwiftUI, `NavigationSplitView`/`TabView`,
|
||||||
|
`OutlineGroup` channel tree, `IOSAudioRouter` AVAudioSession driver, 24 Swift source files,
|
||||||
|
iOS 18.0 deployment target. App Group `group.cat.voice.VoiceCat` for Keychain sharing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M5 — Moderation, polish, and beyond [~] (in progress 2026-06-17)
|
||||||
|
|
||||||
|
**Exit criterion:** four ABI-level tests green (`test_m5_permissions`,
|
||||||
|
`test_m5_kick_ban_move_mute`, `test_m5_admin_accounts`, `test_m5_channel_crud`);
|
||||||
|
`vccli` can drive all moderation/admin/channel operations against a live server.
|
||||||
|
|
||||||
|
- [x] **Server-side moderation & permissions** — per-session `Permissions`, kick/ban/move/
|
||||||
|
server-mute, channel CRUD, DB schema v2 (`channels`, `bans`), BLAKE2b channel passwords.
|
||||||
|
- [x] **C ABI** — `vc_kick_user`, `vc_ban_user`, `vc_set_permission`, `vc_set_server_mute`,
|
||||||
|
`vc_move_user`, `vc_create_channel`, `vc_edit_channel`, `vc_delete_channel`,
|
||||||
|
`vc_create_account`, `vc_reset_password`, `vc_delete_account`, `vc_list_accounts`,
|
||||||
|
`vc_get_permissions`; events `VC_EVENT_GENERIC_RESULT`, `VC_EVENT_ACCOUNT_LIST`.
|
||||||
|
- [x] **Four M5 tests** passing — `ctest --preset dev` 21/21.
|
||||||
|
- [x] **vccli** M5 flags: `--kick`, `--ban`, `--move`, `--server-mute`/`-unmute`/`-deafen`/
|
||||||
|
`-undeafen`, `--set-permission`, channel CRUD, account CRUD, `--username`/`--password`.
|
||||||
|
- [x] **All three client UIs** (Windows WinForms, macOS AppKit, iOS SwiftUI) expose the full
|
||||||
|
M5 moderation and admin surface.
|
||||||
|
- [x] **Docs** — `docs/protocol.md`, `docs/security.md` kept in sync.
|
||||||
|
- [x] **DRED/audio-quality polish** — done (2026-06-20). `bool dred` added to `AudioConfig`
|
||||||
|
proto (field 11) and `vc_audio_config` C ABI. Encoder: `OPUS_SET_DRED_DURATION(2)` when
|
||||||
|
enabled (20 ms of ML redundancy per packet). Decoder: `OpusDREDDecoder` + per-stream
|
||||||
|
`OpusDRED` scratch pre-allocated; `JitterBuffer::try_copy_front_payload` peeks at the next
|
||||||
|
buffered packet on every PLC step; if DRED data is present, `opus_decoder_dred_decode`
|
||||||
|
reconstructs the lost frame — otherwise falls back to standard PLC. New test:
|
||||||
|
`test_dred_toggle` (ctest 22/22). Files: `voicecat.proto`, `voicecat.h`,
|
||||||
|
`opus_codec.{h,cpp}`, `audio_engine.{h,cpp}`, `client.cpp`, `session.{h,cpp}`.
|
||||||
|
- [ ] **DRED toggle in client UIs** — expose the `dred` flag in all three channel-config UIs
|
||||||
|
so admins can enable it per channel. Windows: `ChannelEditForm` / `vc_channel_info.audio.dred`
|
||||||
|
checkbox. macOS AppKit: channel-edit sheet. iOS SwiftUI: channel-edit form. All three UIs
|
||||||
|
already have full channel CRUD wired; this is an additive checkbox on the existing audio-config
|
||||||
|
section. (Core/protocol/ABI all done — this is UI-only work.)
|
||||||
|
- [x] **macOS ScreenCaptureKit screen-audio** — done 2026-06-21. `ScreenAudioCapture.swift`
|
||||||
|
drives an `SCStream` (audio-only, `excludesCurrentProcessAudio`), converts Float32 →
|
||||||
|
int16 in the channel's mono/stereo mode, and calls `vc_stream_feed_pcm`. Capture starts on
|
||||||
|
the self `.streamStarted` event (when the effective config is known); wired into
|
||||||
|
`MainWindowController.screenAudioClicked()`.
|
||||||
|
- [x] **iOS ReplayKit Broadcast Extension** (`VoiceCatBroadcast`) — done 2026-06-21.
|
||||||
|
Forward-to-host design: the extension (`SampleHandler.swift`) captures `.audioApp`,
|
||||||
|
converts to 48 kHz int16 stereo, and writes a shared App Group SPSC ring
|
||||||
|
(`BroadcastAudioRing.swift`); the host's `BroadcastAudioPump` owns the `SCREEN_AUDIO`
|
||||||
|
stream and feeds via `vc_stream_feed_pcm` (single session, no creds on disk). UI is an
|
||||||
|
`RPSystemBroadcastPickerView` in `VoiceControlsView`. (Replaced the speculative
|
||||||
|
`BroadcastCredentials.swift` self-connecting design, now removed.)
|
||||||
|
- [x] **External PCM feed/tap API** (`vc_stream_feed_pcm` + `vc_set_pcm_sink`) — done
|
||||||
|
2026-06-20. Promotes `vc_test_inject_capture` (mono-only, TEST-ONLY) to a public API with
|
||||||
|
stereo support. Adds a symmetric PCM sink fired on the playback thread per decoded remote
|
||||||
|
stream. Full wrappers for Swift (`feedPcm`/`setPcmSink`) and C# (`StreamFeedPcm`/
|
||||||
|
`SetPcmSink`). Three new C++ ctests (`test_feed_pcm_round_trip`, `test_feed_pcm_stereo`,
|
||||||
|
`test_pcm_sink`), 4 Swift XCTest smoke tests, 4 C# xUnit smoke tests. Docs updated
|
||||||
|
(architecture.md §4 new subsection, voice.md §9 updated, protocol.md §8 explicit
|
||||||
|
no-protocol-change note, roadmap.md M5 entry). `ctest --preset dev` 23/23.
|
||||||
|
- [x] **macOS client UI overhaul** — done 2026-06-20. Mirrors the Windows client's UI
|
||||||
|
overhaul (toolbar, unified log, PM windows, channel counts, output volume, keyboard
|
||||||
|
shortcuts), adapted to Mac-native conventions:
|
||||||
|
- **NSToolbar**: Join Voice, Share Screen Audio, Mute, Deafen (SF Symbol toggle buttons),
|
||||||
|
and Output Volume slider (NSSlider 0–100, default 80). Voice actions + mute/deafen +
|
||||||
|
output volume moved out of the bottom voice panel into the toolbar. Bottom panel keeps
|
||||||
|
input-mode segmented control / VAD slider / PTT key / device picker / level meter.
|
||||||
|
- **Unified log**: chat `NSTextView` + activity `NSTableView` collapsed into a single
|
||||||
|
`NSTextView` — activity events in `secondaryLabelColor` (gray), chat in default color.
|
||||||
|
Removed `activityTableView` and `activityLog` array.
|
||||||
|
- **Private messaging**: scope dropdown removed; compose bar always sends to the current
|
||||||
|
channel. Each PM conversation opens in its own modeless `PrivateMessageWindowController`
|
||||||
|
(NSWindow). Incoming `.textMessage` with `.private` scope routed to the right window;
|
||||||
|
outgoing PMs echoed by server arrive through the same path. "Send Private Message…"
|
||||||
|
added to user context menu. "New Private Message…" (⌘⇧N) opens `UserPickerSheet`
|
||||||
|
listing all server users.
|
||||||
|
- **Channel counts**: outline view renders `"Name (n)"` with live user counts;
|
||||||
|
`refreshChannelTree()` called on `.userJoined`/`.userLeft` (was missing).
|
||||||
|
- **Voice menu** (⌘⇧V join/leave, ⌘⇧S share screen, ⌘⇧M mute, ⌘⇧D deafen) and **Messages
|
||||||
|
menu** (⌘⇧N new PM) added to `NSApp.mainMenu` via `NSMenuItem` key equivalents with
|
||||||
|
`[.command, .shift]` mask. Removed on `windowWillClose`. Mac-native: ⌘ not Ctrl, dispatched
|
||||||
|
by the responder chain (no custom key monitor needed).
|
||||||
|
- **Output volume**: `setOutputVolume(_:)` wrapper added to `VoiceCatClient.swift` (was
|
||||||
|
missing — the C ABI + C# wrapper shipped in commit 97fa659 but the Swift wrapper was
|
||||||
|
never added). Wired end-to-end: toolbar slider → `client.setOutputVolume(gain)`.
|
||||||
|
- **Part A (uncompiled Swift fix)**: the external PCM feed/tap Swift wrapper (commit
|
||||||
|
615d2a8) was never compiled — the local xcframework predating the `voicecat.h` PCM
|
||||||
|
additions. Fixed: rebuilt xcframework (regenerated module map), fixed `UInt`→`Int` type
|
||||||
|
mismatch in `feedPcm` (Swift imports `size_t` as `Int` not `UInt`), added
|
||||||
|
`VoiceCatPcmSinkCallback` typealias (Swift-idiomatic alias for the C `vc_pcm_sink_cb`
|
||||||
|
so consumers don't need to directly import `VoiceCatC`). `swift test` 10/10 green.
|
||||||
|
- **Audio settings moved to Settings window**: the bottom voice panel (input mode, VAD
|
||||||
|
slider, PTT key, device picker, level meter) was removed from the main window and moved
|
||||||
|
into a new `SettingsWindowController` — a modeless window opened via the app menu's
|
||||||
|
"Settings…" (⌘,) item. The main window is now just toolbar + channels + users + chat.
|
||||||
|
Source-of-truth for audio settings (`selectedInputMode`, `vadThresholdValue`,
|
||||||
|
`selectedInputDeviceId`, `pttKeyCode`) lives in `MainWindowController` so voice start can
|
||||||
|
apply them even before the settings window has been opened; `SettingsWindowController`
|
||||||
|
reads from and writes back to those properties and applies changes to the client
|
||||||
|
immediately when voice is active. The level meter is forwarded from
|
||||||
|
`MainWindowController.handleLevel` → `settingsWindowController.updateLevel(rms:)`.
|
||||||
|
`keyCodeName` helper deduplicated (was duplicated in `PttKeyCaptureSheet.swift` +
|
||||||
|
`MainWindowController.swift` — now shared from `MainWindowController.swift`).
|
||||||
|
- Files: `MainWindowController.swift` (overhauled), `PrivateMessageWindowController.swift`
|
||||||
|
(new), `UserPickerSheet.swift` (new), `SettingsWindowController.swift` (new),
|
||||||
|
`VoiceCatClient.swift` (setOutputVolume + VoiceCatPcmSinkCallback typealias + feedPcm
|
||||||
|
type fix), `ExternalPcmTests.swift` (use typealias), `PttKeyCaptureSheet.swift` (removed
|
||||||
|
duplicate `keyCodeName`), `VoiceCatMac.xcodeproj/project.pbxproj` (register 3 new files).
|
||||||
|
- **Platform-specific adaptations** (vs. Windows): `NSToolbar` instead of `ToolStrip`;
|
||||||
|
global menu bar + `NSMenuItem` key equivalents (⌘ not Ctrl, responder-chain dispatched);
|
||||||
|
PM windows as modeless `NSWindow`s; picker as Mac sheet; gray = `secondaryLabelColor`;
|
||||||
|
SF Symbols for toolbar icons.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
35
README.md
35
README.md
@@ -5,10 +5,11 @@ channel-based voice, channel + private text, one server you run yourself. Plain
|
|||||||
(control) and **UDP** (media), no WebRTC. Encrypted by default. A shared **C++ core**
|
(control) and **UDP** (media), no WebRTC. Encrypted by default. A shared **C++ core**
|
||||||
(`libvoicecat`) drives native clients (Swift on macOS/iOS, C# on Windows) and the server.
|
(`libvoicecat`) drives native clients (Swift on macOS/iOS, C# on Windows) and the server.
|
||||||
|
|
||||||
> **Status: pre-implementation.** The design is complete in [`docs/`](docs/). The code is an
|
> **Status:** Design complete in [`docs/`](docs/). M1–M5 are implemented — real TLS control
|
||||||
> M0 **skeleton** — it compiles and links, but every subsystem is a stub. See
|
> plane, encrypted UDP voice (Opus), multi-stream, TOFU identity pinning, channel tree,
|
||||||
> [`AGENTS.md`](AGENTS.md) to start building, and [`docs/roadmap.md`](docs/roadmap.md) for the
|
> permissions, moderation, disconnect/keepalive/reaper. Windows WinForms C# client shipped
|
||||||
> milestones.
|
> (M4). macOS/iOS Swift client is next. See [`PROGRESS.md`](PROGRESS.md) and
|
||||||
|
> [`docs/roadmap.md`](docs/roadmap.md).
|
||||||
|
|
||||||
## Read the design first
|
## Read the design first
|
||||||
|
|
||||||
@@ -16,28 +17,34 @@ The [`docs/`](docs/) folder is the source of truth. Start at [`docs/README.md`](
|
|||||||
then `architecture` → `protocol` → `voice` → `security` → `tech-stack` → `deployment` →
|
then `architecture` → `protocol` → `voice` → `security` → `tech-stack` → `deployment` →
|
||||||
`roadmap`.
|
`roadmap`.
|
||||||
|
|
||||||
## Build the skeleton (no dependencies needed yet)
|
## Build
|
||||||
|
|
||||||
The M0 skeleton builds with just a C++20 compiler + CMake + Ninja — **no vcpkg, no
|
The default development preset is **`dev`** — it builds everything (server + tools + tests)
|
||||||
third-party libraries**, because every subsystem is currently a stub.
|
with real vcpkg deps. It works on Windows, Linux, and macOS (vcpkg triplet auto-resolved).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# one-time vcpkg setup:
|
||||||
|
git clone https://github.com/microsoft/vcpkg && ./vcpkg/bootstrap-vcpkg.sh # .bat on Windows
|
||||||
|
export VCPKG_ROOT=/path/to/vcpkg # Linux/macOS; or $env:VCPKG_ROOT on PowerShell
|
||||||
|
|
||||||
|
# configure + build + test:
|
||||||
cmake --preset dev
|
cmake --preset dev
|
||||||
cmake --build --preset dev
|
cmake --build --preset dev
|
||||||
ctest --preset dev # runs the smoke test (links the core, calls the C ABI)
|
ctest --preset dev # 21 behavior tests
|
||||||
```
|
```
|
||||||
|
|
||||||
Artifacts land in `build/dev/bin/` (`voicecat-server`, `vccli`).
|
Artifacts land in `build/dev/bin/` (`voicecat-server`, `vccli`, `voicecat-admin`).
|
||||||
|
|
||||||
When you start implementing a subsystem that needs real libraries, build with vcpkg deps:
|
The `skeleton` preset (no vcpkg deps, stubs only) is a fast smoke check that needs no
|
||||||
|
third-party libraries:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# one-time: git clone https://github.com/microsoft/vcpkg && ./vcpkg/bootstrap-vcpkg.sh
|
cmake --preset skeleton && cmake --build --preset skeleton && ctest --preset skeleton
|
||||||
export VCPKG_ROOT=/path/to/vcpkg # set VCPKG_ROOT (works on Linux/macOS/Windows)
|
|
||||||
cmake --preset server-release # auto-installs deps from vcpkg.json
|
|
||||||
cmake --build --preset server-release
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
See [`docs/building.md`](docs/building.md) for the full preset matrix (including `release`,
|
||||||
|
`server-release`, `windows-client`, and Apple platform scaffolding).
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
64
clients/apple/Package.swift
Normal file
64
clients/apple/Package.swift
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// swift-tools-version: 6.0
|
||||||
|
//
|
||||||
|
// VoiceCatCore — the shared Swift core for the VoiceCat macOS (AppKit) and iOS (SwiftUI)
|
||||||
|
// clients. It wraps libvoicecat's C ABI (core/include/voicecat.h) as imported through the
|
||||||
|
// VoiceCatCore.xcframework binary target's module map (`import VoiceCatC`), and exposes a
|
||||||
|
// Swift-idiomatic, @MainActor-safe surface.
|
||||||
|
//
|
||||||
|
// Architecture: docs/architecture.md §4 ("one core, many faces"). The Windows C# client
|
||||||
|
// (clients/windows/VoiceCat.Interop) is the proven mirror of this same layering — the Swift
|
||||||
|
// wrapper follows the same patterns (callback-lifetime, string-lifetime, event-delivery
|
||||||
|
// thread handoff, immediate vc_free_* on list reads) adapted to Swift's interop model.
|
||||||
|
//
|
||||||
|
// The XCFramework is a LOCAL BUILD ARTIFACT — run `scripts/build-xcframework.sh` before
|
||||||
|
// `swift build` / `swift test`. See clients/apple/README.md.
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "VoiceCatCore",
|
||||||
|
// macOS 14 (Sonoma) is the AppKit client's deployment target. iOS 18 is the SwiftUI client
|
||||||
|
// target (clients/apple/iOS/) — 18.0 unlocks the newest AVAudioSession APIs (stereo capture,
|
||||||
|
// polar patterns, data sources). Run `scripts/build-xcframework.sh --all` to produce all
|
||||||
|
// three slices: macos-arm64, ios-arm64, ios-arm64-simulator.
|
||||||
|
// swift-tools-version 6.0 is required for .iOS(.v18); swiftLanguageVersions .v5 keeps the
|
||||||
|
// Swift 5 language mode (avoids Swift 6 strict concurrency checking on pre-existing code).
|
||||||
|
platforms: [
|
||||||
|
.macOS(.v14),
|
||||||
|
.iOS(.v18),
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
.library(name: "VoiceCatCore", targets: ["VoiceCatCore"]),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
// Binary target — the prebuilt static lib + headers + module map. Produced by
|
||||||
|
// scripts/build-xcframework.sh from the `apple-dev` CMake preset.
|
||||||
|
.binaryTarget(
|
||||||
|
name: "VoiceCatCoreXCF",
|
||||||
|
path: "VoiceCatCore.xcframework"
|
||||||
|
),
|
||||||
|
// The Swift wrapper library — what the macOS/iOS apps import as `import VoiceCatCore`.
|
||||||
|
.target(
|
||||||
|
name: "VoiceCatCore",
|
||||||
|
dependencies: ["VoiceCatCoreXCF"],
|
||||||
|
path: "Sources/VoiceCatCore"
|
||||||
|
),
|
||||||
|
// Smoke tests against a real voicecat-server — mirrors clients/windows/
|
||||||
|
// VoiceCat.Interop.Tests/VoiceCatClientSmokeTests.cs. Requires the `dev` CMake preset
|
||||||
|
// to be built (build/dev/bin/voicecat-server + voicecat-admin).
|
||||||
|
//
|
||||||
|
// linkerSettings: libvoicecat.a is a static C++20 library (built by the apple-dev
|
||||||
|
// preset with vcpkg's clang), so the final executable must link libc++ (the LLVM C++
|
||||||
|
// standard library on macOS). vcpkg's static deps (mbedtls/sodium/opus/protobuf/
|
||||||
|
// sqlite3/spdlog/asio) are already compiled into the .a; macOS system frameworks
|
||||||
|
// (CoreAudio/CoreFoundation) are auto-discovered by the linker (PROGRESS.md).
|
||||||
|
.testTarget(
|
||||||
|
name: "VoiceCatCoreTests",
|
||||||
|
dependencies: ["VoiceCatCore"],
|
||||||
|
path: "Tests/VoiceCatCoreTests",
|
||||||
|
linkerSettings: [
|
||||||
|
.linkedLibrary("c++"),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
],
|
||||||
|
swiftLanguageModes: [.v5]
|
||||||
|
)
|
||||||
@@ -1,19 +1,130 @@
|
|||||||
# Apple client (macOS + iOS) — placeholder
|
# Apple client (macOS + iOS)
|
||||||
|
|
||||||
Built in **M4** (see [`docs/roadmap.md`](../../docs/roadmap.md)). Swift + SwiftUI, consuming
|
Built in **M4** (see [`docs/roadmap.md`](../../docs/roadmap.md)). One shared **Swift core**
|
||||||
`libvoicecat` through the C ABI ([`core/include/voicecat.h`](../../core/include/voicecat.h)).
|
(`VoiceCatCore` package) wrapping the C ABI ([`core/include/voicecat.h`](../../core/include/voicecat.h)),
|
||||||
|
with platform-specific UIs: **AppKit** for macOS (best VoiceOver accessibility), **SwiftUI**
|
||||||
|
for iOS. See [`docs/architecture.md`](../../docs/architecture.md) §4 and
|
||||||
|
[`docs/tech-stack.md`](../../docs/tech-stack.md) §2.
|
||||||
|
|
||||||
Planned shape (see [`docs/architecture.md`](../../docs/architecture.md) §4 and
|
## What's here now
|
||||||
[`docs/tech-stack.md`](../../docs/tech-stack.md) §2):
|
|
||||||
|
|
||||||
- A Swift Package wrapping the core as an **XCFramework** (macOS + iOS device + simulator).
|
### `VoiceCatCore` Swift Package — ✓ complete (2026-06-18)
|
||||||
- A module map exposing `voicecat.h` to Swift (Swift can also use C++ interop directly, but
|
|
||||||
the C ABI is the stable contract).
|
|
||||||
- SwiftUI app target for macOS and iOS.
|
|
||||||
- **iOS audio:** app owns `AVAudioSession` (`.playAndRecord` / `.voiceChat`), mic permission,
|
|
||||||
interruption/route handling, calling `vc_audio_*` hooks on the core.
|
|
||||||
- **iOS screen/system audio (`SCREEN_AUDIO`):** a **ReplayKit Broadcast Upload Extension**
|
|
||||||
capturing `RPSampleBufferType.audioApp`, linking a minimal core slice, sharing session
|
|
||||||
state via an **App Group** ([`docs/voice.md`](../../docs/voice.md) §9).
|
|
||||||
|
|
||||||
Nothing here yet — the core must reach M2 (working voice) before the GUI is worth building.
|
The shared Swift core that both the macOS AppKit app and the iOS SwiftUI app will consume.
|
||||||
|
Mirrors the Windows client's `VoiceCat.Interop` layer ([`clients/windows/`](../windows/))
|
||||||
|
using Swift-native C interop instead of P/Invoke.
|
||||||
|
|
||||||
|
```
|
||||||
|
clients/apple/
|
||||||
|
├── Package.swift # SPM: binary target (XCFramework) + VoiceCatCore library + tests
|
||||||
|
├── VoiceCatCore.xcframework/ # BUILT ARTIFACT — produced by scripts/build-xcframework.sh (gitignored)
|
||||||
|
├── scripts/
|
||||||
|
│ └── build-xcframework.sh # builds libvoicecat + vcpkg deps → fat .a → XCFramework + module map
|
||||||
|
├── Sources/VoiceCatCore/
|
||||||
|
│ ├── Enums.swift # Swift-idiomatic mirrors of the 9 voicecat.h C enums
|
||||||
|
│ ├── Config.swift # VoiceCatConfig (wraps vc_config)
|
||||||
|
│ ├── Event.swift # VoiceCatEvent — copies ev.text inside the callback (the #1 lifetime rule)
|
||||||
|
│ ├── Models.swift # Channel, User, Stream, Device, Permissions, Account, AudioConfig, …
|
||||||
|
│ ├── Marshaling.swift # C arrays → Swift arrays + immediate vc_free_* (callers never manage native lifetime)
|
||||||
|
│ ├── Callbacks.swift # @convention(c) on_event/on_level + Unmanaged.passUnretained context bridging
|
||||||
|
│ └── VoiceCatClient.swift # the public Swift surface — owns vc_client*, all 38 C functions, event delivery on @MainActor
|
||||||
|
└── Tests/VoiceCatCoreTests/
|
||||||
|
└── VoiceCatClientSmokeTests.swift # 6 XCTest smoke tests against a real voicecat-server (6/6 green)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key patterns** (carried over from the proven C# `VoiceCat.Interop` — see
|
||||||
|
[`docs/architecture.md`](../../docs/architecture.md) §4 per-platform binding notes):
|
||||||
|
|
||||||
|
- **C interop via module map:** `import VoiceCatC` — Swift sees all C enums/structs/functions
|
||||||
|
directly. No manual struct/function redeclaration (unlike C# P/Invoke). The module map
|
||||||
|
(`module VoiceCatC { header "voicecat.h" }`) is staged into the XCFramework headers by
|
||||||
|
`build-xcframework.sh`.
|
||||||
|
- **`@convention(c)` callbacks:** plain C function pointers (not ARC-managed closures) +
|
||||||
|
`Unmanaged.passUnretained(self)` as the `user` context — the Swift analog of C#'s
|
||||||
|
`[UnmanagedCallersOnly]` + `GCHandle`. `deinit` calls `vc_client_destroy` (joins all
|
||||||
|
threads) before the object's memory is freed, so no callback can fire with a dangling
|
||||||
|
pointer.
|
||||||
|
- **Config string lifetimes:** the core stores raw pointers from `vc_config` (doesn't copy).
|
||||||
|
Native CString storage (`strdup`) is held for the client's entire lifetime, freed in
|
||||||
|
`deinit` after `vc_client_destroy`.
|
||||||
|
- **Event delivery:** events buffered in a lock-protected array + coalesced
|
||||||
|
`DispatchQueue.main` drain (one async block at a time) — the Swift analog of C#'s
|
||||||
|
`Channel<VoiceCatEvent>` + 30ms WinForms Timer pump. `ev.text` is copied to `String`
|
||||||
|
inside the callback before enqueueing (dangling-pointer rule).
|
||||||
|
- **Level meters:** coalesced to latest-per-stream-id (intermediate values are visually
|
||||||
|
irrelevant, same as C#'s `ConcurrentDictionary<uint,float>`).
|
||||||
|
- **Immediate `vc_free_*`** on list reads — callers never manage native list lifetime.
|
||||||
|
|
||||||
|
### Tests — 6/6 green
|
||||||
|
|
||||||
|
```
|
||||||
|
swift test
|
||||||
|
# ✓ testVersionStringIsNonEmpty
|
||||||
|
# ✓ testResultStringRoundTrips
|
||||||
|
# ✓ testConnectTofuAuthListChannelsRoundTrips (connect → TOFU → confirm → guest auth → channels → permissions → guest ListAccounts rejected)
|
||||||
|
# ✓ testAdminChannelCrudAccountCrudRoundTrips (admin auth → channel create/edit/delete → account create/list/reset/delete)
|
||||||
|
# ✓ testScreenAudioStreamStartsAndStops (screen-audio stream start/stop through Swift interop)
|
||||||
|
# ✓ testPerStreamRecvControlsRoundTrip (two clients, per-stream gain/mute/NR round-trip)
|
||||||
|
```
|
||||||
|
|
||||||
|
Prerequisites for tests: `cmake --preset dev && cmake --build --preset dev` (builds
|
||||||
|
`voicecat-server` + `voicecat-admin` into `build/dev/bin/`).
|
||||||
|
|
||||||
|
## What's NOT here yet (next steps)
|
||||||
|
|
||||||
|
- **macOS AppKit app** (`clients/apple/macOS/`) — the M4 UI: connect dialog, saved-server
|
||||||
|
list (Keychain for passwords), TOFU identity dialog, main window (NSOutlineView channel
|
||||||
|
tree, NSTableView user list, NSTextView chat, activity log), voice controls, per-user
|
||||||
|
tuning, full VoiceOver accessibility. Mirrors the Windows `VoiceCat.App` feature set.
|
||||||
|
- **iOS SwiftUI app** — AVAudioSession, mic permission, foreground voice.
|
||||||
|
- **`vc_audio_suspend`/`vc_audio_resume` ABI hooks** — deferred until the iOS client
|
||||||
|
milestone (keep ABI stable).
|
||||||
|
- **ReplayKit Broadcast Upload Extension** for iOS `SCREEN_AUDIO` ([`docs/voice.md`](../../docs/voice.md) §9).
|
||||||
|
- **macOS `SCREEN_AUDIO`** via ScreenCaptureKit (currently stub returns `false`).
|
||||||
|
- **iOS XCFramework slices** — `apple-ios` / `apple-ios-sim` presets are scaffolding; run
|
||||||
|
`scripts/build-xcframework.sh --all` once the iOS vcpkg triplets are validated.
|
||||||
|
|
||||||
|
## Building the XCFramework
|
||||||
|
|
||||||
|
The XCFramework is a **local build artifact** (gitignored, like the Windows client's
|
||||||
|
`build/windows-client/bin/voicecat.dll`). Run the build script before `swift build` /
|
||||||
|
`swift test`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Prerequisites: VCPKG_ROOT set, Xcode installed
|
||||||
|
export VCPKG_ROOT=/path/to/vcpkg
|
||||||
|
|
||||||
|
# Build the macOS slice + fat static lib + XCFramework (validated)
|
||||||
|
scripts/build-xcframework.sh
|
||||||
|
# → clients/apple/VoiceCatCore.xcframework/ (macOS-arm64 slice)
|
||||||
|
|
||||||
|
# Build all 3 slices (macOS + iOS device + iOS sim) — iOS still scaffolding
|
||||||
|
scripts/build-xcframework.sh --all
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fat static library
|
||||||
|
|
||||||
|
The `apple-dev` CMake preset produces a 1.9 MB `libvoicecat.a` containing only voicecat's
|
||||||
|
own object files — vcpkg's static dependencies (protobuf, mbedtls, libsodium, opus, sqlite3,
|
||||||
|
spdlog, asio, abseil, …) are 107 separate `.a` files under `vcpkg_installed/arm64-osx/lib/`.
|
||||||
|
A Swift Package binary target can only link ONE `.a` per XCFramework slice, so
|
||||||
|
`build-xcframework.sh` merges them all into a single self-contained `libvoicecat-fat.a`
|
||||||
|
(~30 MB) using `libtool -static`. This is the Apple equivalent of how the Windows client
|
||||||
|
ships a single `voicecat.dll` with all deps statically linked (via MinGW's `-static` flags
|
||||||
|
in [`core/CMakeLists.txt`](../../core/CMakeLists.txt)).
|
||||||
|
|
||||||
|
### Swift Package
|
||||||
|
|
||||||
|
```bash
|
||||||
|
swift build # builds VoiceCatCore library
|
||||||
|
swift test # runs 6 smoke tests against a real voicecat-server
|
||||||
|
```
|
||||||
|
|
||||||
|
The `Package.swift` declares:
|
||||||
|
- A **binary target** (`VoiceCatCoreXCF`) pointing at the local `VoiceCatCore.xcframework`.
|
||||||
|
- A **library target** (`VoiceCatCore`) that depends on the binary target and provides the
|
||||||
|
Swift wrapper.
|
||||||
|
- A **test target** (`VoiceCatCoreTests`) with `linkerSettings: [.linkedLibrary("c++")]` —
|
||||||
|
the fat static lib is C++20, so the final executable must link libc++ (the LLVM C++ standard
|
||||||
|
library on macOS). vcpkg's static deps are already in the `.a`; macOS system frameworks
|
||||||
|
(CoreAudio/CoreFoundation) are auto-discovered by the linker.
|
||||||
|
|||||||
51
clients/apple/Sources/VoiceCatCore/Callbacks.swift
Normal file
51
clients/apple/Sources/VoiceCatCore/Callbacks.swift
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
// Callbacks — the C function pointers passed to `vc_callbacks`. These are the Swift
|
||||||
|
// equivalent of the C# client's `[UnmanagedCallersOnly]` static methods (NativeCallbacks.cs).
|
||||||
|
//
|
||||||
|
// The critical patterns (carried over from the proven C# implementation):
|
||||||
|
// 1. `@convention(c)` closures — plain C function pointers, NOT GC/ARC-managed closures.
|
||||||
|
// A @convention(c) closure cannot capture context, which is why the `user` pointer is
|
||||||
|
// used to resolve back to the VoiceCatClient instance (the C# version uses GCHandle for
|
||||||
|
// the same thing; Swift uses Unmanaged).
|
||||||
|
// 2. `Unmanaged.passUnretained(self).toOpaque()` as the `user` context — a stable raw
|
||||||
|
// pointer to the Swift object WITHOUT incrementing the retain count. This is safe
|
||||||
|
// because `deinit` calls `vc_client_destroy` (which synchronously joins every internal
|
||||||
|
// thread) BEFORE the object's memory is freed — so no callback can fire after the object
|
||||||
|
// is gone. (The C# equivalent: GCHandle.Alloc + GCHandle.Free in Dispose.)
|
||||||
|
// 3. Copy `ev.text` to a Swift `String` INSIDE `onEvent` (via `VoiceCatEvent.from(_:)`)
|
||||||
|
// before returning — the raw pointer is dangling after the callback returns. This is
|
||||||
|
// the #1 lifetime rule from voicecat.h's vc_event doc comment.
|
||||||
|
|
||||||
|
import VoiceCatC
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Internal: builds the `vc_callbacks` struct wired to VoiceCatClient's C function pointers.
|
||||||
|
/// The `user` context is an Unmanaged-passUnretained pointer to the client — resolved back
|
||||||
|
/// to the client inside `onEvent`/`onLevel` below.
|
||||||
|
internal enum Callbacks {
|
||||||
|
/// The `on_event` C function pointer. Non-capturing @convention(c) closure — resolves
|
||||||
|
/// the VoiceCatClient from `user` and enqueues a safe copy of the event.
|
||||||
|
static let onEvent: @convention(c) (
|
||||||
|
UnsafeMutableRawPointer?, UnsafePointer<vc_event>?
|
||||||
|
) -> Void = { user, ev in
|
||||||
|
guard let user, let ev else { return }
|
||||||
|
let client = Unmanaged<VoiceCatClient>.fromOpaque(user).takeUnretainedValue()
|
||||||
|
// Copy the event (including text) to a Swift value NOW — the raw vc_event is
|
||||||
|
// invalid after this callback returns.
|
||||||
|
client.enqueueEvent(VoiceCatEvent.from(ev.pointee))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `on_level` C function pointer. Coalesces to "latest sample per stream_id"
|
||||||
|
/// (intermediate values are visually irrelevant — same as C#'s ConcurrentDictionary).
|
||||||
|
static let onLevel: @convention(c) (
|
||||||
|
UnsafeMutableRawPointer?, UInt32, Float
|
||||||
|
) -> Void = { user, streamId, rms in
|
||||||
|
guard let user else { return }
|
||||||
|
let client = Unmanaged<VoiceCatClient>.fromOpaque(user).takeUnretainedValue()
|
||||||
|
client.enqueueLevel(streamId, rms)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct the vc_callbacks struct for a given client.
|
||||||
|
static func make(user: UnsafeMutableRawPointer) -> vc_callbacks {
|
||||||
|
vc_callbacks(on_event: onEvent, on_level: onLevel, user: user)
|
||||||
|
}
|
||||||
|
}
|
||||||
30
clients/apple/Sources/VoiceCatCore/Config.swift
Normal file
30
clients/apple/Sources/VoiceCatCore/Config.swift
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// VoiceCatConfig — Swift-idiomatic mirror of `vc_config` (voicecat.h). Passed to
|
||||||
|
// VoiceCatClient.init. The native CString storage for the string fields is held for the
|
||||||
|
// client's entire lifetime inside VoiceCatClient — see VoiceCatClient.swift's doc comment
|
||||||
|
// on why (the core stores raw pointers from vc_config by value, it does not copy the data).
|
||||||
|
|
||||||
|
import VoiceCatC
|
||||||
|
|
||||||
|
/// Configuration for a `VoiceCatClient`. Mirrors `vc_config`.
|
||||||
|
public struct VoiceCatConfig: Sendable {
|
||||||
|
/// E.g. "VoiceCat-macOS". Forwarded in `ClientHello.client_name`.
|
||||||
|
public let clientName: String
|
||||||
|
/// E.g. "0.0.1". Forwarded in `ClientHello.client_version`.
|
||||||
|
public let clientVersion: String
|
||||||
|
public let logLevel: VoiceCatLogLevel
|
||||||
|
/// Path to the TOFU pin file (see `confirmServerIdentity` / docs/security.md §1.1).
|
||||||
|
/// nil = built-in relative default (only suitable for tests).
|
||||||
|
public let tofuStorePath: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
clientName: String,
|
||||||
|
clientVersion: String,
|
||||||
|
logLevel: VoiceCatLogLevel = .info,
|
||||||
|
tofuStorePath: String? = nil
|
||||||
|
) {
|
||||||
|
self.clientName = clientName
|
||||||
|
self.clientVersion = clientVersion
|
||||||
|
self.logLevel = logLevel
|
||||||
|
self.tofuStorePath = tofuStorePath
|
||||||
|
}
|
||||||
|
}
|
||||||
155
clients/apple/Sources/VoiceCatCore/Enums.swift
Normal file
155
clients/apple/Sources/VoiceCatCore/Enums.swift
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
// Swift-idiomatic mirrors of the voicecat.h C enums. Keep these in lockstep with
|
||||||
|
// core/include/voicecat.h — values are append-only per the C ABI's house rule, so it's
|
||||||
|
// safe to add new cases at the end here too, but never renumber/remove existing ones.
|
||||||
|
//
|
||||||
|
// Swift imports the C enums directly via `import VoiceCatC` (e.g. VoiceCatC.VC_OK), but
|
||||||
|
// those case names are C-style (VC_ERR_NOT_IMPLEMENTED, VC_EVENT_SERVER_IDENTITY) — these
|
||||||
|
// mirrors give the Swift UI and tests clean dot-syntax (VoiceCatResult.notImplemented,
|
||||||
|
// VoiceCatEventType.serverIdentity) and a typed bridge to/from the C values.
|
||||||
|
//
|
||||||
|
// NOTE: Swift's Clang importer brings C `typedef enum` types in as UInt32-backed enums
|
||||||
|
// (all our C enum values are non-negative), so these mirrors use UInt32 raw values too.
|
||||||
|
// The one signed field in the ABI — `vc_event.result` is `int32_t` (not `vc_result`) — is
|
||||||
|
// bridged via `UInt32(bitPattern:)` in Event.swift.
|
||||||
|
|
||||||
|
import VoiceCatC
|
||||||
|
|
||||||
|
/// Result codes — mirrors `vc_result` (voicecat.h). Additive-only: new values go at the end.
|
||||||
|
public enum VoiceCatResult: UInt32, Sendable, Equatable {
|
||||||
|
case ok = 0
|
||||||
|
case notImplemented = 1
|
||||||
|
case invalidArg = 2
|
||||||
|
case notConnected = 3
|
||||||
|
case already = 4
|
||||||
|
case authFailed = 5
|
||||||
|
case permissionDenied = 6
|
||||||
|
case timeout = 7
|
||||||
|
case io = 8
|
||||||
|
case protocolError = 9
|
||||||
|
case crypto = 10
|
||||||
|
case audio = 11
|
||||||
|
case internalError = 12
|
||||||
|
|
||||||
|
/// Human-readable description from the core (vc_result_string returns a static literal).
|
||||||
|
public var description: String {
|
||||||
|
String(cString: vc_result_string(vc_result(rawValue)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bridge from the C enum.
|
||||||
|
public init(_ cValue: vc_result) { self = VoiceCatResult(rawValue: cValue.rawValue) ?? .internalError }
|
||||||
|
/// Bridge to the C enum.
|
||||||
|
public var cValue: vc_result { vc_result(rawValue) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log level — mirrors `vc_log_level`.
|
||||||
|
public enum VoiceCatLogLevel: UInt32, Sendable, Equatable {
|
||||||
|
case trace = 0
|
||||||
|
case debug = 1
|
||||||
|
case info = 2
|
||||||
|
case warn = 3
|
||||||
|
case error = 4
|
||||||
|
case off = 5
|
||||||
|
|
||||||
|
public init(_ cValue: vc_log_level) { self = VoiceCatLogLevel(rawValue: cValue.rawValue) ?? .info }
|
||||||
|
public var cValue: vc_log_level { vc_log_level(rawValue) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connection state — mirrors `vc_connection_state`.
|
||||||
|
public enum VoiceCatConnectionState: UInt32, Sendable, Equatable {
|
||||||
|
case disconnected = 0
|
||||||
|
case connecting = 1
|
||||||
|
case tlsHandshake = 2
|
||||||
|
case authenticating = 3
|
||||||
|
case connected = 4
|
||||||
|
/// M4: handshake succeeded, waiting on `confirmServerIdentity()`.
|
||||||
|
case verifyingIdentity = 5
|
||||||
|
|
||||||
|
public init(_ cValue: vc_connection_state) {
|
||||||
|
self = VoiceCatConnectionState(rawValue: cValue.rawValue) ?? .disconnected
|
||||||
|
}
|
||||||
|
public var cValue: vc_connection_state { vc_connection_state(rawValue) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text message scope — mirrors `vc_text_scope`.
|
||||||
|
public enum VoiceCatTextScope: UInt32, Sendable, Equatable {
|
||||||
|
case channel = 0
|
||||||
|
case `private` = 1
|
||||||
|
case server = 2
|
||||||
|
|
||||||
|
public init(_ cValue: vc_text_scope) { self = VoiceCatTextScope(rawValue: cValue.rawValue) ?? .channel }
|
||||||
|
public var cValue: vc_text_scope { vc_text_scope(rawValue) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Audio device kind — mirrors `vc_device_kind`.
|
||||||
|
public enum VoiceCatDeviceKind: UInt32, Sendable, Equatable {
|
||||||
|
case input = 0
|
||||||
|
case output = 1
|
||||||
|
|
||||||
|
public init(_ cValue: vc_device_kind) { self = VoiceCatDeviceKind(rawValue: cValue.rawValue) ?? .input }
|
||||||
|
public var cValue: vc_device_kind { vc_device_kind(rawValue) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream kind — mirrors `vc_stream_kind`.
|
||||||
|
public enum VoiceCatStreamKind: UInt32, Sendable, Equatable {
|
||||||
|
case mic = 0
|
||||||
|
/// System/desktop audio (docs/voice.md §9).
|
||||||
|
case screenAudio = 1
|
||||||
|
case auxDevice = 2
|
||||||
|
|
||||||
|
public init(_ cValue: vc_stream_kind) { self = VoiceCatStreamKind(rawValue: cValue.rawValue) ?? .mic }
|
||||||
|
public var cValue: vc_stream_kind { vc_stream_kind(rawValue) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send-side input gate mode (docs/voice.md §11) — mirrors `vc_input_mode`.
|
||||||
|
public enum VoiceCatInputMode: UInt32, Sendable, Equatable {
|
||||||
|
case voiceActivation = 0
|
||||||
|
case pushToTalk = 1
|
||||||
|
/// Transmit unconditionally, no VAD gate.
|
||||||
|
case alwaysOn = 2
|
||||||
|
|
||||||
|
public init(_ cValue: vc_input_mode) { self = VoiceCatInputMode(rawValue: cValue.rawValue) ?? .voiceActivation }
|
||||||
|
public var cValue: vc_input_mode { vc_input_mode(rawValue) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Event type — mirrors `vc_event_type`. Additive-only.
|
||||||
|
public enum VoiceCatEventType: UInt32, Sendable, Equatable {
|
||||||
|
case connectionState = 0
|
||||||
|
case authResult = 1
|
||||||
|
case channelList = 2
|
||||||
|
case userJoined = 3
|
||||||
|
case userLeft = 4
|
||||||
|
case userUpdated = 5
|
||||||
|
case textMessage = 6
|
||||||
|
case streamStarted = 7
|
||||||
|
case streamStopped = 8
|
||||||
|
case talkState = 9
|
||||||
|
case error = 10
|
||||||
|
case disconnected = 11
|
||||||
|
/// M4: reply to `joinChannel()` — see `VoiceCatEvent.result` / `.channelId`.
|
||||||
|
case joinResult = 12
|
||||||
|
/// M4: the TOFU server-identity gate — see `VoiceCatEvent.tofuStatus` / `.text`.
|
||||||
|
case serverIdentity = 13
|
||||||
|
/// M5: async result for moderation/admin/channel operations.
|
||||||
|
case genericResult = 14
|
||||||
|
/// M5: reply to `requestAccountList()` — call `listAccounts()` to read.
|
||||||
|
case accountList = 15
|
||||||
|
|
||||||
|
public init(_ cValue: vc_event_type) {
|
||||||
|
self = VoiceCatEventType(rawValue: cValue.rawValue) ?? .error
|
||||||
|
}
|
||||||
|
public var cValue: vc_event_type { vc_event_type(rawValue) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TOFU server-identity classification — mirrors `vc_tofu_status`. Pins the TLS leaf
|
||||||
|
/// certificate's SHA-256 fingerprint (NOT the display-only Ed25519 value — see
|
||||||
|
/// docs/security.md §1.1 and `VoiceCatServerIdentity`).
|
||||||
|
public enum VoiceCatTofuStatus: UInt32, Sendable, Equatable {
|
||||||
|
case firstConnect = 0
|
||||||
|
case matched = 1
|
||||||
|
case mismatch = 2
|
||||||
|
|
||||||
|
public init(_ cValue: vc_tofu_status) {
|
||||||
|
self = VoiceCatTofuStatus(rawValue: cValue.rawValue) ?? .firstConnect
|
||||||
|
}
|
||||||
|
public var cValue: vc_tofu_status { vc_tofu_status(rawValue) }
|
||||||
|
}
|
||||||
61
clients/apple/Sources/VoiceCatCore/Event.swift
Normal file
61
clients/apple/Sources/VoiceCatCore/Event.swift
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// VoiceCatEvent — a Swift value type that is safe to hold/queue past the native callback's
|
||||||
|
// return. This is the Swift analog of the C# client's `VoiceCatEvent` record.
|
||||||
|
//
|
||||||
|
// CRITICAL (voicecat.h's vc_event doc comment): the native `vc_event.text` pointer is owned
|
||||||
|
// by the core and valid ONLY for the duration of the `on_event` callback. `from(_:)` copies
|
||||||
|
// it to a Swift `String` immediately — never hold the raw `vc_event` across the callback
|
||||||
|
// boundary, or `text` will be a dangling pointer by the time it's read. This is the #1
|
||||||
|
// lifetime rule carried over from the Windows client (NativeCallbacks.cs / VoiceCatEvent.cs).
|
||||||
|
|
||||||
|
import VoiceCatC
|
||||||
|
|
||||||
|
/// A Swift-safe copy of a `vc_event`. Produced inside the `on_event` callback (see
|
||||||
|
/// Callbacks.swift) — all pointer fields are converted to value types before the callback
|
||||||
|
/// returns.
|
||||||
|
public struct VoiceCatEvent: Sendable, Equatable {
|
||||||
|
public let type: VoiceCatEventType
|
||||||
|
public let connectionState: VoiceCatConnectionState
|
||||||
|
public let result: VoiceCatResult
|
||||||
|
public let userId: UInt32
|
||||||
|
public let channelId: UInt32
|
||||||
|
public let streamId: UInt32
|
||||||
|
public let textScope: VoiceCatTextScope
|
||||||
|
/// Generic small payload, meaning per event type. For `.serverIdentity` this is the
|
||||||
|
/// `VoiceCatTofuStatus`; for `.genericResult` the server error code; for `.talkState`
|
||||||
|
/// talking(0/1).
|
||||||
|
public let u32a: UInt32
|
||||||
|
/// Copied from the core's `vc_event.text` inside the callback. nil if the core passed NULL.
|
||||||
|
public let text: String?
|
||||||
|
public let timestampUnixMs: UInt64
|
||||||
|
|
||||||
|
/// Convenience: the TOFU status, valid when `type == .serverIdentity` (maps `u32a`).
|
||||||
|
public var tofuStatus: VoiceCatTofuStatus? {
|
||||||
|
type == .serverIdentity ? VoiceCatTofuStatus(rawValue: u32a) : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy a native `vc_event` into a safe Swift value. MUST be called inside the callback
|
||||||
|
/// while `ev.text` is still valid — `String(cString:)` copies the bytes here.
|
||||||
|
@inline(__always)
|
||||||
|
public static func from(_ ev: vc_event) -> VoiceCatEvent {
|
||||||
|
let text: String?
|
||||||
|
if let raw = ev.text {
|
||||||
|
text = String(cString: raw) // copies — safe to hold past callback return
|
||||||
|
} else {
|
||||||
|
text = nil
|
||||||
|
}
|
||||||
|
// ev.result is int32_t (not vc_result) per voicecat.h — bridge via bitPattern.
|
||||||
|
// ev.u32a is uint32_t — matches VoiceCatTofuStatus's UInt32 raw value directly.
|
||||||
|
return VoiceCatEvent(
|
||||||
|
type: VoiceCatEventType(ev.type),
|
||||||
|
connectionState: VoiceCatConnectionState(ev.connection_state),
|
||||||
|
result: VoiceCatResult(rawValue: UInt32(bitPattern: ev.result)) ?? .internalError,
|
||||||
|
userId: ev.user_id,
|
||||||
|
channelId: ev.channel_id,
|
||||||
|
streamId: ev.stream_id,
|
||||||
|
textScope: VoiceCatTextScope(ev.text_scope),
|
||||||
|
u32a: ev.u32a,
|
||||||
|
text: text,
|
||||||
|
timestampUnixMs: ev.timestamp_unix_ms
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
107
clients/apple/Sources/VoiceCatCore/Marshaling.swift
Normal file
107
clients/apple/Sources/VoiceCatCore/Marshaling.swift
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
// Marshaling — shared "walk a native array of owned-struct entries, convert to Swift value
|
||||||
|
// types, free the native list" pattern. Identical shape for vc_device_list / vc_channel_list
|
||||||
|
// / vc_user_list / vc_stream_summary_list / vc_account_list (all core-allocated, caller-freed
|
||||||
|
// per voicecat.h). The matching vc_free_*_list call happens INSIDE each function here,
|
||||||
|
// immediately after the conversion, so callers never need to remember to free anything
|
||||||
|
// themselves. This is the Swift analog of the C# client's Marshaling.cs.
|
||||||
|
|
||||||
|
import VoiceCatC
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Internal marshaling helpers — convert core-allocated C arrays to Swift arrays and
|
||||||
|
/// immediately free the native list. Not part of the public API.
|
||||||
|
internal enum Marshaling {
|
||||||
|
/// Convert a nullable `const char*` to a Swift `String` (empty if NULL).
|
||||||
|
@inline(__always)
|
||||||
|
static func string(_ ptr: UnsafePointer<CChar>?) -> String {
|
||||||
|
guard let ptr else { return "" }
|
||||||
|
return String(cString: ptr)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func devices(_ list: inout vc_device_list) -> [Device] {
|
||||||
|
guard let items = list.items else { vc_free_device_list(&list); return [] }
|
||||||
|
var result: [Device] = []
|
||||||
|
result.reserveCapacity(list.count)
|
||||||
|
for i in 0..<list.count {
|
||||||
|
let d = items.advanced(by: i).pointee
|
||||||
|
result.append(Device(id: string(d.id), name: string(d.name), isDefault: d.is_default != 0))
|
||||||
|
}
|
||||||
|
vc_free_device_list(&list)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
static func channels(_ list: inout vc_channel_list) -> [Channel] {
|
||||||
|
guard let items = list.items else { vc_free_channel_list(&list); return [] }
|
||||||
|
var result: [Channel] = []
|
||||||
|
result.reserveCapacity(list.count)
|
||||||
|
for i in 0..<list.count {
|
||||||
|
let c = items.advanced(by: i).pointee
|
||||||
|
result.append(Channel(id: c.id, parentId: c.parent_id, name: string(c.name),
|
||||||
|
topic: string(c.topic), passwordProtected: c.password_protected != 0,
|
||||||
|
maxUsers: c.max_users))
|
||||||
|
}
|
||||||
|
vc_free_channel_list(&list)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
static func users(_ list: inout vc_user_list) -> [User] {
|
||||||
|
guard let items = list.items else { vc_free_user_list(&list); return [] }
|
||||||
|
var result: [User] = []
|
||||||
|
result.reserveCapacity(list.count)
|
||||||
|
for i in 0..<list.count {
|
||||||
|
let u = items.advanced(by: i).pointee
|
||||||
|
result.append(User(id: u.id, nickname: string(u.nickname), isGuest: u.is_guest != 0,
|
||||||
|
channelId: u.channel_id, selfMicMuted: u.self_mic_muted != 0,
|
||||||
|
selfDeafened: u.self_deafened != 0, serverMuted: u.server_muted != 0,
|
||||||
|
serverDeafened: u.server_deafened != 0))
|
||||||
|
}
|
||||||
|
vc_free_user_list(&list)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
static func streamSummaries(_ list: inout vc_stream_summary_list) -> [StreamSummary] {
|
||||||
|
guard let items = list.items else { vc_free_stream_summary_list(&list); return [] }
|
||||||
|
var result: [StreamSummary] = []
|
||||||
|
result.reserveCapacity(list.count)
|
||||||
|
for i in 0..<list.count {
|
||||||
|
let s = items.advanced(by: i).pointee
|
||||||
|
result.append(StreamSummary(streamId: s.stream_id, kind: VoiceCatStreamKind(s.kind),
|
||||||
|
label: string(s.label)))
|
||||||
|
}
|
||||||
|
vc_free_stream_summary_list(&list)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
static func accounts(_ list: inout vc_account_list) -> [Account] {
|
||||||
|
guard let items = list.items else { vc_free_account_list(&list); return [] }
|
||||||
|
var result: [Account] = []
|
||||||
|
result.reserveCapacity(list.count)
|
||||||
|
for i in 0..<list.count {
|
||||||
|
let a = items.advanced(by: i).pointee
|
||||||
|
result.append(Account(username: string(a.username), isAdmin: a.is_admin != 0,
|
||||||
|
createdAtUnixMs: a.created_at_unix_ms,
|
||||||
|
lastLoginUnixMs: a.last_login_unix_ms))
|
||||||
|
}
|
||||||
|
vc_free_account_list(&list)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
static func remoteStreamState(_ s: vc_remote_stream_state) -> RemoteStreamState {
|
||||||
|
RemoteStreamState(gain: s.gain, muted: s.muted != 0, noiseReduction: s.noise_reduction != 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func audioConfig(_ c: vc_audio_config) -> AudioConfig {
|
||||||
|
AudioConfig(codec: c.codec, stereo: c.mode != 0, sampleRate: c.sample_rate,
|
||||||
|
bitrateBps: c.bitrate_bps, frameMs: c.frame_ms, application: c.application,
|
||||||
|
fec: c.fec != 0, expectedPacketLoss: c.expected_packet_loss,
|
||||||
|
dtx: c.dtx != 0, complexity: c.complexity)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func permissions(_ p: vc_permissions) -> Permissions {
|
||||||
|
Permissions(canCreateTempChannel: p.can_create_temp_channel != 0,
|
||||||
|
canKick: p.can_kick != 0, canBan: p.can_ban != 0,
|
||||||
|
canMoveUsers: p.can_move_users != 0,
|
||||||
|
canAdminAccounts: p.can_admin_accounts != 0,
|
||||||
|
isAdmin: p.is_admin != 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
236
clients/apple/Sources/VoiceCatCore/Models.swift
Normal file
236
clients/apple/Sources/VoiceCatCore/Models.swift
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
// Plain Swift value types — what survives past the native struct/free-list lifetime
|
||||||
|
// (Marshaling.swift converts the C structs into these and immediately frees the native
|
||||||
|
// list). Nothing here holds a raw pointer. This is the Swift analog of the C# client's
|
||||||
|
// Models.cs. Field naming follows Swift camelCase (the C structs use snake_case).
|
||||||
|
|
||||||
|
import VoiceCatC
|
||||||
|
|
||||||
|
/// Channel snapshot — mirrors `vc_channel` (the pull-based view; re-call `listChannels()`
|
||||||
|
/// after `.channelList` / `.userJoined` / `.userLeft` / `.userUpdated` events).
|
||||||
|
public struct Channel: Sendable, Equatable, Identifiable {
|
||||||
|
public let id: UInt32
|
||||||
|
/// 0 = root.
|
||||||
|
public let parentId: UInt32
|
||||||
|
public let name: String
|
||||||
|
public let topic: String
|
||||||
|
public let passwordProtected: Bool
|
||||||
|
/// 0 = unlimited.
|
||||||
|
public let maxUsers: UInt32
|
||||||
|
|
||||||
|
public init(id: UInt32, parentId: UInt32, name: String, topic: String,
|
||||||
|
passwordProtected: Bool, maxUsers: UInt32) {
|
||||||
|
self.id = id; self.parentId = parentId; self.name = name; self.topic = topic
|
||||||
|
self.passwordProtected = passwordProtected; self.maxUsers = maxUsers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Channel creation/edition descriptor — mirrors `vc_channel_info`. Used by
|
||||||
|
/// `createChannel(_:)` and `editChannel(_:)`. `id == 0` means new channel (for create).
|
||||||
|
public struct ChannelEdit: Sendable, Equatable {
|
||||||
|
public let id: UInt32 // 0 = new channel for create
|
||||||
|
public let parentId: UInt32 // 0 = root
|
||||||
|
public let name: String
|
||||||
|
public let topic: String
|
||||||
|
public let passwordProtected: Bool
|
||||||
|
public let password: String? // nil/empty ignored if passwordProtected == false
|
||||||
|
public let maxUsers: UInt32 // 0 = unlimited
|
||||||
|
public let sortOrder: UInt32
|
||||||
|
/// 0/nil fields use server defaults.
|
||||||
|
public let audio: AudioConfig
|
||||||
|
|
||||||
|
public init(id: UInt32, parentId: UInt32, name: String, topic: String,
|
||||||
|
passwordProtected: Bool, password: String?, maxUsers: UInt32,
|
||||||
|
sortOrder: UInt32, audio: AudioConfig) {
|
||||||
|
self.id = id; self.parentId = parentId; self.name = name; self.topic = topic
|
||||||
|
self.passwordProtected = passwordProtected; self.password = password
|
||||||
|
self.maxUsers = maxUsers; self.sortOrder = sortOrder; self.audio = audio
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User snapshot — mirrors `vc_user`.
|
||||||
|
public struct User: Sendable, Equatable, Identifiable {
|
||||||
|
public let id: UInt32
|
||||||
|
public let nickname: String
|
||||||
|
public let isGuest: Bool
|
||||||
|
public let channelId: UInt32
|
||||||
|
public let selfMicMuted: Bool
|
||||||
|
public let selfDeafened: Bool
|
||||||
|
public let serverMuted: Bool
|
||||||
|
public let serverDeafened: Bool
|
||||||
|
|
||||||
|
public init(id: UInt32, nickname: String, isGuest: Bool, channelId: UInt32,
|
||||||
|
selfMicMuted: Bool, selfDeafened: Bool, serverMuted: Bool,
|
||||||
|
serverDeafened: Bool) {
|
||||||
|
self.id = id; self.nickname = nickname; self.isGuest = isGuest; self.channelId = channelId
|
||||||
|
self.selfMicMuted = selfMicMuted; self.selfDeafened = selfDeafened
|
||||||
|
self.serverMuted = serverMuted; self.serverDeafened = serverDeafened
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Permission bitset — mirrors `vc_permissions` (M5).
|
||||||
|
public struct Permissions: Sendable, Equatable {
|
||||||
|
public let canCreateTempChannel: Bool
|
||||||
|
public let canKick: Bool
|
||||||
|
public let canBan: Bool
|
||||||
|
public let canMoveUsers: Bool
|
||||||
|
public let canAdminAccounts: Bool
|
||||||
|
public let isAdmin: Bool
|
||||||
|
|
||||||
|
public init(canCreateTempChannel: Bool, canKick: Bool, canBan: Bool,
|
||||||
|
canMoveUsers: Bool, canAdminAccounts: Bool, isAdmin: Bool) {
|
||||||
|
self.canCreateTempChannel = canCreateTempChannel; self.canKick = canKick; self.canBan = canBan
|
||||||
|
self.canMoveUsers = canMoveUsers; self.canAdminAccounts = canAdminAccounts; self.isAdmin = isAdmin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Account entry — mirrors `vc_account` (M5, reply to `listAccounts()`).
|
||||||
|
public struct Account: Sendable, Equatable {
|
||||||
|
public let username: String
|
||||||
|
public let isAdmin: Bool
|
||||||
|
public let createdAtUnixMs: UInt64
|
||||||
|
public let lastLoginUnixMs: UInt64
|
||||||
|
|
||||||
|
public init(username: String, isAdmin: Bool, createdAtUnixMs: UInt64,
|
||||||
|
lastLoginUnixMs: UInt64) {
|
||||||
|
self.username = username; self.isAdmin = isAdmin
|
||||||
|
self.createdAtUnixMs = createdAtUnixMs; self.lastLoginUnixMs = lastLoginUnixMs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-user stream summary — mirrors `vc_stream_summary`. For the full effective Opus
|
||||||
|
/// config of a specific (user_id, stream_id), use `VoiceCatClient.getStreamAudioConfig`.
|
||||||
|
public struct StreamSummary: Sendable, Equatable, Identifiable {
|
||||||
|
public let id: UInt32 // stream_id
|
||||||
|
public let kind: VoiceCatStreamKind
|
||||||
|
public let label: String
|
||||||
|
|
||||||
|
public init(streamId: UInt32, kind: VoiceCatStreamKind, label: String) {
|
||||||
|
self.id = streamId; self.kind = kind; self.label = label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receive-side state the local listener chose for a specific remote stream — mirrors
|
||||||
|
/// `vc_remote_stream_state`. All LOCAL (no protocol traffic) — docs/voice.md §10.
|
||||||
|
/// Defaults (if `setRemoteStream` was never called): gain 1.0, unmuted, NR off.
|
||||||
|
public struct RemoteStreamState: Sendable, Equatable {
|
||||||
|
public let gain: Float // 0.0–… ; default 1.0
|
||||||
|
public let muted: Bool
|
||||||
|
public let noiseReduction: Bool
|
||||||
|
|
||||||
|
public init(gain: Float, muted: Bool, noiseReduction: Bool) {
|
||||||
|
self.gain = gain; self.muted = muted; self.noiseReduction = noiseReduction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Audio device — mirrors `vc_device`. `id` is an opaque, internally-encoded handle
|
||||||
|
/// (currently hex-encoded `ma_device_id`) — always round-trip an id from `listDevices`;
|
||||||
|
/// never construct one by hand (docs/architecture.md §4).
|
||||||
|
public struct Device: Sendable, Equatable, Identifiable {
|
||||||
|
public let id: String
|
||||||
|
public let name: String
|
||||||
|
public let isDefault: Bool
|
||||||
|
|
||||||
|
public init(id: String, name: String, isDefault: Bool) {
|
||||||
|
self.id = id; self.name = name; self.isDefault = isDefault
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// iOS audio input port — derived from `AVAudioSession.availableInputs`. Unlike the
|
||||||
|
/// miniaudio-based `Device` (which returns ~2 entries on iOS), this exposes the real
|
||||||
|
/// AVAudioSession input ports (builtInMic, bluetoothHFP, headsetMic, usbAudio, airPlay)
|
||||||
|
/// with their data sources (orientation: front/back/top/bottom) and polar patterns
|
||||||
|
/// (omni/cardioid/subcardioid/bidirectional). Used by `IOSAudioRouter` + `SettingsView`.
|
||||||
|
public struct IOSAudioInputPort: Identifiable, Hashable {
|
||||||
|
public let id: String // port UID (stable across route changes)
|
||||||
|
public let name: String // human-readable port name
|
||||||
|
public let portType: String // AVAudioSession.Port raw value as string
|
||||||
|
public let dataSources: [IOSAudioDataSource]?
|
||||||
|
public let isSelected: Bool // true if this is the current preferredInput
|
||||||
|
|
||||||
|
public init(id: String, name: String, portType: String,
|
||||||
|
dataSources: [IOSAudioDataSource]?, isSelected: Bool) {
|
||||||
|
self.id = id; self.name = name; self.portType = portType
|
||||||
|
self.dataSources = dataSources; self.isSelected = isSelected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// iOS audio data source — a sub-selection of an input port (e.g. built-in mic
|
||||||
|
/// orientation: front/back/top/bottom). May have polar pattern options.
|
||||||
|
public struct IOSAudioDataSource: Identifiable, Hashable {
|
||||||
|
public let id: String // dataSource UID
|
||||||
|
public let name: String // "Front", "Back", "Top", "Bottom"
|
||||||
|
public let polarPatterns: [String]? // AVAudioSession.PolarPattern raw values
|
||||||
|
public let isSelected: Bool // true if this is the current preferredDataSource
|
||||||
|
public let selectedPolarPattern: String?
|
||||||
|
|
||||||
|
public init(id: String, name: String, polarPatterns: [String]?,
|
||||||
|
isSelected: Bool, selectedPolarPattern: String?) {
|
||||||
|
self.id = id; self.name = name; self.polarPatterns = polarPatterns
|
||||||
|
self.isSelected = isSelected; self.selectedPolarPattern = selectedPolarPattern
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// iOS audio output route — read-only display of `AVAudioSession.currentRoute.outputs`.
|
||||||
|
public struct IOSAudioOutputRoute: Identifiable, Hashable {
|
||||||
|
public let id: String // port UID
|
||||||
|
public let name: String // human-readable route name
|
||||||
|
public let portType: String // AVAudioSession.Port raw value as string
|
||||||
|
|
||||||
|
public init(id: String, name: String, portType: String) {
|
||||||
|
self.id = id; self.name = name; self.portType = portType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Effective Opus configuration — mirrors `vc_audio_config`.
|
||||||
|
public struct AudioConfig: Sendable, Equatable {
|
||||||
|
public let codec: UInt32 // 0 = OPUS
|
||||||
|
public let stereo: Bool // mode: 0 = mono, 1 = stereo
|
||||||
|
public let sampleRate: UInt32
|
||||||
|
public let bitrateBps: UInt32
|
||||||
|
public let frameMs: UInt32
|
||||||
|
public let application: UInt32 // 0 = VOIP, 1 = AUDIO, 2 = LOWDELAY
|
||||||
|
public let fec: Bool
|
||||||
|
public let expectedPacketLoss: UInt32 // % 0..100
|
||||||
|
public let dtx: Bool
|
||||||
|
public let complexity: UInt32 // 0..10
|
||||||
|
|
||||||
|
public init(codec: UInt32 = 0, stereo: Bool = false, sampleRate: UInt32 = 48000,
|
||||||
|
bitrateBps: UInt32 = 64000, frameMs: UInt32 = 20, application: UInt32 = 0,
|
||||||
|
fec: Bool = true, expectedPacketLoss: UInt32 = 5, dtx: Bool = false,
|
||||||
|
complexity: UInt32 = 10) {
|
||||||
|
self.codec = codec; self.stereo = stereo; self.sampleRate = sampleRate
|
||||||
|
self.bitrateBps = bitrateBps; self.frameMs = frameMs; self.application = application
|
||||||
|
self.fec = fec; self.expectedPacketLoss = expectedPacketLoss; self.dtx = dtx
|
||||||
|
self.complexity = complexity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream descriptor — mirrors `vc_stream_desc`. Used by `startStream(kind:deviceId:label:)`.
|
||||||
|
public struct StreamDescriptor: Sendable, Equatable {
|
||||||
|
public let kind: VoiceCatStreamKind
|
||||||
|
/// nil = default device for this kind.
|
||||||
|
public let deviceId: String?
|
||||||
|
public let label: String
|
||||||
|
|
||||||
|
public init(kind: VoiceCatStreamKind, deviceId: String? = nil, label: String) {
|
||||||
|
self.kind = kind; self.deviceId = deviceId; self.label = label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server identity info — parsed from a `.serverIdentity` event + `getServerIdentityDisplay()`.
|
||||||
|
/// The `tlsCertFingerprint` (SHA-256 hex of the TLS leaf cert) is the value the TOFU gate
|
||||||
|
/// actually pins on; `ed25519Fingerprint` is display-only (docs/security.md §1.1).
|
||||||
|
public struct ServerIdentity: Sendable, Equatable {
|
||||||
|
public let tofuStatus: VoiceCatTofuStatus
|
||||||
|
/// SHA-256 hex of the TLS leaf certificate — the pinned value. No separators (64 chars).
|
||||||
|
public let tlsCertFingerprint: String
|
||||||
|
/// Ed25519 identity fingerprint from ServerHello, hex-formatted — display only.
|
||||||
|
/// Empty if not yet available.
|
||||||
|
public let ed25519Fingerprint: String
|
||||||
|
|
||||||
|
public init(tofuStatus: VoiceCatTofuStatus, tlsCertFingerprint: String,
|
||||||
|
ed25519Fingerprint: String) {
|
||||||
|
self.tofuStatus = tofuStatus; self.tlsCertFingerprint = tlsCertFingerprint
|
||||||
|
self.ed25519Fingerprint = ed25519Fingerprint
|
||||||
|
}
|
||||||
|
}
|
||||||
591
clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift
Normal file
591
clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift
Normal file
@@ -0,0 +1,591 @@
|
|||||||
|
// VoiceCatClient — the public, Swift-idiomatic surface over libvoicecat. This is the Swift
|
||||||
|
// analog of the C# client's `VoiceCatClient.cs` (clients/windows/VoiceCat.Interop).
|
||||||
|
//
|
||||||
|
// Key patterns carried over from the proven C# implementation (see docs/architecture.md §4
|
||||||
|
// per-platform binding notes):
|
||||||
|
//
|
||||||
|
// 1. HANDLE OWNERSHIP: the class owns `vc_client*`; `deinit` calls `vc_client_destroy`
|
||||||
|
// (which synchronously joins every internal thread, so nothing can still be reading the
|
||||||
|
// config-string pointers or firing callbacks by the time it returns).
|
||||||
|
//
|
||||||
|
// 2. CONFIG STRING LIFETIMES: the core stores raw pointers from `vc_config` by value — it
|
||||||
|
// does NOT copy the string data. `client_name`/`client_version`/`tofu_store_path` are
|
||||||
|
// read later, whenever `connect()` actually runs on the io_thread_. So the native CString
|
||||||
|
// storage (`_clientNamePtr` etc.) must outlive the WHOLE client, not just `init`. It's
|
||||||
|
// freed in `deinit`, AFTER `vc_client_destroy` has returned. (C#: Marshal.StringToCoTask
|
||||||
|
// MemUTF8 in ctor, FreeCoTaskMem in Dispose after destroy.)
|
||||||
|
//
|
||||||
|
// 3. EVENT DELIVERY THREAD HANDOFF: `on_event` fires on the core's event thread. Events are
|
||||||
|
// buffered in a lock-protected array and drained on `DispatchQueue.main` — this is the
|
||||||
|
// boundary where the core's thread hands off to the UI thread. The C# analog is
|
||||||
|
// `Channel<VoiceCatEvent>` drained by a 30ms WinForms Timer; the Swift analog is a
|
||||||
|
// coalesced main-queue drain (only one async block scheduled at a time). `on_event`'s
|
||||||
|
// `text` is copied to a Swift `String` inside the callback (Callbacks.swift) before
|
||||||
|
// enqueueing — the raw pointer is dangling by the time the main thread drains.
|
||||||
|
//
|
||||||
|
// 4. LEVEL METER COALESCING: `on_level` fires far more often than `on_event` and
|
||||||
|
// intermediate values are visually irrelevant — coalesced to "latest sample per
|
||||||
|
// stream_id" in a lock-protected dictionary, drained on main alongside events.
|
||||||
|
// (C#: ConcurrentDictionary<uint,float> cleared in PumpEvents.)
|
||||||
|
//
|
||||||
|
// 5. IMMEDIATE vc_free_* ON LIST READS: `listChannels()`/`listUsers()`/etc. walk the native
|
||||||
|
// array, convert to Swift value types, and call `vc_free_*_list` INSIDE the function —
|
||||||
|
// callers never manage native list lifetime. (C#: Marshaling.ToManaged does the same.)
|
||||||
|
|
||||||
|
import VoiceCatC
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Swift-idiomatic alias for the C `vc_pcm_sink_cb` function-pointer type from
|
||||||
|
/// `voicecat.h`. Exposed publicly so consumers (`VoiceCatMac`, tests) can declare a sink
|
||||||
|
/// callback without directly importing the `VoiceCatC` C module. Mirrors the C# wrapper's
|
||||||
|
/// `VcPcmSinkCallback` delegate.
|
||||||
|
public typealias VoiceCatPcmSinkCallback = vc_pcm_sink_cb
|
||||||
|
|
||||||
|
/// The Swift wrapper around `vc_client*`. Owns the native handle for its entire lifetime;
|
||||||
|
/// `deinit` destroys it. Events and level meters are delivered on the main queue via the
|
||||||
|
/// `onEvent` / `onLevel` closures.
|
||||||
|
///
|
||||||
|
/// Thread-safety: the public methods are not thread-safe — call them from the main thread
|
||||||
|
/// (the standard AppKit/SwiftUI pattern). The internal event/level buffers are thread-safe
|
||||||
|
/// (lock-protected) because they're written from the core's event thread.
|
||||||
|
public final class VoiceCatClient {
|
||||||
|
|
||||||
|
// MARK: - Stored properties
|
||||||
|
|
||||||
|
/// The opaque C handle (`vc_client*` — Swift imports the incomplete C struct as
|
||||||
|
/// `OpaquePointer`). Set in `init`, passed to every C function, destroyed in `deinit`.
|
||||||
|
private var handle: OpaquePointer?
|
||||||
|
|
||||||
|
/// Unmanaged pointer to `self` — passed as `vc_callbacks.user` so the C function-pointer
|
||||||
|
/// callbacks can resolve back to this instance. `passUnretained` (not `passRetained`)
|
||||||
|
/// because we want normal ARC to control the object's lifetime — `deinit` calls
|
||||||
|
/// `vc_client_destroy` (joins all threads) before the object's memory is freed, so no
|
||||||
|
/// callback can fire with a dangling `user` pointer. See Callbacks.swift.
|
||||||
|
///
|
||||||
|
/// Computed (not stored) to break a circular init dependency: it needs `self`, but
|
||||||
|
/// stored properties must be initialized before `self` is available. `Unmanaged.passUn
|
||||||
|
/// retained(self).toOpaque()` always returns the same address for a given instance, so
|
||||||
|
/// computing it on demand is safe and consistent.
|
||||||
|
private var selfPointer: UnsafeMutableRawPointer {
|
||||||
|
Unmanaged.passUnretained(self).toOpaque()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Native CString storage backing `vc_config` — must outlive the whole client (the core
|
||||||
|
/// stores raw pointers, doesn't copy). Freed in `deinit` after `vc_client_destroy`.
|
||||||
|
private var clientNamePtr: UnsafeMutablePointer<CChar>?
|
||||||
|
private var clientVersionPtr: UnsafeMutablePointer<CChar>?
|
||||||
|
private var tofuStorePathPtr: UnsafeMutablePointer<CChar>?
|
||||||
|
|
||||||
|
// MARK: - Event / level delivery (main-queue)
|
||||||
|
|
||||||
|
/// Called on the main queue for every event, in order, never coalesced. Set this from
|
||||||
|
/// the main thread (AppKit/SwiftUI) to drive your UI.
|
||||||
|
public var onEvent: ((VoiceCatEvent) -> Void)?
|
||||||
|
|
||||||
|
/// Called on the main queue with the latest RMS level per stream_id since the last drain.
|
||||||
|
/// Intermediate values are coalesced (only the latest per stream_id is delivered).
|
||||||
|
public var onLevel: ((UInt32, Float) -> Void)?
|
||||||
|
|
||||||
|
/// Lock-protected buffers, written from the core's event thread, drained on main.
|
||||||
|
private let bufferLock = NSLock()
|
||||||
|
private var eventBuffer: [VoiceCatEvent] = []
|
||||||
|
private var levelSamples: [UInt32: Float] = [:]
|
||||||
|
private var drainScheduled = false
|
||||||
|
|
||||||
|
// MARK: - Init / deinit
|
||||||
|
|
||||||
|
/// Create a client. `config.clientName`/`clientVersion`/`tofuStorePath` are copied to
|
||||||
|
/// native CString storage held for the client's entire lifetime (the core reads them
|
||||||
|
/// later, e.g. when `connect()` runs on the io thread).
|
||||||
|
public init(config: VoiceCatConfig) {
|
||||||
|
// Allocate native C strings — must persist until after vc_client_destroy in deinit.
|
||||||
|
// These don't need `self`, so they're safe to set first.
|
||||||
|
self.clientNamePtr = strdup(config.clientName)
|
||||||
|
self.clientVersionPtr = strdup(config.clientVersion)
|
||||||
|
self.tofuStorePathPtr = config.tofuStorePath.flatMap { strdup($0) }
|
||||||
|
self.handle = nil // placeholder — set below after callbacks are wired
|
||||||
|
|
||||||
|
// All stored properties are now initialized → `self` is fully available, so we can
|
||||||
|
// call `selfPointer` (the computed property) to build the callbacks struct.
|
||||||
|
var nativeConfig = vc_config()
|
||||||
|
nativeConfig.client_name = UnsafePointer(clientNamePtr)
|
||||||
|
nativeConfig.client_version = UnsafePointer(clientVersionPtr)
|
||||||
|
nativeConfig.log_level = config.logLevel.cValue
|
||||||
|
nativeConfig.tofu_store_path = UnsafePointer(tofuStorePathPtr)
|
||||||
|
|
||||||
|
let callbacks = Callbacks.make(user: selfPointer)
|
||||||
|
self.handle = vc_client_create(&nativeConfig, callbacks)
|
||||||
|
|
||||||
|
if handle == nil {
|
||||||
|
free(clientNamePtr); clientNamePtr = nil
|
||||||
|
free(clientVersionPtr); clientVersionPtr = nil
|
||||||
|
if let tofuStorePathPtr { free(tofuStorePathPtr); self.tofuStorePathPtr = nil }
|
||||||
|
fatalError("vc_client_create returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
if let handle {
|
||||||
|
// Joins every internal thread synchronously — no callbacks can fire after this
|
||||||
|
// returns, so the selfPointer and config-string pointers are safe to free.
|
||||||
|
vc_client_destroy(handle)
|
||||||
|
self.handle = nil
|
||||||
|
}
|
||||||
|
// Free config strings AFTER destroy (the core may have been reading them up until
|
||||||
|
// destroy joined the io thread).
|
||||||
|
free(clientNamePtr); clientNamePtr = nil
|
||||||
|
free(clientVersionPtr); clientVersionPtr = nil
|
||||||
|
if let tofuStorePathPtr { free(tofuStorePathPtr); self.tofuStorePathPtr = nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internal: event/level enqueue (called from the core's event thread)
|
||||||
|
|
||||||
|
/// Called by Callbacks.onEvent on the core's event thread. Buffers the event and
|
||||||
|
/// schedules a coalesced main-queue drain.
|
||||||
|
internal func enqueueEvent(_ event: VoiceCatEvent) {
|
||||||
|
bufferLock.lock()
|
||||||
|
eventBuffer.append(event)
|
||||||
|
let shouldSchedule = !drainScheduled
|
||||||
|
drainScheduled = true
|
||||||
|
bufferLock.unlock()
|
||||||
|
if shouldSchedule {
|
||||||
|
DispatchQueue.main.async { [weak self] in self?.drain() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called by Callbacks.onLevel on the core's event thread. Coalesces to latest-per-stream
|
||||||
|
/// and schedules a coalesced main-queue drain.
|
||||||
|
internal func enqueueLevel(_ streamId: UInt32, _ rms: Float) {
|
||||||
|
bufferLock.lock()
|
||||||
|
levelSamples[streamId] = rms
|
||||||
|
let shouldSchedule = !drainScheduled
|
||||||
|
drainScheduled = true
|
||||||
|
bufferLock.unlock()
|
||||||
|
if shouldSchedule {
|
||||||
|
DispatchQueue.main.async { [weak self] in self?.drain() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drains buffered events + coalesced levels on the main queue. Only one drain is
|
||||||
|
/// scheduled at a time (debounced via `drainScheduled`).
|
||||||
|
private func drain() {
|
||||||
|
bufferLock.lock()
|
||||||
|
let events = eventBuffer
|
||||||
|
eventBuffer.removeAll()
|
||||||
|
let levels = levelSamples
|
||||||
|
levelSamples.removeAll()
|
||||||
|
drainScheduled = false
|
||||||
|
bufferLock.unlock()
|
||||||
|
|
||||||
|
for event in events { onEvent?(event) }
|
||||||
|
for (streamId, rms) in levels { onLevel?(streamId, rms) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lifecycle (statics)
|
||||||
|
|
||||||
|
/// The core's version string (e.g. "VoiceCat 0.0.1 (protocol v1)"). Static literal — never freed.
|
||||||
|
public static var versionString: String {
|
||||||
|
String(cString: vc_version_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human-readable description of a result code. Static literal — never freed.
|
||||||
|
public static func resultString(_ code: VoiceCatResult) -> String {
|
||||||
|
String(cString: vc_result_string(code.cValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Connection & auth (async; results via onEvent)
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func connect(host: String, port: UInt16) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_connect(handle, host, port))
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func disconnect() -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_disconnect(handle))
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func authenticateGuest(_ nickname: String) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_authenticate_guest(handle, nickname))
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func authenticateUser(_ username: String, password: String) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_authenticate_user(handle, username, password))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - TOFU server-identity gate (M4)
|
||||||
|
|
||||||
|
/// Accept or reject the pending server-identity check. Call after a `.serverIdentity`
|
||||||
|
/// event. `accept=true` on firstConnect/mismatch updates the pin file and proceeds;
|
||||||
|
/// `accept=false` aborts (emits `.disconnected` with `.crypto`). See docs/security.md §1.1.
|
||||||
|
@discardableResult
|
||||||
|
public func confirmServerIdentity(accept: Bool) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_confirm_server_identity(handle, accept ? 1 : 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Ed25519 identity fingerprint from ServerHello, hex-formatted — DISPLAY ONLY, not
|
||||||
|
/// the value the TOFU gate pins on (see docs/security.md §1.1). Empty if not yet available.
|
||||||
|
/// Uses the two-call idiom: query size with nil buffer, then allocate + fetch.
|
||||||
|
public func getServerIdentityDisplay() -> String {
|
||||||
|
var len: Int = 0
|
||||||
|
_ = vc_get_server_identity_display(handle, nil, 0, &len)
|
||||||
|
if len == 0 { return "" }
|
||||||
|
let buf = UnsafeMutablePointer<CChar>.allocate(capacity: len + 1)
|
||||||
|
defer { buf.deallocate() }
|
||||||
|
_ = vc_get_server_identity_display(handle, buf, len + 1, &len)
|
||||||
|
return String(cString: buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Channels
|
||||||
|
|
||||||
|
/// Join a channel. Result arrives as a `.joinResult` event (not via the return value,
|
||||||
|
/// which only reflects "request queued"). `password` is for password-protected channels.
|
||||||
|
@discardableResult
|
||||||
|
public func joinChannel(_ channelId: UInt32, password: String? = nil) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_join_channel(handle, channelId, password))
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func leaveChannel() -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_leave_channel(handle))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull the current channel tree. Re-call after `.channelList`/`.userJoined`/`.userLeft`/
|
||||||
|
/// `.userUpdated` events. The native list is freed inside this call — callers never
|
||||||
|
/// manage native lifetime.
|
||||||
|
public func listChannels() -> [Channel] {
|
||||||
|
var native = vc_channel_list()
|
||||||
|
_ = vc_list_channels(handle, &native)
|
||||||
|
return Marshaling.channels(&native)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func listUsers() -> [User] {
|
||||||
|
var native = vc_user_list()
|
||||||
|
_ = vc_list_users(handle, &native)
|
||||||
|
return Marshaling.users(&native)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func listUserStreams(_ userId: UInt32) -> [StreamSummary] {
|
||||||
|
var native = vc_stream_summary_list()
|
||||||
|
let r = vc_list_user_streams(handle, userId, &native)
|
||||||
|
guard r == VC_OK else { return [] }
|
||||||
|
return Marshaling.streamSummaries(&native)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Local media streams
|
||||||
|
|
||||||
|
/// Start a mic / screen-audio / aux stream. Returns `(result, streamId)` — `streamId`
|
||||||
|
/// is non-zero on success. The `label` and `deviceId` C strings are only needed for the
|
||||||
|
/// duration of the call (the core copies what it needs), so we use temporary strdup'd
|
||||||
|
/// buffers freed via `defer`.
|
||||||
|
@discardableResult
|
||||||
|
public func startStream(_ descriptor: StreamDescriptor) -> (VoiceCatResult, UInt32) {
|
||||||
|
var streamId: UInt32 = 0
|
||||||
|
let labelPtr = strdup(descriptor.label)
|
||||||
|
defer { free(labelPtr) }
|
||||||
|
let deviceIdPtr = descriptor.deviceId.flatMap { strdup($0) }
|
||||||
|
defer { if let deviceIdPtr { free(deviceIdPtr) } }
|
||||||
|
|
||||||
|
var desc = vc_stream_desc()
|
||||||
|
desc.kind = descriptor.kind.cValue
|
||||||
|
desc.device_id = deviceIdPtr.map { UnsafePointer($0) }
|
||||||
|
desc.label = UnsafePointer(labelPtr)
|
||||||
|
|
||||||
|
let r = vc_stream_start(handle, &desc, &streamId)
|
||||||
|
return (VoiceCatResult(r), streamId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func stopStream(_ streamId: UInt32) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_stream_stop(handle, streamId))
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func setInputDevice(streamId: UInt32, deviceId: String?) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_set_input_device(handle, streamId, deviceId))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the capture channel count for a local MIC stream (1 = mono, 2 = stereo interleaved).
|
||||||
|
/// Takes effect on the next AudioEngine restart (immediately if already running). Used by
|
||||||
|
/// the iOS `IOSAudioRouter` when the user picks stereo built-in mic capture.
|
||||||
|
@discardableResult
|
||||||
|
public func setCaptureChannels(streamId: UInt32, channels: UInt32) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_set_capture_channels(handle, streamId, channels))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - External PCM feed / tap
|
||||||
|
|
||||||
|
/// External PCM feed — drives a local stream's encode pipeline with caller-supplied PCM
|
||||||
|
/// instead of (or in addition to) a hardware capture device. Intended for ReplayKit
|
||||||
|
/// Broadcast Extension (iOS), ScreenCaptureKit (macOS), bots, and soundboard use cases.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - streamId: The stream returned by `startStream`. Must be active.
|
||||||
|
/// - pcm: Raw int16 PCM pointer. Caller must keep the buffer alive for the duration of the call.
|
||||||
|
/// - samplesPerChannel: Samples per channel (e.g. 960 for 20 ms @ 48 kHz).
|
||||||
|
/// - channels: 1 (mono) or 2 (stereo interleaved L/R).
|
||||||
|
@discardableResult
|
||||||
|
public func feedPcm(streamId: UInt32, pcm: UnsafePointer<Int16>,
|
||||||
|
samplesPerChannel: Int, channels: UInt32) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_stream_feed_pcm(handle, streamId, pcm,
|
||||||
|
samplesPerChannel, channels))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience overload for feeding from a Swift `[Int16]` array.
|
||||||
|
@discardableResult
|
||||||
|
public func feedPcm(streamId: UInt32, pcm: [Int16],
|
||||||
|
samplesPerChannel: Int, channels: UInt32) -> VoiceCatResult {
|
||||||
|
pcm.withUnsafeBufferPointer {
|
||||||
|
feedPcm(streamId: streamId, pcm: $0.baseAddress!,
|
||||||
|
samplesPerChannel: samplesPerChannel, channels: channels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// External PCM tap — receive decoded per-stream audio as raw int16 PCM before it
|
||||||
|
/// reaches the hardware mix. Fires once per decoded Opus frame per remote stream.
|
||||||
|
///
|
||||||
|
/// The callback is a C function pointer (`@convention(c)`) receiving:
|
||||||
|
/// `(user, userId, streamId, pcm, samplesPerChannel, channels, sampleRate)`
|
||||||
|
///
|
||||||
|
/// Pass `nil` to disable (default). The callback MUST NOT block or allocate.
|
||||||
|
@discardableResult
|
||||||
|
public func setPcmSink(_ cb: VoiceCatPcmSinkCallback?, user: UnsafeMutableRawPointer?) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_set_pcm_sink(handle, cb, user))
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func setInputMode(_ mode: VoiceCatInputMode) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_set_input_mode(handle, mode.cValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// VAD threshold: normalized RMS 0.0–1.0 (default ~0.025). Takes effect immediately.
|
||||||
|
@discardableResult
|
||||||
|
public func setVadThreshold(_ threshold: Float) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_set_vad_threshold(handle, threshold))
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func setPushToTalk(_ active: Bool) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_set_push_to_talk(handle, active ? 1 : 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func setSelfMute(micMuted: Bool, deafened: Bool) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_set_self_mute(handle, micMuted ? 1 : 0, deafened ? 1 : 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Global playback volume applied after mixing all remote streams. gain 0.0 = silent,
|
||||||
|
/// 1.0 = unity (default), >1.0 amplifies. Always LOCAL — no protocol traffic. Mirrors the
|
||||||
|
/// Windows client's `SetOutputVolume` and the C ABI `vc_set_output_volume` added in M5.
|
||||||
|
@discardableResult
|
||||||
|
public func setOutputVolume(_ gain: Float) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_set_output_volume(handle, gain < 0 ? 0 : gain))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AVAudioSession interruption hooks (iOS)
|
||||||
|
|
||||||
|
/// Pause miniaudio device I/O. Call when AVAudioSession interruption begins.
|
||||||
|
@discardableResult
|
||||||
|
public func audioSuspend() -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_audio_suspend(handle))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resume miniaudio device I/O. Call after re-activating AVAudioSession.
|
||||||
|
@discardableResult
|
||||||
|
public func audioResume() -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_audio_resume(handle))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full audio engine restart — uninitialize and re-initialize the capture and playback
|
||||||
|
/// devices so they pick up a new AVAudioSession route. Call this AFTER reconfiguring
|
||||||
|
/// AVAudioSession (setCategory, setPreferredInput, setPreferredPolarPattern, etc.) so the
|
||||||
|
/// core's devices reopen against the new route. Unlike `audioSuspend()`/`audioResume()`
|
||||||
|
/// (which only stop/start the existing devices, leaving them bound to the route that was
|
||||||
|
/// active when they were opened), this fully re-initializes them. Safe to call when the
|
||||||
|
/// engine is not running (it will just start it).
|
||||||
|
@discardableResult
|
||||||
|
public func audioRestart() -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_audio_restart(handle))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Receive-side, per remote stream (LOCAL — no protocol traffic; docs/voice.md §10)
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func setRemoteStream(userId: UInt32, streamId: UInt32, gain: Float,
|
||||||
|
muted: Bool, noiseReduction: Bool) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_set_remote_stream(handle, userId, streamId, gain,
|
||||||
|
muted ? 1 : 0, noiseReduction ? 1 : 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getRemoteStream(userId: UInt32, streamId: UInt32) -> (VoiceCatResult, RemoteStreamState?) {
|
||||||
|
var state = vc_remote_stream_state()
|
||||||
|
let r = vc_get_remote_stream(handle, userId, streamId, &state)
|
||||||
|
guard r == VC_OK else { return (VoiceCatResult(r), nil) }
|
||||||
|
return (VoiceCatResult(r), Marshaling.remoteStreamState(state))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getStreamAudioConfig(userId: UInt32, streamId: UInt32) -> (VoiceCatResult, AudioConfig?) {
|
||||||
|
var cfg = vc_audio_config()
|
||||||
|
let r = vc_get_stream_audio_config(handle, userId, streamId, &cfg)
|
||||||
|
guard r == VC_OK else { return (VoiceCatResult(r), nil) }
|
||||||
|
return (VoiceCatResult(r), Marshaling.audioConfig(cfg))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Text
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func sendText(scope: VoiceCatTextScope, targetId: UInt32, text: String) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_send_text(handle, scope.cValue, targetId, text))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Device enumeration (works pre-connect)
|
||||||
|
|
||||||
|
public func listDevices(_ kind: VoiceCatDeviceKind) -> [Device] {
|
||||||
|
var native = vc_device_list()
|
||||||
|
_ = vc_list_devices(handle, kind.cValue, &native)
|
||||||
|
return Marshaling.devices(&native)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - M5: Moderation
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func kickUser(_ userId: UInt32, reason: String? = nil) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_kick_user(handle, userId, reason))
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func banUser(_ userId: UInt32, reason: String? = nil,
|
||||||
|
expiresUnixMs: UInt64 = 0) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_ban_user(handle, userId, reason, expiresUnixMs))
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func setPermission(_ userId: UInt32, perms: Permissions) -> VoiceCatResult {
|
||||||
|
var native = vc_permissions()
|
||||||
|
native.can_create_temp_channel = perms.canCreateTempChannel ? 1 : 0
|
||||||
|
native.can_kick = perms.canKick ? 1 : 0
|
||||||
|
native.can_ban = perms.canBan ? 1 : 0
|
||||||
|
native.can_move_users = perms.canMoveUsers ? 1 : 0
|
||||||
|
native.can_admin_accounts = perms.canAdminAccounts ? 1 : 0
|
||||||
|
native.is_admin = perms.isAdmin ? 1 : 0
|
||||||
|
return VoiceCatResult(vc_set_permission(handle, userId, &native))
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func setServerMute(_ userId: UInt32, muted: Bool, deafened: Bool) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_set_server_mute(handle, userId, muted ? 1 : 0, deafened ? 1 : 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func moveUser(_ userId: UInt32, toChannel channelId: UInt32) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_move_user(handle, userId, channelId))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - M5: Channel admin
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func createChannel(_ info: ChannelEdit) -> VoiceCatResult {
|
||||||
|
var native = vc_channel_info()
|
||||||
|
Self.populateChannelInfo(&native, from: info)
|
||||||
|
defer { Self.freeChannelInfoStrings(&native) }
|
||||||
|
return VoiceCatResult(vc_create_channel(handle, &native))
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func editChannel(_ info: ChannelEdit) -> VoiceCatResult {
|
||||||
|
var native = vc_channel_info()
|
||||||
|
Self.populateChannelInfo(&native, from: info)
|
||||||
|
defer { Self.freeChannelInfoStrings(&native) }
|
||||||
|
return VoiceCatResult(vc_edit_channel(handle, &native))
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func deleteChannel(_ channelId: UInt32) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_delete_channel(handle, channelId))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - M5: Account admin
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func createAccount(_ username: String, password: String) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_create_account(handle, username, password))
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func resetPassword(_ username: String, newPassword: String) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_reset_password(handle, username, newPassword))
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func deleteAccount(_ username: String) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_delete_account(handle, username))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request the account list — result arrives as a `.accountList` event, then call
|
||||||
|
/// `listAccounts()` to pull the cached list.
|
||||||
|
@discardableResult
|
||||||
|
public func requestAccountList() -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_list_accounts(handle))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func listAccounts() -> [Account] {
|
||||||
|
var native = vc_account_list()
|
||||||
|
_ = vc_get_account_list(handle, &native)
|
||||||
|
return Marshaling.accounts(&native)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getPermissions() -> Permissions {
|
||||||
|
var native = vc_permissions()
|
||||||
|
_ = vc_get_permissions(handle, &native)
|
||||||
|
return Marshaling.permissions(native)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers for vc_channel_info / vc_audio_config construction
|
||||||
|
|
||||||
|
extension VoiceCatClient {
|
||||||
|
/// Populate a `vc_channel_info` from a Swift `ChannelEdit`. The string fields
|
||||||
|
/// (`name`/`topic`/`password`) are strdup'd — the caller MUST call
|
||||||
|
/// `freeChannelInfoStrings(_:)` after the C call returns (the core copies what it needs
|
||||||
|
/// during the call, so the temporary buffers can be freed via `defer`).
|
||||||
|
internal static func populateChannelInfo(_ native: inout vc_channel_info, from info: ChannelEdit) {
|
||||||
|
native.id = info.id
|
||||||
|
native.parent_id = info.parentId
|
||||||
|
native.name = UnsafePointer(strdup(info.name))
|
||||||
|
native.topic = UnsafePointer(strdup(info.topic))
|
||||||
|
native.password_protected = info.passwordProtected ? 1 : 0
|
||||||
|
native.password = (info.passwordProtected && !(info.password?.isEmpty ?? true))
|
||||||
|
? UnsafePointer(strdup(info.password!)) : nil
|
||||||
|
native.max_users = info.maxUsers
|
||||||
|
native.sort_order = info.sortOrder
|
||||||
|
native.audio = info.audio.toNative()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Free the strdup'd string fields of a `vc_channel_info` populated by
|
||||||
|
/// `populateChannelInfo`. Call this in a `defer` after the C call.
|
||||||
|
internal static func freeChannelInfoStrings(_ native: inout vc_channel_info) {
|
||||||
|
if let p = native.name { free(UnsafeMutablePointer(mutating: p)); native.name = nil }
|
||||||
|
if let p = native.topic { free(UnsafeMutablePointer(mutating: p)); native.topic = nil }
|
||||||
|
if let p = native.password { free(UnsafeMutablePointer(mutating: p)); native.password = nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AudioConfig {
|
||||||
|
/// Convert to a native `vc_audio_config`.
|
||||||
|
internal func toNative() -> vc_audio_config {
|
||||||
|
var n = vc_audio_config()
|
||||||
|
n.codec = codec
|
||||||
|
n.mode = stereo ? 1 : 0
|
||||||
|
n.sample_rate = sampleRate
|
||||||
|
n.bitrate_bps = bitrateBps
|
||||||
|
n.frame_ms = frameMs
|
||||||
|
n.application = application
|
||||||
|
n.fec = fec ? 1 : 0
|
||||||
|
n.expected_packet_loss = expectedPacketLoss
|
||||||
|
n.dtx = dtx ? 1 : 0
|
||||||
|
n.complexity = complexity
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
68
clients/apple/Tests/VoiceCatCoreTests/ExternalPcmTests.swift
Normal file
68
clients/apple/Tests/VoiceCatCoreTests/ExternalPcmTests.swift
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// ExternalPcmTests — Swift wrapper smoke tests for vc_stream_feed_pcm / vc_set_pcm_sink.
|
||||||
|
//
|
||||||
|
// These tests verify that the Swift API surface compiles, is callable, and returns expected
|
||||||
|
// results at the C-ABI boundary — without requiring a live server or audio hardware.
|
||||||
|
// Full end-to-end relay / decode verification is covered by tests/test_external_pcm.cpp
|
||||||
|
// (C++ ctest), which runs headlessly on all platforms.
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import VoiceCatCore
|
||||||
|
|
||||||
|
final class ExternalPcmTests: XCTestCase {
|
||||||
|
|
||||||
|
// MARK: - feedPcm: API surface smoke
|
||||||
|
|
||||||
|
/// Calling feedPcm without a connected client or active stream must return .invalidArg
|
||||||
|
/// (not crash). Proves the Swift→C bridge compiles and handles the error path.
|
||||||
|
func testFeedPcm_noActiveStream_returnsInvalidArg() {
|
||||||
|
let client = VoiceCatClient(config: VoiceCatConfig(
|
||||||
|
clientName: "ext-pcm-test",
|
||||||
|
clientVersion: "0.1",
|
||||||
|
logLevel: .off
|
||||||
|
))
|
||||||
|
let sine = [Int16](repeating: 0, count: 960)
|
||||||
|
// Stream 0 doesn't exist — the core must return invalidArg, not crash.
|
||||||
|
let result = client.feedPcm(streamId: 0, pcm: sine, samplesPerChannel: 960, channels: 1)
|
||||||
|
XCTAssertEqual(result, .invalidArg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calling feedPcm with channels=3 (invalid) must return .invalidArg.
|
||||||
|
func testFeedPcm_invalidChannels_returnsInvalidArg() {
|
||||||
|
let client = VoiceCatClient(config: VoiceCatConfig(
|
||||||
|
clientName: "ext-pcm-test",
|
||||||
|
clientVersion: "0.1",
|
||||||
|
logLevel: .off
|
||||||
|
))
|
||||||
|
let pcm = [Int16](repeating: 0, count: 960 * 3)
|
||||||
|
let result = client.feedPcm(streamId: 0, pcm: pcm, samplesPerChannel: 960, channels: 3)
|
||||||
|
XCTAssertEqual(result, .invalidArg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - setPcmSink: API surface smoke
|
||||||
|
|
||||||
|
/// setPcmSink(nil) on a freshly-created client must succeed (nil = disable, which is the
|
||||||
|
/// default state — a no-op that must still return .ok).
|
||||||
|
func testSetPcmSink_nil_returnsOk() {
|
||||||
|
let client = VoiceCatClient(config: VoiceCatConfig(
|
||||||
|
clientName: "ext-pcm-test",
|
||||||
|
clientVersion: "0.1",
|
||||||
|
logLevel: .off
|
||||||
|
))
|
||||||
|
let result = client.setPcmSink(nil, user: nil)
|
||||||
|
XCTAssertEqual(result, .ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calling setPcmSink with a @convention(c) function and then immediately disabling it
|
||||||
|
/// with nil must both succeed. Verifies the C-ABI function-pointer round-trip.
|
||||||
|
func testSetPcmSink_enableThenDisable_bothSucceed() {
|
||||||
|
let client = VoiceCatClient(config: VoiceCatConfig(
|
||||||
|
clientName: "ext-pcm-test",
|
||||||
|
clientVersion: "0.1",
|
||||||
|
logLevel: .off
|
||||||
|
))
|
||||||
|
|
||||||
|
let mySink: VoiceCatPcmSinkCallback = { _, _, _, _, _, _, _ in }
|
||||||
|
XCTAssertEqual(client.setPcmSink(mySink, user: nil), .ok)
|
||||||
|
XCTAssertEqual(client.setPcmSink(nil, user: nil), .ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,505 @@
|
|||||||
|
// VoiceCatClientSmokeTests — exercises the full connect → TOFU → auth → channels →
|
||||||
|
// moderation flow purely through the Swift wrapper layer (VoiceCatClient), against a real
|
||||||
|
// `voicecat-server` (the same binary the C++ ctest suite uses, built by `cmake --preset dev`).
|
||||||
|
// This is the Swift analog of clients/windows/VoiceCat.Interop.Tests/VoiceCatClientSmokeTests.cs.
|
||||||
|
//
|
||||||
|
// Why this exists (same rationale as the C# tests): the C++ ctest suite proves the protocol
|
||||||
|
// works at the C++ level, but Swift-specific interop bugs — @convention(c) callback lifetime,
|
||||||
|
// Unmanaged pointer resolution, CString memory management, enum raw-value bridging, struct
|
||||||
|
// field layout — can only be caught by exercising the exact Swift→C boundary. These tests
|
||||||
|
// catch the same class of bugs the C# P/Invoke tests catch, for Swift.
|
||||||
|
//
|
||||||
|
// Prerequisites: `cmake --preset dev && cmake --build --preset dev` (builds voicecat-server
|
||||||
|
// and voicecat-admin into build/dev/bin/), AND `scripts/build-xcframework.sh` (builds the
|
||||||
|
// VoiceCatCore.xcframework that the Swift Package links).
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
import Foundation
|
||||||
|
@testable import VoiceCatCore
|
||||||
|
|
||||||
|
/// Manages a real `voicecat-server` process for the test suite's lifetime. Starts the server
|
||||||
|
/// on an ephemeral port (--port 0), parses the bound port from stdout, and provisions a known
|
||||||
|
/// admin account via `voicecat-admin`. Killed + cleaned up in deinit.
|
||||||
|
private final class ServerHarness {
|
||||||
|
let port: UInt16
|
||||||
|
private let process: Process
|
||||||
|
let tempDir: String
|
||||||
|
|
||||||
|
init() throws {
|
||||||
|
let repoRoot = Self.findRepoRoot()
|
||||||
|
let serverURL = URL(fileURLWithPath: repoRoot)
|
||||||
|
.appendingPathComponent("build/dev/bin/voicecat-server")
|
||||||
|
|
||||||
|
guard FileManager.default.isExecutableFile(atPath: serverURL.path) else {
|
||||||
|
throw NSError(domain: "VoiceCatTest", code: 1, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "voicecat-server not found at \(serverURL.path) — "
|
||||||
|
+ "build the dev preset first: cmake --preset dev && cmake --build --preset dev",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
let tempDir = NSTemporaryDirectory() + "vc_swift_smoke_" + UUID().uuidString
|
||||||
|
try FileManager.default.createDirectory(atPath: tempDir, withIntermediateDirectories: true)
|
||||||
|
self.tempDir = tempDir
|
||||||
|
|
||||||
|
let p = Process()
|
||||||
|
p.executableURL = serverURL
|
||||||
|
p.arguments = ["--port", "0", "--data-dir", tempDir, "--name", "SwiftSmokeTest"]
|
||||||
|
|
||||||
|
// Pipe stdout to read the bound port; stderr to /dev/null.
|
||||||
|
let stdoutPipe = Pipe()
|
||||||
|
p.standardOutput = stdoutPipe
|
||||||
|
p.standardError = FileHandle(forWritingAtPath: "/dev/null")
|
||||||
|
try p.run()
|
||||||
|
self.process = p
|
||||||
|
|
||||||
|
// Parse "[voicecat-server] ... — TCP :<port> UDP :<port>" from stdout. The server
|
||||||
|
// prints several lines before the port line (version, first-run admin box, etc.), so
|
||||||
|
// we keep reading until we find a line matching "TCP :<port>". Read with a 10s timeout
|
||||||
|
// so a crashed/hung server can't hang the test forever.
|
||||||
|
guard let port = Self.readPortWithTimeout(stdoutPipe, timeout: 10) else {
|
||||||
|
p.terminate()
|
||||||
|
throw NSError(domain: "VoiceCatTest", code: 2, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "voicecat-server did not report a bound TCP port within 10s",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
self.port = port
|
||||||
|
|
||||||
|
// Provision a known admin account for moderation/admin tests (M5).
|
||||||
|
let adminURL = URL(fileURLWithPath: repoRoot)
|
||||||
|
.appendingPathComponent("build/dev/bin/voicecat-admin")
|
||||||
|
guard FileManager.default.isExecutableFile(atPath: adminURL.path) else {
|
||||||
|
throw NSError(domain: "VoiceCatTest", code: 3, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "voicecat-admin not found at \(adminURL.path)",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
let adminProc = Process()
|
||||||
|
adminProc.executableURL = adminURL
|
||||||
|
adminProc.arguments = ["--data-dir", tempDir, "account", "add", "admin2",
|
||||||
|
"--admin", "--password", "testpassword123"]
|
||||||
|
adminProc.standardOutput = FileHandle(forWritingAtPath: "/dev/null")
|
||||||
|
adminProc.standardError = FileHandle(forWritingAtPath: "/dev/null")
|
||||||
|
try adminProc.run()
|
||||||
|
adminProc.waitUntilExit()
|
||||||
|
guard adminProc.terminationStatus == 0 else {
|
||||||
|
throw NSError(domain: "VoiceCatTest", code: 4, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "voicecat-admin failed to provision admin2 (exit \(adminProc.terminationStatus))",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
if process.isRunning { process.terminate() }
|
||||||
|
try? FileManager.default.removeItem(atPath: tempDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func findRepoRoot() -> String {
|
||||||
|
var url = URL(fileURLWithPath: #file)
|
||||||
|
while url.path != "/" && !FileManager.default.fileExists(atPath: url.appendingPathComponent("CMakePresets.json").path) {
|
||||||
|
url = url.deletingLastPathComponent()
|
||||||
|
}
|
||||||
|
guard FileManager.default.fileExists(atPath: url.appendingPathComponent("CMakePresets.json").path) else {
|
||||||
|
fatalError("Could not find repo root (CMakePresets.json) above \(#file)")
|
||||||
|
}
|
||||||
|
return url.path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read from the server's stdout until a line matching "TCP :<port>" is found, or the
|
||||||
|
/// timeout expires. The server prints several lines (version banner, first-run admin box,
|
||||||
|
/// etc.) before the port line — see server/src/server.cpp.
|
||||||
|
private static func readPortWithTimeout(_ pipe: Pipe, timeout: TimeInterval) -> UInt16? {
|
||||||
|
let handle = pipe.fileHandleForReading
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
var buffer = Data()
|
||||||
|
while Date() < deadline {
|
||||||
|
let data = handle.availableData
|
||||||
|
if !data.isEmpty {
|
||||||
|
buffer.append(data)
|
||||||
|
// Check each complete line in the buffer for "TCP :<port>".
|
||||||
|
while let newlineIdx = buffer.firstIndex(of: 0x0A) {
|
||||||
|
let lineData = buffer.prefix(newlineIdx)
|
||||||
|
buffer = buffer.suffix(from: buffer.index(after: newlineIdx))
|
||||||
|
if let line = String(data: lineData, encoding: .utf8),
|
||||||
|
let port = parsePort(from: line) {
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Thread.sleep(forTimeInterval: 0.05)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parsePort(from line: String) -> UInt16? {
|
||||||
|
// Match "TCP :<port>" — see server/src/server.cpp.
|
||||||
|
guard let range = line.range(of: #"TCP :(\d+)"#, options: .regularExpression) else { return nil }
|
||||||
|
let digits = line[range].split(separator: ":").last ?? ""
|
||||||
|
return UInt16(digits.trimmingCharacters(in: .whitespaces))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// XCTest smoke tests against a real voicecat-server, through the Swift VoiceCatClient wrapper.
|
||||||
|
final class VoiceCatClientSmokeTests: XCTestCase {
|
||||||
|
private static var harness: ServerHarness?
|
||||||
|
|
||||||
|
override class func setUp() {
|
||||||
|
do {
|
||||||
|
harness = try ServerHarness()
|
||||||
|
} catch {
|
||||||
|
// Store the error so each test fails with a clear message rather than a crash.
|
||||||
|
NSLog("ServerHarness setup failed: \(error.localizedDescription)")
|
||||||
|
harness = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override class func tearDown() {
|
||||||
|
harness = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private var port: UInt16 {
|
||||||
|
guard let p = Self.harness?.port else {
|
||||||
|
XCTFail("ServerHarness not started — see setUp error in log")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
private var tempDir: String {
|
||||||
|
Self.harness?.tempDir ?? NSTemporaryDirectory()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: wait until the predicate is satisfied, running the main runloop to process
|
||||||
|
/// dispatched events. The Swift analog of the C# `PumpUntil` helper. Our events are
|
||||||
|
/// delivered via DispatchQueue.main.async, which the main runloop processes during
|
||||||
|
/// `RunLoop.current.run(until:)`.
|
||||||
|
///
|
||||||
|
/// Uses RunLoop polling (not XCTestExpectation) so that the "assert something does NOT
|
||||||
|
/// happen within N seconds" pattern works without generating spurious "Asynchronous wait
|
||||||
|
/// failed" errors — `wait(for:timeout:)` logs an error when an expectation isn't
|
||||||
|
/// fulfilled, which is wrong for negative checks.
|
||||||
|
private func waitFor(timeout: TimeInterval = 5, _ predicate: @escaping () -> Bool) -> Bool {
|
||||||
|
if predicate() { return true }
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
// Run the main runloop for ~20ms — processes DispatchQueue.main.async blocks
|
||||||
|
// (where our events/levels are drained) and timer sources.
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.02))
|
||||||
|
if predicate() { return true }
|
||||||
|
}
|
||||||
|
return predicate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requireHarness() -> Bool {
|
||||||
|
guard Self.harness != nil else {
|
||||||
|
XCTFail("ServerHarness not started — see setUp error in log")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tests
|
||||||
|
|
||||||
|
func testVersionStringIsNonEmpty() {
|
||||||
|
XCTAssertFalse(VoiceCatClient.versionString.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testResultStringRoundTrips() {
|
||||||
|
XCTAssertFalse(VoiceCatClient.resultString(.ok).isEmpty)
|
||||||
|
XCTAssertFalse(VoiceCatClient.resultString(.permissionDenied).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full connect → TOFU → confirm → guest auth → list channels → permissions → guest
|
||||||
|
/// ListAccounts rejected. Mirrors the C# `Connect_Tofu_Auth_ListChannels_RoundTrips`.
|
||||||
|
func testConnectTofuAuthListChannelsRoundTrips() throws {
|
||||||
|
guard requireHarness() else { return }
|
||||||
|
|
||||||
|
var events: [VoiceCatEvent] = []
|
||||||
|
let client = VoiceCatClient(config: VoiceCatConfig(
|
||||||
|
clientName: "vc-swift-smoke",
|
||||||
|
clientVersion: "0.1",
|
||||||
|
logLevel: .off,
|
||||||
|
tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins.txt")
|
||||||
|
))
|
||||||
|
client.onEvent = { events.append($0) }
|
||||||
|
|
||||||
|
XCTAssertEqual(client.connect(host: "127.0.0.1", port: port), .ok)
|
||||||
|
XCTAssertEqual(client.authenticateGuest("SwiftSmoke"), .ok)
|
||||||
|
|
||||||
|
// Wait for VC_EVENT_SERVER_IDENTITY.
|
||||||
|
XCTAssertTrue(waitFor { events.contains { $0.type == .serverIdentity } },
|
||||||
|
"did not receive .serverIdentity")
|
||||||
|
let identityEvent = try XCTUnwrap(events.first { $0.type == .serverIdentity })
|
||||||
|
XCTAssertEqual(identityEvent.tofuStatus, .firstConnect)
|
||||||
|
XCTAssertNotNil(identityEvent.text)
|
||||||
|
XCTAssertEqual(identityEvent.text?.count, 64, "SHA-256 hex, no separators")
|
||||||
|
|
||||||
|
// Auth must NOT complete before identity is confirmed (800ms, like the C# test).
|
||||||
|
XCTAssertFalse(waitFor(timeout: 0.8) { events.contains { $0.type == .authResult } },
|
||||||
|
"auth completed before identity confirmation (should be held open)")
|
||||||
|
|
||||||
|
XCTAssertEqual(client.confirmServerIdentity(accept: true), .ok)
|
||||||
|
|
||||||
|
// Wait for VC_EVENT_AUTH_RESULT.
|
||||||
|
XCTAssertTrue(waitFor { events.contains { $0.type == .authResult } },
|
||||||
|
"did not receive .authResult after confirming identity")
|
||||||
|
let authEvent = try XCTUnwrap(events.first { $0.type == .authResult })
|
||||||
|
XCTAssertEqual(authEvent.result, .ok)
|
||||||
|
|
||||||
|
// Wait for VC_EVENT_CHANNEL_LIST.
|
||||||
|
XCTAssertTrue(waitFor { events.contains { $0.type == .channelList } },
|
||||||
|
"did not receive .channelList")
|
||||||
|
|
||||||
|
let channels = client.listChannels()
|
||||||
|
XCTAssertTrue(channels.contains { $0.id == 1 && $0.name == "Lobby" },
|
||||||
|
"expected Lobby (channel 1) in \(channels.map { $0.name })")
|
||||||
|
|
||||||
|
// M5: permissions getter round-trip.
|
||||||
|
let perms = client.getPermissions()
|
||||||
|
XCTAssertFalse(perms.isAdmin)
|
||||||
|
XCTAssertFalse(perms.canKick)
|
||||||
|
|
||||||
|
// M5: guest ListAccounts is rejected by the server with a GenericResult — proves the
|
||||||
|
// moderation wrapper path works end-to-end through the Swift interop layer.
|
||||||
|
events.removeAll()
|
||||||
|
XCTAssertEqual(client.requestAccountList(), .ok)
|
||||||
|
XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult } },
|
||||||
|
"did not receive .genericResult for guest ListAccounts")
|
||||||
|
let generic = try XCTUnwrap(events.first { $0.type == .genericResult })
|
||||||
|
XCTAssertEqual(generic.result, .permissionDenied)
|
||||||
|
|
||||||
|
client.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Admin auth → channel CRUD → account CRUD. Mirrors C# `Admin_ChannelCrud_AccountCrud_RoundTrips`.
|
||||||
|
func testAdminChannelCrudAccountCrudRoundTrips() throws {
|
||||||
|
guard requireHarness() else { return }
|
||||||
|
|
||||||
|
var events: [VoiceCatEvent] = []
|
||||||
|
let client = VoiceCatClient(config: VoiceCatConfig(
|
||||||
|
clientName: "vc-swift-admin",
|
||||||
|
clientVersion: "0.1",
|
||||||
|
logLevel: .off,
|
||||||
|
tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins_admin.txt")
|
||||||
|
))
|
||||||
|
client.onEvent = { events.append($0) }
|
||||||
|
|
||||||
|
XCTAssertEqual(client.connect(host: "127.0.0.1", port: port), .ok)
|
||||||
|
XCTAssertEqual(client.authenticateUser("admin2", password: "testpassword123"), .ok)
|
||||||
|
|
||||||
|
XCTAssertTrue(waitFor { events.contains { $0.type == .serverIdentity } })
|
||||||
|
XCTAssertEqual(client.confirmServerIdentity(accept: true), .ok)
|
||||||
|
XCTAssertTrue(waitFor { events.contains { $0.type == .authResult } })
|
||||||
|
XCTAssertEqual(try XCTUnwrap(events.first { $0.type == .authResult }).result, .ok)
|
||||||
|
XCTAssertTrue(waitFor { events.contains { $0.type == .channelList } })
|
||||||
|
|
||||||
|
let perms = client.getPermissions()
|
||||||
|
XCTAssertTrue(perms.isAdmin || perms.canAdminAccounts)
|
||||||
|
|
||||||
|
// Channel CRUD — create.
|
||||||
|
let audioConfig = AudioConfig(stereo: true, bitrateBps: 64000, frameMs: 20,
|
||||||
|
application: 1, fec: true, expectedPacketLoss: 5, complexity: 10)
|
||||||
|
XCTAssertEqual(client.createChannel(ChannelEdit(
|
||||||
|
id: 0, parentId: 0, name: "Swift Test Channel", topic: "Created by Swift smoke test",
|
||||||
|
passwordProtected: false, password: nil, maxUsers: 42, sortOrder: 0, audio: audioConfig
|
||||||
|
)), .ok)
|
||||||
|
XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } },
|
||||||
|
"CreateChannel did not succeed")
|
||||||
|
|
||||||
|
var channels = client.listChannels()
|
||||||
|
let created = try XCTUnwrap(channels.first { $0.name == "Swift Test Channel" })
|
||||||
|
XCTAssertEqual(created.topic, "Created by Swift smoke test")
|
||||||
|
XCTAssertFalse(created.passwordProtected)
|
||||||
|
|
||||||
|
// Channel CRUD — edit.
|
||||||
|
events.removeAll()
|
||||||
|
XCTAssertEqual(client.editChannel(ChannelEdit(
|
||||||
|
id: created.id, parentId: created.parentId, name: created.name,
|
||||||
|
topic: "Updated topic", passwordProtected: false, password: nil,
|
||||||
|
maxUsers: 100, sortOrder: 0, audio: audioConfig
|
||||||
|
)), .ok)
|
||||||
|
XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } },
|
||||||
|
"EditChannel did not succeed")
|
||||||
|
|
||||||
|
// Channel CRUD — delete.
|
||||||
|
events.removeAll()
|
||||||
|
XCTAssertEqual(client.deleteChannel(created.id), .ok)
|
||||||
|
XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } },
|
||||||
|
"DeleteChannel did not succeed")
|
||||||
|
|
||||||
|
// Account CRUD — create.
|
||||||
|
events.removeAll()
|
||||||
|
XCTAssertEqual(client.createAccount("swift_smoke_user", password: "initialpw"), .ok)
|
||||||
|
XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } },
|
||||||
|
"CreateAccount did not succeed")
|
||||||
|
|
||||||
|
// Account CRUD — list.
|
||||||
|
events.removeAll()
|
||||||
|
XCTAssertEqual(client.requestAccountList(), .ok)
|
||||||
|
XCTAssertTrue(waitFor { events.contains { $0.type == .accountList } },
|
||||||
|
"did not receive .accountList")
|
||||||
|
let accounts = client.listAccounts()
|
||||||
|
XCTAssertTrue(accounts.contains { $0.username == "swift_smoke_user" })
|
||||||
|
|
||||||
|
// Account CRUD — reset password.
|
||||||
|
events.removeAll()
|
||||||
|
XCTAssertEqual(client.resetPassword("swift_smoke_user", newPassword: "newpw123"), .ok)
|
||||||
|
XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } },
|
||||||
|
"ResetPassword did not succeed")
|
||||||
|
|
||||||
|
// Account CRUD — delete.
|
||||||
|
events.removeAll()
|
||||||
|
XCTAssertEqual(client.deleteAccount("swift_smoke_user"), .ok)
|
||||||
|
XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } },
|
||||||
|
"DeleteAccount did not succeed")
|
||||||
|
|
||||||
|
client.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Screen-audio (SCREEN_AUDIO) stream start/stop through the Swift wrapper. The core's
|
||||||
|
/// macOS CoreAudio path starts the StreamAnnounce; this exercises the full
|
||||||
|
/// startStream → .streamStarted → stopStream → .streamStopped path through Swift interop.
|
||||||
|
/// Mirrors C# `ScreenAudioStream_Starts_And_Stops`.
|
||||||
|
func testScreenAudioStreamStartsAndStops() throws {
|
||||||
|
guard requireHarness() else { return }
|
||||||
|
|
||||||
|
var events: [VoiceCatEvent] = []
|
||||||
|
let client = VoiceCatClient(config: VoiceCatConfig(
|
||||||
|
clientName: "vc-swift-screen",
|
||||||
|
clientVersion: "0.1",
|
||||||
|
logLevel: .off,
|
||||||
|
tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins_screen.txt")
|
||||||
|
))
|
||||||
|
client.onEvent = { events.append($0) }
|
||||||
|
|
||||||
|
XCTAssertEqual(client.connect(host: "127.0.0.1", port: port), .ok)
|
||||||
|
XCTAssertEqual(client.authenticateGuest("SwiftScreen"), .ok)
|
||||||
|
|
||||||
|
XCTAssertTrue(waitFor { events.contains { $0.type == .serverIdentity } })
|
||||||
|
XCTAssertEqual(client.confirmServerIdentity(accept: true), .ok)
|
||||||
|
XCTAssertTrue(waitFor { events.contains { $0.type == .authResult } })
|
||||||
|
XCTAssertEqual(try XCTUnwrap(events.first { $0.type == .authResult }).result, .ok)
|
||||||
|
XCTAssertTrue(waitFor { events.contains { $0.type == .channelList } })
|
||||||
|
|
||||||
|
// Give the async UDP binding handshake a moment to land (mirrors vccli's 500ms sleep).
|
||||||
|
Thread.sleep(forTimeInterval: 0.5)
|
||||||
|
|
||||||
|
let (startResult, streamId) = client.startStream(
|
||||||
|
StreamDescriptor(kind: .screenAudio, label: "Desktop audio")
|
||||||
|
)
|
||||||
|
XCTAssertEqual(startResult, .ok)
|
||||||
|
XCTAssertNotEqual(streamId, 0, "streamId should be non-zero on success")
|
||||||
|
|
||||||
|
// The core emits .streamStarted for the local client too.
|
||||||
|
XCTAssertTrue(waitFor(timeout: 5) {
|
||||||
|
events.contains { $0.type == .streamStarted && $0.streamId == streamId }
|
||||||
|
}, "did not receive .streamStarted for screen-audio stream")
|
||||||
|
|
||||||
|
XCTAssertEqual(client.stopStream(streamId), .ok)
|
||||||
|
XCTAssertTrue(waitFor(timeout: 5) {
|
||||||
|
events.contains { $0.type == .streamStopped && $0.streamId == streamId }
|
||||||
|
}, "did not receive .streamStopped for screen-audio stream")
|
||||||
|
|
||||||
|
client.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-stream receive-side controls (gain/mute/NR) round-trip through Swift: two clients
|
||||||
|
/// in a channel, one publishes a MIC stream, the other setRemoteStream's it then
|
||||||
|
/// getRemoteStream's it back. Catches Swift-specific marshaling bugs (field order,
|
||||||
|
/// bool-from-int, float precision) that the C++ ctest can't. Mirrors C#
|
||||||
|
/// `PerStream_RecvControls_Round_Trip_Through_PInvoke`.
|
||||||
|
func testPerStreamRecvControlsRoundTrip() throws {
|
||||||
|
guard requireHarness() else { return }
|
||||||
|
|
||||||
|
var eventsA: [VoiceCatEvent] = []
|
||||||
|
var eventsB: [VoiceCatEvent] = []
|
||||||
|
|
||||||
|
let a = VoiceCatClient(config: VoiceCatConfig(
|
||||||
|
clientName: "vc-swift-mix-a", clientVersion: "0.1", logLevel: .off,
|
||||||
|
tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins_mix_a.txt")
|
||||||
|
))
|
||||||
|
let b = VoiceCatClient(config: VoiceCatConfig(
|
||||||
|
clientName: "vc-swift-mix-b", clientVersion: "0.1", logLevel: .off,
|
||||||
|
tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins_mix_b.txt")
|
||||||
|
))
|
||||||
|
a.onEvent = { eventsA.append($0) }
|
||||||
|
b.onEvent = { eventsB.append($0) }
|
||||||
|
|
||||||
|
// Connect + auth A first, then B (staggering avoids concurrent TLS handshakes).
|
||||||
|
XCTAssertEqual(a.connect(host: "127.0.0.1", port: port), .ok)
|
||||||
|
XCTAssertEqual(a.authenticateGuest("SwiftMixA"), .ok)
|
||||||
|
XCTAssertTrue(waitFor { eventsA.contains { $0.type == .serverIdentity } })
|
||||||
|
XCTAssertEqual(a.confirmServerIdentity(accept: true), .ok)
|
||||||
|
XCTAssertTrue(waitFor { eventsA.contains { $0.type == .authResult } })
|
||||||
|
XCTAssertEqual(try XCTUnwrap(eventsA.first { $0.type == .authResult }).result, .ok)
|
||||||
|
XCTAssertTrue(waitFor { eventsA.contains { $0.type == .channelList } })
|
||||||
|
|
||||||
|
XCTAssertEqual(b.connect(host: "127.0.0.1", port: port), .ok)
|
||||||
|
XCTAssertEqual(b.authenticateGuest("SwiftMixB"), .ok)
|
||||||
|
XCTAssertTrue(waitFor { eventsB.contains { $0.type == .serverIdentity } })
|
||||||
|
XCTAssertEqual(b.confirmServerIdentity(accept: true), .ok)
|
||||||
|
XCTAssertTrue(waitFor { eventsB.contains { $0.type == .authResult } })
|
||||||
|
XCTAssertEqual(try XCTUnwrap(eventsB.first { $0.type == .authResult }).result, .ok)
|
||||||
|
XCTAssertTrue(waitFor { eventsB.contains { $0.type == .channelList } })
|
||||||
|
|
||||||
|
// Both join Lobby (channel 1) so voice relays between them.
|
||||||
|
XCTAssertEqual(a.joinChannel(1), .ok)
|
||||||
|
XCTAssertTrue(waitFor { eventsA.contains { $0.type == .joinResult } },
|
||||||
|
"A did not receive .joinResult")
|
||||||
|
XCTAssertEqual(b.joinChannel(1), .ok)
|
||||||
|
XCTAssertTrue(waitFor { eventsB.contains { $0.type == .joinResult } },
|
||||||
|
"B did not receive .joinResult")
|
||||||
|
|
||||||
|
// UDP binding handshake is async; give it a moment.
|
||||||
|
Thread.sleep(forTimeInterval: 0.5)
|
||||||
|
|
||||||
|
// A publishes a MIC stream.
|
||||||
|
let (startResult, streamId) = a.startStream(StreamDescriptor(kind: .mic, label: "mix-test-mic"))
|
||||||
|
XCTAssertEqual(startResult, .ok)
|
||||||
|
XCTAssertNotEqual(streamId, 0)
|
||||||
|
|
||||||
|
// B sees A's stream.
|
||||||
|
XCTAssertTrue(waitFor(timeout: 5) {
|
||||||
|
eventsB.contains { $0.type == .streamStarted && $0.streamId == streamId }
|
||||||
|
}, "B did not see A's .streamStarted")
|
||||||
|
|
||||||
|
// Resolve A's user id from B's user list.
|
||||||
|
var aUid: UInt32 = 0
|
||||||
|
XCTAssertTrue(waitFor(timeout: 3) {
|
||||||
|
aUid = b.listUsers().first { $0.nickname == "SwiftMixA" }?.id ?? 0
|
||||||
|
return aUid != 0
|
||||||
|
}, "could not resolve A's user id on B")
|
||||||
|
XCTAssertNotEqual(aUid, 0)
|
||||||
|
|
||||||
|
// B can enumerate A's stream.
|
||||||
|
XCTAssertTrue(waitFor(timeout: 3) {
|
||||||
|
b.listUserStreams(aUid).contains { $0.id == streamId }
|
||||||
|
}, "B could not enumerate A's stream")
|
||||||
|
let bStreams = b.listUserStreams(aUid)
|
||||||
|
XCTAssertTrue(bStreams.contains { $0.id == streamId && $0.kind == .mic })
|
||||||
|
|
||||||
|
// Before B ever sets anything, defaults read back (gain 1.0, unmuted, NR off).
|
||||||
|
let (r0, st0) = b.getRemoteStream(userId: aUid, streamId: streamId)
|
||||||
|
XCTAssertEqual(r0, .ok)
|
||||||
|
XCTAssertNotNil(st0)
|
||||||
|
XCTAssertEqual(st0?.gain, 1.0)
|
||||||
|
XCTAssertFalse(st0?.muted ?? true)
|
||||||
|
XCTAssertFalse(st0?.noiseReduction ?? true)
|
||||||
|
|
||||||
|
// B turns A down to 0.5×, mutes, enables NR — then reads it back.
|
||||||
|
XCTAssertEqual(b.setRemoteStream(userId: aUid, streamId: streamId,
|
||||||
|
gain: 0.5, muted: true, noiseReduction: true), .ok)
|
||||||
|
let (r1, st1) = b.getRemoteStream(userId: aUid, streamId: streamId)
|
||||||
|
XCTAssertEqual(r1, .ok)
|
||||||
|
XCTAssertNotNil(st1)
|
||||||
|
XCTAssertEqual(st1?.gain, 0.5)
|
||||||
|
XCTAssertTrue(st1?.muted ?? false)
|
||||||
|
XCTAssertTrue(st1?.noiseReduction ?? false)
|
||||||
|
|
||||||
|
// Unknown stream id on a known user → .invalidArg.
|
||||||
|
let (rBad, stBad) = b.getRemoteStream(userId: aUid, streamId: 0xDEADBEEF)
|
||||||
|
XCTAssertEqual(rBad, .invalidArg)
|
||||||
|
XCTAssertNil(stBad)
|
||||||
|
|
||||||
|
a.disconnect()
|
||||||
|
b.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
178
clients/apple/iOS/Shared/BroadcastAudioRing.swift
Normal file
178
clients/apple/iOS/Shared/BroadcastAudioRing.swift
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import Foundation
|
||||||
|
import Darwin
|
||||||
|
|
||||||
|
// Darwin notification names the extension posts and the host observes, so the host can react to
|
||||||
|
// broadcast start/stop promptly instead of only polling the ring's active flag. Shared (compiled
|
||||||
|
// into both targets) so the names can't drift.
|
||||||
|
enum BroadcastNotification {
|
||||||
|
static let started = "cat.voice.VoiceCat.broadcast.started"
|
||||||
|
static let finished = "cat.voice.VoiceCat.broadcast.finished"
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastAudioRing — cross-process single-producer/single-consumer int16 PCM ring over an
|
||||||
|
// mmap'd file in the shared App Group container. Compiled into BOTH the host app and the
|
||||||
|
// ReplayKit broadcast upload extension (docs/voice.md §9, iOS detail).
|
||||||
|
//
|
||||||
|
// producer = the broadcast extension's RPBroadcastSampleHandler (captured system audio)
|
||||||
|
// consumer = the host app's BroadcastAudioPump (drains and feeds the core via feedPcm)
|
||||||
|
//
|
||||||
|
// Why a hand-rolled ring instead of Swift's `Atomic`: the storage lives in shared memory mapped
|
||||||
|
// into two processes, so the synchronization words must sit in that mapping — Swift's managed
|
||||||
|
// atomics can't. For strict SPSC, aligned 64-bit monotonic indices with full memory barriers
|
||||||
|
// (`OSMemoryBarrier`) give correct acquire/release ordering. The extension does NOT link
|
||||||
|
// libvoicecat — it only writes PCM here; all encode/crypto/UDP happens in the host.
|
||||||
|
//
|
||||||
|
// Lifecycle: the host opens the ring at connect (and thus initializes the header first); the
|
||||||
|
// extension opens it later when a broadcast starts. On a rising edge of `isActive` the host
|
||||||
|
// calls `drainStale()` to discard any pre-roll, then drains in whole 20 ms frames.
|
||||||
|
final class BroadcastAudioRing {
|
||||||
|
|
||||||
|
static let appGroupId = "group.cat.voice.VoiceCat"
|
||||||
|
|
||||||
|
enum RingError: Error { case noContainer, openFailed, mapFailed }
|
||||||
|
|
||||||
|
// Header layout (byte offsets into the mmap). Indices are 8-byte aligned; mmap is
|
||||||
|
// page-aligned so offsets 24/32 satisfy that.
|
||||||
|
private static let magic: UInt32 = 0x5643_4252 // "VCBR"
|
||||||
|
private static let version: UInt32 = 1
|
||||||
|
private static let headerBytes = 64
|
||||||
|
/// 1 second of 48 kHz stereo int16 — ample slack for ~10 ms host drains.
|
||||||
|
static let capacitySamples = 48_000 * 2
|
||||||
|
|
||||||
|
private static let offMagic = 0
|
||||||
|
private static let offVersion = 4
|
||||||
|
private static let offChannels = 8
|
||||||
|
private static let offSampleRate = 12
|
||||||
|
private static let offActive = 16
|
||||||
|
private static let offWrite = 24
|
||||||
|
private static let offRead = 32
|
||||||
|
|
||||||
|
private let fd: Int32
|
||||||
|
private let mapBase: UnsafeMutableRawPointer
|
||||||
|
private let mapSize: Int
|
||||||
|
private let data: UnsafeMutablePointer<Int16>
|
||||||
|
private let capacity: Int
|
||||||
|
|
||||||
|
init() throws {
|
||||||
|
guard let container = FileManager.default.containerURL(
|
||||||
|
forSecurityApplicationGroupIdentifier: Self.appGroupId) else {
|
||||||
|
throw RingError.noContainer
|
||||||
|
}
|
||||||
|
let dir = container.appendingPathComponent("voicecat", isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
let path = dir.appendingPathComponent("broadcast_audio.ring").path
|
||||||
|
|
||||||
|
capacity = Self.capacitySamples
|
||||||
|
mapSize = Self.headerBytes + capacity * MemoryLayout<Int16>.size
|
||||||
|
|
||||||
|
let f = open(path, O_RDWR | O_CREAT, 0o644)
|
||||||
|
guard f >= 0 else { throw RingError.openFailed }
|
||||||
|
if ftruncate(f, off_t(mapSize)) != 0 { close(f); throw RingError.openFailed }
|
||||||
|
|
||||||
|
let p = mmap(nil, mapSize, PROT_READ | PROT_WRITE, MAP_SHARED, f, 0)
|
||||||
|
guard let p, p != MAP_FAILED else { close(f); throw RingError.mapFailed }
|
||||||
|
|
||||||
|
fd = f
|
||||||
|
mapBase = p
|
||||||
|
data = (p + Self.headerBytes).assumingMemoryBound(to: Int16.self)
|
||||||
|
|
||||||
|
// First opener initializes the header (host opens first, before any broadcast).
|
||||||
|
if load32(Self.offMagic) != Self.magic {
|
||||||
|
store32(Self.offWrite, 0); store32(Self.offWrite + 4, 0)
|
||||||
|
store32(Self.offRead, 0); store32(Self.offRead + 4, 0)
|
||||||
|
store32(Self.offChannels, 0)
|
||||||
|
store32(Self.offSampleRate, 0)
|
||||||
|
store32(Self.offActive, 0)
|
||||||
|
store32(Self.offVersion, Self.version)
|
||||||
|
OSMemoryBarrier()
|
||||||
|
store32(Self.offMagic, Self.magic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
munmap(mapBase, mapSize)
|
||||||
|
close(fd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Header accessors
|
||||||
|
|
||||||
|
var isActive: Bool { OSMemoryBarrier(); return load32(Self.offActive) != 0 }
|
||||||
|
var channels: UInt32 { load32(Self.offChannels) }
|
||||||
|
var sampleRate: UInt32 { load32(Self.offSampleRate) }
|
||||||
|
|
||||||
|
/// Producer: advertise the canonical capture format and toggle the active flag. Called from
|
||||||
|
/// `broadcastStarted`/`broadcastFinished`.
|
||||||
|
func setActive(_ active: Bool, channels: UInt32 = 0, sampleRate: UInt32 = 0) {
|
||||||
|
if active {
|
||||||
|
store32(Self.offChannels, channels)
|
||||||
|
store32(Self.offSampleRate, sampleRate)
|
||||||
|
}
|
||||||
|
OSMemoryBarrier()
|
||||||
|
store32(Self.offActive, active ? 1 : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Producer (extension)
|
||||||
|
|
||||||
|
/// Append interleaved int16 samples. Drops the whole chunk if it doesn't fit — skipping a
|
||||||
|
/// chunk is better than tearing a frame. Single producer only.
|
||||||
|
func push(_ samples: UnsafeBufferPointer<Int16>) {
|
||||||
|
let n = samples.count
|
||||||
|
guard n > 0, n <= capacity, let src = samples.baseAddress else { return }
|
||||||
|
let w = load64(Self.offWrite) // producer owns the write index
|
||||||
|
let r = loadAcquire64(Self.offRead)
|
||||||
|
if capacity - Int(w &- r) < n { return } // full: drop
|
||||||
|
var idx = Int(w % UInt64(capacity))
|
||||||
|
var off = 0
|
||||||
|
var rem = n
|
||||||
|
while rem > 0 {
|
||||||
|
let chunk = min(rem, capacity - idx)
|
||||||
|
(data + idx).update(from: src + off, count: chunk)
|
||||||
|
idx = (idx + chunk) % capacity
|
||||||
|
off += chunk
|
||||||
|
rem -= chunk
|
||||||
|
}
|
||||||
|
storeRelease64(Self.offWrite, w &+ UInt64(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Consumer (host)
|
||||||
|
|
||||||
|
/// Read up to `out.count` interleaved int16 samples. Returns the number read. Single
|
||||||
|
/// consumer only.
|
||||||
|
func read(into out: UnsafeMutableBufferPointer<Int16>) -> Int {
|
||||||
|
guard let dst = out.baseAddress else { return 0 }
|
||||||
|
let r = load64(Self.offRead) // consumer owns the read index
|
||||||
|
let w = loadAcquire64(Self.offWrite)
|
||||||
|
let available = Int(w &- r)
|
||||||
|
if available <= 0 { return 0 }
|
||||||
|
let n = min(available, out.count)
|
||||||
|
var idx = Int(r % UInt64(capacity))
|
||||||
|
var off = 0
|
||||||
|
var rem = n
|
||||||
|
while rem > 0 {
|
||||||
|
let chunk = min(rem, capacity - idx)
|
||||||
|
(dst + off).update(from: data + idx, count: chunk)
|
||||||
|
idx = (idx + chunk) % capacity
|
||||||
|
off += chunk
|
||||||
|
rem -= chunk
|
||||||
|
}
|
||||||
|
storeRelease64(Self.offRead, r &+ UInt64(n))
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consumer: discard everything currently buffered (catch the read index up to write).
|
||||||
|
func drainStale() { storeRelease64(Self.offRead, loadAcquire64(Self.offWrite)) }
|
||||||
|
|
||||||
|
// MARK: - Memory-ordered accessors
|
||||||
|
|
||||||
|
private func ptr32(_ off: Int) -> UnsafeMutablePointer<UInt32> {
|
||||||
|
(mapBase + off).assumingMemoryBound(to: UInt32.self)
|
||||||
|
}
|
||||||
|
private func ptr64(_ off: Int) -> UnsafeMutablePointer<UInt64> {
|
||||||
|
(mapBase + off).assumingMemoryBound(to: UInt64.self)
|
||||||
|
}
|
||||||
|
private func load32(_ off: Int) -> UInt32 { ptr32(off).pointee }
|
||||||
|
private func store32(_ off: Int, _ v: UInt32) { ptr32(off).pointee = v }
|
||||||
|
private func load64(_ off: Int) -> UInt64 { ptr64(off).pointee }
|
||||||
|
private func loadAcquire64(_ off: Int) -> UInt64 { let v = ptr64(off).pointee; OSMemoryBarrier(); return v }
|
||||||
|
private func storeRelease64(_ off: Int, _ v: UInt64) { OSMemoryBarrier(); ptr64(off).pointee = v }
|
||||||
|
}
|
||||||
31
clients/apple/iOS/VoiceCatBroadcast/Info.plist
Normal file
31
clients/apple/iOS/VoiceCatBroadcast/Info.plist
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?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>CFBundleDisplayName</key>
|
||||||
|
<string>VoiceCat Screen Audio</string>
|
||||||
|
<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>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>0.0.1</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.broadcast-services-upload</string>
|
||||||
|
<key>NSExtensionPrincipalClass</key>
|
||||||
|
<string>$(PRODUCT_MODULE_NAME).SampleHandler</string>
|
||||||
|
<key>RPBroadcastProcessMode</key>
|
||||||
|
<string>RPBroadcastProcessModeSampleBuffer</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
88
clients/apple/iOS/VoiceCatBroadcast/SampleHandler.swift
Normal file
88
clients/apple/iOS/VoiceCatBroadcast/SampleHandler.swift
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import ReplayKit
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
// SampleHandler — the ReplayKit broadcast upload extension entry point (docs/voice.md §9, iOS).
|
||||||
|
//
|
||||||
|
// This runs in a SEPARATE process with a ~50 MB memory cap. It captures system/app audio
|
||||||
|
// (`.audioApp`), drops video and mic buffers, converts each chunk to the core's canonical
|
||||||
|
// format (48 kHz, int16, stereo interleaved) with AVAudioConverter, and writes it into the
|
||||||
|
// App Group shared-memory ring. The host app's BroadcastAudioPump drains the ring and feeds the
|
||||||
|
// already-connected VoiceCatClient — so all Opus/AEAD/UDP work happens in the host, and the
|
||||||
|
// extension stays tiny and well inside the memory budget (no libvoicecat here).
|
||||||
|
class SampleHandler: RPBroadcastSampleHandler {
|
||||||
|
|
||||||
|
private var ring: BroadcastAudioRing?
|
||||||
|
private var converter: AVAudioConverter?
|
||||||
|
private var inputFormat: AVAudioFormat?
|
||||||
|
private let outputFormat = AVAudioFormat(commonFormat: .pcmFormatInt16,
|
||||||
|
sampleRate: 48_000, channels: 2, interleaved: true)!
|
||||||
|
|
||||||
|
override func broadcastStarted(withSetupInfo setupInfo: [String: NSObject]?) {
|
||||||
|
ring = try? BroadcastAudioRing()
|
||||||
|
ring?.setActive(true, channels: 2, sampleRate: 48_000)
|
||||||
|
postDarwin(BroadcastNotification.started)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func broadcastFinished() {
|
||||||
|
ring?.setActive(false)
|
||||||
|
postDarwin(BroadcastNotification.finished)
|
||||||
|
ring = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
override func processSampleBuffer(_ sampleBuffer: CMSampleBuffer,
|
||||||
|
with sampleBufferType: RPSampleBufferType) {
|
||||||
|
// App/system audio only — drop video (the memory hog) and the device mic (the host
|
||||||
|
// already captures and sends the user's voice).
|
||||||
|
guard sampleBufferType == .audioApp, let ring else { return }
|
||||||
|
guard let input = makeInputBuffer(sampleBuffer),
|
||||||
|
let conv = converter(for: input.format) else { return }
|
||||||
|
|
||||||
|
let ratio = outputFormat.sampleRate / input.format.sampleRate
|
||||||
|
let capacity = AVAudioFrameCount(Double(input.frameLength) * ratio) + 1024
|
||||||
|
guard let output = AVAudioPCMBuffer(pcmFormat: outputFormat, frameCapacity: capacity) else { return }
|
||||||
|
|
||||||
|
var supplied = false
|
||||||
|
var error: NSError?
|
||||||
|
let status = conv.convert(to: output, error: &error) { _, outStatus in
|
||||||
|
if supplied { outStatus.pointee = .noDataNow; return nil }
|
||||||
|
supplied = true
|
||||||
|
outStatus.pointee = .haveData
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
guard status != .error, output.frameLength > 0,
|
||||||
|
let mData = output.audioBufferList.pointee.mBuffers.mData else { return }
|
||||||
|
|
||||||
|
// Interleaved int16 → one buffer of frameLength * channels samples.
|
||||||
|
let count = Int(output.frameLength) * Int(outputFormat.channelCount)
|
||||||
|
ring.push(UnsafeBufferPointer(start: mData.assumingMemoryBound(to: Int16.self), count: count))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
/// Wrap the ReplayKit CMSampleBuffer's PCM in an AVAudioPCMBuffer matching its native format.
|
||||||
|
private func makeInputBuffer(_ sb: CMSampleBuffer) -> AVAudioPCMBuffer? {
|
||||||
|
guard let fmtDesc = CMSampleBufferGetFormatDescription(sb),
|
||||||
|
var asbd = CMAudioFormatDescriptionGetStreamBasicDescription(fmtDesc)?.pointee,
|
||||||
|
let fmt = AVAudioFormat(streamDescription: &asbd) else { return nil }
|
||||||
|
let frames = AVAudioFrameCount(CMSampleBufferGetNumSamples(sb))
|
||||||
|
guard frames > 0, let buf = AVAudioPCMBuffer(pcmFormat: fmt, frameCapacity: frames) else { return nil }
|
||||||
|
buf.frameLength = frames
|
||||||
|
let status = CMSampleBufferCopyPCMDataIntoAudioBufferList(
|
||||||
|
sb, at: 0, frameCount: Int32(frames), into: buf.mutableAudioBufferList)
|
||||||
|
return status == noErr ? buf : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reuse the converter while the input format is stable; rebuild if ReplayKit changes it.
|
||||||
|
private func converter(for inFmt: AVAudioFormat) -> AVAudioConverter? {
|
||||||
|
if let converter, inputFormat == inFmt { return converter }
|
||||||
|
inputFormat = inFmt
|
||||||
|
converter = AVAudioConverter(from: inFmt, to: outputFormat)
|
||||||
|
return converter
|
||||||
|
}
|
||||||
|
|
||||||
|
private func postDarwin(_ name: String) {
|
||||||
|
CFNotificationCenterPostNotification(
|
||||||
|
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||||
|
CFNotificationName(name as CFString), nil, nil, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
608
clients/apple/iOS/VoiceCatiOS.xcodeproj/project.pbxproj
Normal file
608
clients/apple/iOS/VoiceCatiOS.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,608 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 60;
|
||||||
|
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 */; };
|
||||||
|
BBBB00000000000000000037 /* ServerListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000001E /* ServerListView.swift */; };
|
||||||
|
BBBB00000000000000000038 /* AddServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000001F /* AddServerView.swift */; };
|
||||||
|
BBBB00000000000000000039 /* ServerIdentityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000020 /* ServerIdentityView.swift */; };
|
||||||
|
BBBB0000000000000000003A /* PasswordPromptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000021 /* PasswordPromptView.swift */; };
|
||||||
|
BBBB0000000000000000003B /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000022 /* MainView.swift */; };
|
||||||
|
BBBB0000000000000000003C /* ChannelTreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000023 /* ChannelTreeView.swift */; };
|
||||||
|
BBBB0000000000000000003D /* UserListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000024 /* UserListView.swift */; };
|
||||||
|
BBBB0000000000000000003E /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000025 /* ChatView.swift */; };
|
||||||
|
BBBB00000000000000000070 /* ChannelBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000060 /* ChannelBrowserView.swift */; };
|
||||||
|
BBBB00000000000000000071 /* ChannelDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000061 /* ChannelDetailView.swift */; };
|
||||||
|
BBBB00000000000000000072 /* UserRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000062 /* UserRow.swift */; };
|
||||||
|
BBBB00000000000000000040 /* VoiceControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000027 /* VoiceControlsView.swift */; };
|
||||||
|
BBBB00000000000000000041 /* PerUserTuningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000028 /* PerUserTuningView.swift */; };
|
||||||
|
BBBB00000000000000000042 /* ChannelEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000029 /* ChannelEditView.swift */; };
|
||||||
|
BBBB00000000000000000043 /* BanUserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002A /* BanUserView.swift */; };
|
||||||
|
BBBB00000000000000000044 /* MoveUserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002B /* MoveUserView.swift */; };
|
||||||
|
BBBB00000000000000000045 /* PermissionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002C /* PermissionsView.swift */; };
|
||||||
|
BBBB00000000000000000046 /* AccountsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002D /* AccountsView.swift */; };
|
||||||
|
BBBB00000000000000000047 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002E /* SettingsView.swift */; };
|
||||||
|
BBBB0000000000000000004B /* IOSAudioRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002F /* IOSAudioRouter.swift */; };
|
||||||
|
BBBB00000000000000000048 /* VoiceCatCore in Frameworks */ = {isa = PBXBuildFile; productRef = BBBB0000000000000000004A /* VoiceCatCore */; };
|
||||||
|
CCCC00000000000000000010 /* BroadcastAudioPump.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCCC00000000000000000002 /* BroadcastAudioPump.swift */; };
|
||||||
|
CCCC00000000000000000011 /* BroadcastAudioRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCCC00000000000000000001 /* BroadcastAudioRing.swift */; };
|
||||||
|
CCCC00000000000000000012 /* SampleHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCCC00000000000000000003 /* SampleHandler.swift */; };
|
||||||
|
CCCC00000000000000000013 /* BroadcastAudioRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCCC00000000000000000001 /* BroadcastAudioRing.swift */; };
|
||||||
|
CCCC00000000000000000014 /* VoiceCatBroadcast.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = CCCC00000000000000000006 /* VoiceCatBroadcast.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
CCCC00000000000000000036 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = BBBB00000000000000000001 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = CCCC00000000000000000030 /* VoiceCatBroadcast */;
|
||||||
|
remoteInfo = VoiceCatBroadcast;
|
||||||
|
};
|
||||||
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
CCCC00000000000000000035 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = CCCC00000000000000000030 /* VoiceCatBroadcast */;
|
||||||
|
targetProxy = CCCC00000000000000000036 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
BBBB00000000000000000012 /* VoiceCatiOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VoiceCatiOS.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
BBBB00000000000000000015 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000016 /* VoiceCatiOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = VoiceCatiOS.entitlements; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000017 /* VoiceCatiOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceCatiOSApp.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000018 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000019 /* SessionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionState.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB0000000000000000001A /* AudioSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionManager.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB0000000000000000001B /* ServerListStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListStore.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB0000000000000000001C /* SavedServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedServer.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB0000000000000000001E /* ServerListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB0000000000000000001F /* AddServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddServerView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000020 /* ServerIdentityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerIdentityView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000021 /* PasswordPromptView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordPromptView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000022 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000023 /* ChannelTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelTreeView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000024 /* UserListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000025 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000060 /* ChannelBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelBrowserView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000061 /* ChannelDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelDetailView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000062 /* UserRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRow.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000027 /* VoiceControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceControlsView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000028 /* PerUserTuningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerUserTuningView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000029 /* ChannelEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelEditView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB0000000000000000002A /* BanUserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BanUserView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB0000000000000000002B /* MoveUserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveUserView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB0000000000000000002C /* PermissionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB0000000000000000002D /* AccountsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB0000000000000000002E /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB0000000000000000002F /* IOSAudioRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSAudioRouter.swift; sourceTree = "<group>"; };
|
||||||
|
CCCC00000000000000000001 /* BroadcastAudioRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastAudioRing.swift; sourceTree = "<group>"; };
|
||||||
|
CCCC00000000000000000002 /* BroadcastAudioPump.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastAudioPump.swift; sourceTree = "<group>"; };
|
||||||
|
CCCC00000000000000000003 /* SampleHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleHandler.swift; sourceTree = "<group>"; };
|
||||||
|
CCCC00000000000000000004 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
CCCC00000000000000000005 /* VoiceCatBroadcast.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = VoiceCatBroadcast.entitlements; sourceTree = "<group>"; };
|
||||||
|
CCCC00000000000000000006 /* VoiceCatBroadcast.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = VoiceCatBroadcast.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
|
CCCC00000000000000000037 /* Embed Foundation Extensions */ = {
|
||||||
|
isa = PBXCopyFilesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
dstPath = "";
|
||||||
|
dstSubfolderSpec = 13;
|
||||||
|
files = (
|
||||||
|
CCCC00000000000000000014 /* VoiceCatBroadcast.appex in Embed Foundation Extensions */,
|
||||||
|
);
|
||||||
|
name = "Embed Foundation Extensions";
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
BBBB00000000000000000011 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
BBBB00000000000000000048 /* VoiceCatCore in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
BBBB00000000000000000002 = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
BBBB00000000000000000003 /* VoiceCatiOS */,
|
||||||
|
CCCC00000000000000000021 /* VoiceCatBroadcast */,
|
||||||
|
CCCC00000000000000000020 /* Shared */,
|
||||||
|
BBBB00000000000000000007 /* Products */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
BBBB00000000000000000003 /* VoiceCatiOS */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
BBBB00000000000000000015 /* Info.plist */,
|
||||||
|
BBBB00000000000000000016 /* VoiceCatiOS.entitlements */,
|
||||||
|
BBBB00000000000000000017 /* VoiceCatiOSApp.swift */,
|
||||||
|
BBBB00000000000000000018 /* AppState.swift */,
|
||||||
|
BBBB00000000000000000019 /* SessionState.swift */,
|
||||||
|
BBBB0000000000000000001A /* AudioSessionManager.swift */,
|
||||||
|
BBBB0000000000000000002F /* IOSAudioRouter.swift */,
|
||||||
|
BBBB0000000000000000001B /* ServerListStore.swift */,
|
||||||
|
BBBB0000000000000000001C /* SavedServer.swift */,
|
||||||
|
CCCC00000000000000000002 /* BroadcastAudioPump.swift */,
|
||||||
|
BBBB00000000000000000006 /* Views */,
|
||||||
|
);
|
||||||
|
path = VoiceCatiOS;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
CCCC00000000000000000020 /* Shared */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
CCCC00000000000000000001 /* BroadcastAudioRing.swift */,
|
||||||
|
);
|
||||||
|
path = Shared;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
CCCC00000000000000000021 /* VoiceCatBroadcast */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
CCCC00000000000000000003 /* SampleHandler.swift */,
|
||||||
|
CCCC00000000000000000004 /* Info.plist */,
|
||||||
|
CCCC00000000000000000005 /* VoiceCatBroadcast.entitlements */,
|
||||||
|
);
|
||||||
|
path = VoiceCatBroadcast;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
BBBB00000000000000000006 /* Views */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
BBBB0000000000000000001E /* ServerListView.swift */,
|
||||||
|
BBBB0000000000000000001F /* AddServerView.swift */,
|
||||||
|
BBBB00000000000000000020 /* ServerIdentityView.swift */,
|
||||||
|
BBBB00000000000000000021 /* PasswordPromptView.swift */,
|
||||||
|
BBBB00000000000000000022 /* MainView.swift */,
|
||||||
|
BBBB00000000000000000023 /* ChannelTreeView.swift */,
|
||||||
|
BBBB00000000000000000024 /* UserListView.swift */,
|
||||||
|
BBBB00000000000000000025 /* ChatView.swift */,
|
||||||
|
BBBB00000000000000000060 /* ChannelBrowserView.swift */,
|
||||||
|
BBBB00000000000000000061 /* ChannelDetailView.swift */,
|
||||||
|
BBBB00000000000000000062 /* UserRow.swift */,
|
||||||
|
BBBB00000000000000000027 /* VoiceControlsView.swift */,
|
||||||
|
BBBB00000000000000000028 /* PerUserTuningView.swift */,
|
||||||
|
BBBB00000000000000000029 /* ChannelEditView.swift */,
|
||||||
|
BBBB0000000000000000002A /* BanUserView.swift */,
|
||||||
|
BBBB0000000000000000002B /* MoveUserView.swift */,
|
||||||
|
BBBB0000000000000000002C /* PermissionsView.swift */,
|
||||||
|
BBBB0000000000000000002D /* AccountsView.swift */,
|
||||||
|
BBBB0000000000000000002E /* SettingsView.swift */,
|
||||||
|
);
|
||||||
|
path = Views;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
BBBB00000000000000000007 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
BBBB00000000000000000012 /* VoiceCatiOS.app */,
|
||||||
|
CCCC00000000000000000006 /* VoiceCatBroadcast.appex */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
BBBB00000000000000000008 /* VoiceCatiOS */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = BBBB0000000000000000000A /* Build configuration list for PBXNativeTarget "VoiceCatiOS" */;
|
||||||
|
buildPhases = (
|
||||||
|
BBBB0000000000000000000F /* Sources */,
|
||||||
|
BBBB00000000000000000010 /* Resources */,
|
||||||
|
BBBB00000000000000000011 /* Frameworks */,
|
||||||
|
CCCC00000000000000000037 /* Embed Foundation Extensions */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
CCCC00000000000000000035 /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
name = VoiceCatiOS;
|
||||||
|
packageProductDependencies = (
|
||||||
|
BBBB0000000000000000004A /* VoiceCatCore */,
|
||||||
|
);
|
||||||
|
productName = VoiceCatiOS;
|
||||||
|
productReference = BBBB00000000000000000012 /* VoiceCatiOS.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
CCCC00000000000000000030 /* VoiceCatBroadcast */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = CCCC00000000000000000032 /* Build configuration list for PBXNativeTarget "VoiceCatBroadcast" */;
|
||||||
|
buildPhases = (
|
||||||
|
CCCC00000000000000000031 /* Sources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = VoiceCatBroadcast;
|
||||||
|
productName = VoiceCatBroadcast;
|
||||||
|
productReference = CCCC00000000000000000006 /* VoiceCatBroadcast.appex */;
|
||||||
|
productType = "com.apple.product-type.app-extension";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
BBBB00000000000000000001 /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = 1;
|
||||||
|
LastSwiftUpdateCheck = 1500;
|
||||||
|
LastUpgradeCheck = 1500;
|
||||||
|
};
|
||||||
|
buildConfigurationList = BBBB00000000000000000009 /* Build configuration list for PBXProject "VoiceCatiOS" */;
|
||||||
|
compatibilityVersion = "Xcode 14.0";
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
en,
|
||||||
|
Base,
|
||||||
|
);
|
||||||
|
mainGroup = BBBB00000000000000000002;
|
||||||
|
packageReferences = (
|
||||||
|
BBBB00000000000000000049 /* XCLocalSwiftPackageReference "../" */,
|
||||||
|
);
|
||||||
|
productRefGroup = BBBB00000000000000000007 /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
BBBB00000000000000000008 /* VoiceCatiOS */,
|
||||||
|
CCCC00000000000000000030 /* VoiceCatBroadcast */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
BBBB00000000000000000010 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
BBBB0000000000000000000F /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
BBBB00000000000000000030 /* VoiceCatiOSApp.swift in Sources */,
|
||||||
|
BBBB00000000000000000031 /* AppState.swift in Sources */,
|
||||||
|
BBBB00000000000000000032 /* SessionState.swift in Sources */,
|
||||||
|
BBBB00000000000000000033 /* AudioSessionManager.swift in Sources */,
|
||||||
|
BBBB0000000000000000004B /* IOSAudioRouter.swift in Sources */,
|
||||||
|
BBBB00000000000000000034 /* ServerListStore.swift in Sources */,
|
||||||
|
BBBB00000000000000000035 /* SavedServer.swift in Sources */,
|
||||||
|
CCCC00000000000000000010 /* BroadcastAudioPump.swift in Sources */,
|
||||||
|
CCCC00000000000000000011 /* BroadcastAudioRing.swift in Sources */,
|
||||||
|
BBBB00000000000000000037 /* ServerListView.swift in Sources */,
|
||||||
|
BBBB00000000000000000038 /* AddServerView.swift in Sources */,
|
||||||
|
BBBB00000000000000000039 /* ServerIdentityView.swift in Sources */,
|
||||||
|
BBBB0000000000000000003A /* PasswordPromptView.swift in Sources */,
|
||||||
|
BBBB0000000000000000003B /* MainView.swift in Sources */,
|
||||||
|
BBBB0000000000000000003C /* ChannelTreeView.swift in Sources */,
|
||||||
|
BBBB0000000000000000003D /* UserListView.swift in Sources */,
|
||||||
|
BBBB0000000000000000003E /* ChatView.swift in Sources */,
|
||||||
|
BBBB00000000000000000070 /* ChannelBrowserView.swift in Sources */,
|
||||||
|
BBBB00000000000000000071 /* ChannelDetailView.swift in Sources */,
|
||||||
|
BBBB00000000000000000072 /* UserRow.swift in Sources */,
|
||||||
|
BBBB00000000000000000040 /* VoiceControlsView.swift in Sources */,
|
||||||
|
BBBB00000000000000000041 /* PerUserTuningView.swift in Sources */,
|
||||||
|
BBBB00000000000000000042 /* ChannelEditView.swift in Sources */,
|
||||||
|
BBBB00000000000000000043 /* BanUserView.swift in Sources */,
|
||||||
|
BBBB00000000000000000044 /* MoveUserView.swift in Sources */,
|
||||||
|
BBBB00000000000000000045 /* PermissionsView.swift in Sources */,
|
||||||
|
BBBB00000000000000000046 /* AccountsView.swift in Sources */,
|
||||||
|
BBBB00000000000000000047 /* SettingsView.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
CCCC00000000000000000031 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
CCCC00000000000000000012 /* SampleHandler.swift in Sources */,
|
||||||
|
CCCC00000000000000000013 /* BroadcastAudioRing.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
BBBB0000000000000000000B /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"DEBUG=1",
|
||||||
|
"$(inherited)",
|
||||||
|
);
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
BBBB0000000000000000000C /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
BBBB0000000000000000000D /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = VoiceCatiOS/VoiceCatiOS.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = FJV8L966W4;
|
||||||
|
INFOPLIST_FILE = VoiceCatiOS/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = VoiceCat;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 0.0.1;
|
||||||
|
OTHER_LDFLAGS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"-lc++",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = 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 = FJV8L966W4;
|
||||||
|
INFOPLIST_FILE = VoiceCatiOS/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = VoiceCat;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 0.0.1;
|
||||||
|
OTHER_LDFLAGS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"-lc++",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = cat.voice.VoiceCatiOS;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.9;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
CCCC00000000000000000033 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CODE_SIGN_ENTITLEMENTS = VoiceCatBroadcast/VoiceCatBroadcast.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = FJV8L966W4;
|
||||||
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
|
INFOPLIST_FILE = VoiceCatBroadcast/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 0.0.1;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = cat.voice.VoiceCatiOS.broadcast;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
SWIFT_VERSION = 5.9;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
CCCC00000000000000000034 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CODE_SIGN_ENTITLEMENTS = VoiceCatBroadcast/VoiceCatBroadcast.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = FJV8L966W4;
|
||||||
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
|
INFOPLIST_FILE = VoiceCatBroadcast/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 0.0.1;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = cat.voice.VoiceCatiOS.broadcast;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
SWIFT_VERSION = 5.9;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
BBBB00000000000000000009 /* Build configuration list for PBXProject "VoiceCatiOS" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
BBBB0000000000000000000B /* Debug */,
|
||||||
|
BBBB0000000000000000000C /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
BBBB0000000000000000000A /* Build configuration list for PBXNativeTarget "VoiceCatiOS" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
BBBB0000000000000000000D /* Debug */,
|
||||||
|
BBBB0000000000000000000E /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
CCCC00000000000000000032 /* Build configuration list for PBXNativeTarget "VoiceCatBroadcast" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
CCCC00000000000000000033 /* Debug */,
|
||||||
|
CCCC00000000000000000034 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
|
BBBB00000000000000000049 /* XCLocalSwiftPackageReference "../" */ = {
|
||||||
|
isa = XCLocalSwiftPackageReference;
|
||||||
|
relativePath = ../;
|
||||||
|
};
|
||||||
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
BBBB0000000000000000004A /* VoiceCatCore */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = BBBB00000000000000000049 /* XCLocalSwiftPackageReference "../" */;
|
||||||
|
productName = VoiceCatCore;
|
||||||
|
};
|
||||||
|
/* End XCSwiftPackageProductDependency section */
|
||||||
|
};
|
||||||
|
rootObject = BBBB00000000000000000001 /* Project object */;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
189
clients/apple/iOS/VoiceCatiOS/AppState.swift
Normal file
189
clients/apple/iOS/VoiceCatiOS/AppState.swift
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
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?.stopMicStream()
|
||||||
|
session?.client.disconnect()
|
||||||
|
AudioSessionManager.shared.deactivateSession()
|
||||||
|
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)
|
||||||
|
connectingClient = nil
|
||||||
|
isConnecting = false
|
||||||
|
connectStatus = ""
|
||||||
|
showPasswordPrompt = false
|
||||||
|
self.session = newSession
|
||||||
|
// Activate the audio session now, while connected — NOT lazily when the first
|
||||||
|
// remote stream arrives. The core opens its miniaudio playback device the moment
|
||||||
|
// a remote stream starts and only THEN emits .streamStarted; if we waited for
|
||||||
|
// that event to activate, the playback device would open against an inactive
|
||||||
|
// AVAudioSession and produce no sound (the "can't hear anyone" bug). Activating
|
||||||
|
// here guarantees the session is live before any device opens.
|
||||||
|
do {
|
||||||
|
try AudioSessionManager.shared.ensureSessionActive()
|
||||||
|
} catch {
|
||||||
|
print("Audio session activate on connect failed: \(error)")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
connectStatus = "Auth failed: \(ev.result.description)"
|
||||||
|
showPasswordPrompt = true
|
||||||
|
}
|
||||||
|
case .disconnected:
|
||||||
|
if session == nil { cancelConnect() }
|
||||||
|
else {
|
||||||
|
AudioSessionManager.shared.deactivateSession()
|
||||||
|
session = nil; isConnecting = false
|
||||||
|
}
|
||||||
|
case .error:
|
||||||
|
connectStatus = ev.text ?? "Unknown error"
|
||||||
|
if session == nil { isConnecting = false }
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
180
clients/apple/iOS/VoiceCatiOS/AudioSessionManager.swift
Normal file
180
clients/apple/iOS/VoiceCatiOS/AudioSessionManager.swift
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import os
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "AudioSessionManager")
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AudioSessionManager {
|
||||||
|
static let shared = AudioSessionManager()
|
||||||
|
|
||||||
|
weak var client: VoiceCatClient?
|
||||||
|
|
||||||
|
/// The stream ID of the currently active local MIC stream, if any. Set by `SessionState`
|
||||||
|
/// when the user joins/leaves voice so `IOSAudioRouter` can reset the core's capture
|
||||||
|
/// channel count (e.g. when switching stereo → mono) without going through `SessionState`.
|
||||||
|
var activeMicStreamId: UInt32?
|
||||||
|
|
||||||
|
/// Tracks whether WE activated the session. The session must be active whenever the
|
||||||
|
/// AudioEngine is running (for capture OR playback), so it is activated when any audio
|
||||||
|
/// needs to play (a remote stream started OR the user joins voice) and only deactivated
|
||||||
|
/// when disconnecting from the server — not when leaving voice, since the user may still
|
||||||
|
/// want to hear remote audio.
|
||||||
|
private var isSessionActive = false
|
||||||
|
|
||||||
|
func configure() {
|
||||||
|
// Load stored audio routing preferences and apply them before any audio session
|
||||||
|
// activation. IOSAudioRouter drives all iOS audio route selection via AVAudioSession;
|
||||||
|
// miniaudio (the core) does NOT touch AVAudioSession on iOS.
|
||||||
|
IOSAudioRouter.shared.loadStoredPreferences()
|
||||||
|
IOSAudioRouter.shared.applyConfiguration()
|
||||||
|
IOSAudioRouter.shared.refreshRoutes()
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self, selector: #selector(handleInterruption),
|
||||||
|
name: AVAudioSession.interruptionNotification, object: nil)
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self, selector: #selector(handleRouteChange),
|
||||||
|
name: AVAudioSession.routeChangeNotification, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Activate the AVAudioSession if not already active. Call before any audio I/O:
|
||||||
|
/// when the user joins voice, or when a remote stream starts (so playback works even
|
||||||
|
/// before the user has joined voice). Idempotent — safe to call multiple times.
|
||||||
|
func ensureSessionActive() throws {
|
||||||
|
guard !isSessionActive else {
|
||||||
|
logger.debug("ensureSessionActive — already active, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
IOSAudioRouter.shared.applyConfiguration()
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
try session.setActive(true, options: [])
|
||||||
|
isSessionActive = true
|
||||||
|
// For the A2DP output presets, make sure output isn't pinned to the built-in speaker.
|
||||||
|
// A2DP routing in .playAndRecord is fragile; clearing any speaker override after the
|
||||||
|
// session is live nudges iOS to honor the Bluetooth output route.
|
||||||
|
if IOSAudioRouter.shared.wantsA2dpOutput {
|
||||||
|
try? session.overrideOutputAudioPort(.none)
|
||||||
|
}
|
||||||
|
let route = AVAudioSession.sharedInstance().currentRoute
|
||||||
|
let outputNames = route.outputs.map { $0.portName }.joined(separator: ", ")
|
||||||
|
let inputNames = route.inputs.map { $0.portName }.joined(separator: ", ")
|
||||||
|
logger.info("session activated — outputs: [\(outputNames)], inputs: [\(inputNames)]")
|
||||||
|
logSessionState("after activate")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deactivate the AVAudioSession. Call ONLY when disconnecting from the server — not
|
||||||
|
/// when leaving voice (the user may still want to hear remote audio).
|
||||||
|
func deactivateSession() {
|
||||||
|
guard isSessionActive else {
|
||||||
|
logger.debug("deactivateSession — not active, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try? AVAudioSession.sharedInstance().setActive(false,
|
||||||
|
options: .notifyOthersOnDeactivation)
|
||||||
|
isSessionActive = false
|
||||||
|
logger.info("session deactivated")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log the full AVAudioSession state — category, mode, options, and active route.
|
||||||
|
/// Useful for diagnosing routing issues, e.g. confirming the session stays
|
||||||
|
/// `PlayAndRecord` with `allowBluetoothA2DP` and keeps the A2DP output route even
|
||||||
|
/// after the mic engine starts.
|
||||||
|
func logSessionState(_ when: String) {
|
||||||
|
let s = AVAudioSession.sharedInstance()
|
||||||
|
var opts: [String] = []
|
||||||
|
let o = s.categoryOptions
|
||||||
|
if o.contains(.mixWithOthers) { opts.append("mixWithOthers") }
|
||||||
|
if o.contains(.duckOthers) { opts.append("duckOthers") }
|
||||||
|
if o.contains(.allowBluetoothHFP) { opts.append("allowBluetoothHFP") }
|
||||||
|
if o.contains(.allowBluetoothA2DP) { opts.append("allowBluetoothA2DP") }
|
||||||
|
if o.contains(.allowAirPlay) { opts.append("allowAirPlay") }
|
||||||
|
if o.contains(.defaultToSpeaker) { opts.append("defaultToSpeaker") }
|
||||||
|
let outs = s.currentRoute.outputs.map { "\($0.portName)[\($0.portType.rawValue)]" }
|
||||||
|
.joined(separator: ", ")
|
||||||
|
let ins = s.currentRoute.inputs.map { "\($0.portName)[\($0.portType.rawValue)]" }
|
||||||
|
.joined(separator: ", ")
|
||||||
|
logger.info("""
|
||||||
|
[SESSION @ \(when, privacy: .public)] category=\(s.category.rawValue, privacy: .public) \
|
||||||
|
mode=\(s.mode.rawValue, privacy: .public) options=[\(opts.joined(separator: ","), privacy: .public)] \
|
||||||
|
inputs=[\(ins, privacy: .public)] outputs=[\(outs, privacy: .public)] \
|
||||||
|
inputCh=\(s.inputNumberOfChannels) outputCh=\(s.outputNumberOfChannels)
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleInterruption(_ notification: Notification) {
|
||||||
|
guard let info = notification.userInfo,
|
||||||
|
let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||||
|
let type = AVAudioSession.InterruptionType(rawValue: typeValue)
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
switch type {
|
||||||
|
case .began:
|
||||||
|
logger.info("interruption began — session suspended by system")
|
||||||
|
isSessionActive = false // system deactivated us
|
||||||
|
client?.audioSuspend()
|
||||||
|
case .ended:
|
||||||
|
let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
|
||||||
|
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
||||||
|
if options.contains(.shouldResume) {
|
||||||
|
do {
|
||||||
|
try AVAudioSession.sharedInstance().setActive(true)
|
||||||
|
isSessionActive = true
|
||||||
|
logger.info("interruption ended — session reactivated")
|
||||||
|
client?.audioResume()
|
||||||
|
} catch {
|
||||||
|
logger.error("interruption ended — reactivation failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@unknown default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleRouteChange(_ notification: Notification) {
|
||||||
|
guard let info = notification.userInfo,
|
||||||
|
let reasonValue = info[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
||||||
|
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue)
|
||||||
|
else {
|
||||||
|
logger.warning("routeChange — unknown reason, refreshing only")
|
||||||
|
IOSAudioRouter.shared.refreshRoutes()
|
||||||
|
NotificationCenter.default.post(name: .voiceCatDeviceListChanged, object: nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("routeChange reason=\(self.reasonLabel(reason))")
|
||||||
|
|
||||||
|
// Re-apply preferences ONLY on external device plug/unplug. Do NOT re-apply on
|
||||||
|
// .categoryChange / .routeConfigurationChange — those are triggered by our own
|
||||||
|
// applyConfiguration() calls (setCategory, setPreferredInput, etc.), and re-applying
|
||||||
|
// would create an infinite notification loop:
|
||||||
|
// handleRouteChange → applyConfiguration → setCategory → routeChange → ...
|
||||||
|
// That loop burns CPU and cycles the audio session on/off — the "glitching" bug.
|
||||||
|
// IOSAudioRouter.applyConfiguration() also has a re-entrancy guard for synchronous
|
||||||
|
// notifications, but the reason check here is the primary defense.
|
||||||
|
if reason == .oldDeviceUnavailable || reason == .newDeviceAvailable {
|
||||||
|
logger.info("routeChange — external device change, re-applying config")
|
||||||
|
IOSAudioRouter.shared.applyConfiguration()
|
||||||
|
}
|
||||||
|
|
||||||
|
IOSAudioRouter.shared.refreshRoutes()
|
||||||
|
NotificationCenter.default.post(name: .voiceCatDeviceListChanged, object: nil)
|
||||||
|
logSessionState("route change (\(reasonLabel(reason)))")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reasonLabel(_ reason: AVAudioSession.RouteChangeReason) -> String {
|
||||||
|
switch reason {
|
||||||
|
case .oldDeviceUnavailable: return "oldDeviceUnavailable"
|
||||||
|
case .newDeviceAvailable: return "newDeviceAvailable"
|
||||||
|
case .categoryChange: return "categoryChange"
|
||||||
|
case .override: return "override"
|
||||||
|
case .wakeFromSleep: return "wakeFromSleep"
|
||||||
|
case .noSuitableRouteForCategory: return "noSuitableRouteForCategory"
|
||||||
|
case .routeConfigurationChange: return "routeConfigurationChange"
|
||||||
|
@unknown default: return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
static let voiceCatDeviceListChanged = Notification.Name("cat.voice.deviceListChanged")
|
||||||
|
}
|
||||||
159
clients/apple/iOS/VoiceCatiOS/BroadcastAudioPump.swift
Normal file
159
clients/apple/iOS/VoiceCatiOS/BroadcastAudioPump.swift
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// BroadcastAudioPump — host side of iOS screen-audio sharing (docs/voice.md §9, iOS detail).
|
||||||
|
//
|
||||||
|
// Drains the App Group shared-memory ring written by the broadcast upload extension and hands
|
||||||
|
// whole 20 ms frames to a feed closure (which calls VoiceCatClient.feedPcm). The host app owns
|
||||||
|
// the SCREEN_AUDIO stream, so screen audio appears as a second stream of the SAME user — exactly
|
||||||
|
// like the Windows/macOS desktop-audio share, and a single session (no separate connection).
|
||||||
|
//
|
||||||
|
// It reacts to the extension's Darwin notifications for prompt start/stop, and the drain timer
|
||||||
|
// also watches the ring's active flag as a safety net if a notification is missed. The ring
|
||||||
|
// always carries stereo; we downmix to mono when the stream's effective config is mono.
|
||||||
|
//
|
||||||
|
// Not @MainActor: the drain runs on a background queue (feedPcm is thread-safe). The start/stop
|
||||||
|
// callbacks are dispatched to main so they can drive @MainActor SessionState.
|
||||||
|
final class BroadcastAudioPump {
|
||||||
|
|
||||||
|
/// The host should start the SCREEN_AUDIO stream (a broadcast became active).
|
||||||
|
var onBroadcastStarted: (() -> Void)?
|
||||||
|
/// The host should stop the SCREEN_AUDIO stream (the broadcast ended).
|
||||||
|
var onBroadcastFinished: (() -> Void)?
|
||||||
|
|
||||||
|
private static let frameSamplesPerChannel = 960 // 20 ms @ 48 kHz
|
||||||
|
|
||||||
|
private let queue = DispatchQueue(label: "cat.voice.broadcast.pump")
|
||||||
|
private var ring: BroadcastAudioRing?
|
||||||
|
private var timer: DispatchSourceTimer?
|
||||||
|
private var feed: ((UnsafePointer<Int16>, Int, UInt32) -> Void)?
|
||||||
|
private var streamChannels = 1
|
||||||
|
private var ringChannels = 2
|
||||||
|
private var pending: [Int16] = [] // interleaved at ringChannels width
|
||||||
|
private var scratch = [Int16](repeating: 0, count: 8192)
|
||||||
|
private var started = false
|
||||||
|
|
||||||
|
// MARK: - Lifecycle (host connect/disconnect)
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard !started else { return }
|
||||||
|
ring = try? BroadcastAudioRing()
|
||||||
|
started = true
|
||||||
|
registerDarwin()
|
||||||
|
// Host reconnected while a broadcast is still running — pick it up.
|
||||||
|
if ring?.isActive == true { onBroadcastStarted?() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
guard started else { return }
|
||||||
|
started = false
|
||||||
|
unregisterDarwin()
|
||||||
|
endFeeding()
|
||||||
|
ring = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Feeding (driven by the host once the stream is live)
|
||||||
|
|
||||||
|
/// Begin draining the ring into `feed`. Called after the SCREEN_AUDIO stream's
|
||||||
|
/// `.streamStarted` event, when its effective channel count is known.
|
||||||
|
func beginFeeding(streamChannels: UInt32,
|
||||||
|
feed: @escaping (UnsafePointer<Int16>, Int, UInt32) -> Void) {
|
||||||
|
queue.async {
|
||||||
|
self.streamChannels = max(1, min(2, Int(streamChannels)))
|
||||||
|
self.ringChannels = max(1, Int(self.ring?.channels ?? 2))
|
||||||
|
self.feed = feed
|
||||||
|
self.pending.removeAll(keepingCapacity: true)
|
||||||
|
self.ring?.drainStale() // discard pre-roll buffered before we were ready
|
||||||
|
self.startTimer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func endFeeding() {
|
||||||
|
queue.async {
|
||||||
|
self.timer?.cancel()
|
||||||
|
self.timer = nil
|
||||||
|
self.feed = nil
|
||||||
|
self.pending.removeAll(keepingCapacity: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Drain
|
||||||
|
|
||||||
|
private func startTimer() {
|
||||||
|
let t = DispatchSource.makeTimerSource(queue: queue)
|
||||||
|
t.schedule(deadline: .now(), repeating: .milliseconds(10), leeway: .milliseconds(2))
|
||||||
|
t.setEventHandler { [weak self] in self?.drain() }
|
||||||
|
timer = t
|
||||||
|
t.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func drain() {
|
||||||
|
guard let ring, let feed else { return }
|
||||||
|
// Safety net: broadcast ended but we missed the Darwin note.
|
||||||
|
if !ring.isActive {
|
||||||
|
DispatchQueue.main.async { [weak self] in self?.onBroadcastFinished?() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
while true {
|
||||||
|
let got = scratch.withUnsafeMutableBufferPointer { ring.read(into: $0) }
|
||||||
|
if got == 0 { break }
|
||||||
|
pending.append(contentsOf: scratch[0..<got])
|
||||||
|
if got < scratch.count { break }
|
||||||
|
}
|
||||||
|
emitFrames(feed)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func emitFrames(_ feed: (UnsafePointer<Int16>, Int, UInt32) -> Void) {
|
||||||
|
let n = Self.frameSamplesPerChannel
|
||||||
|
let rc = ringChannels
|
||||||
|
let sc = streamChannels
|
||||||
|
let inFrame = n * rc
|
||||||
|
while pending.count >= inFrame {
|
||||||
|
if sc == rc {
|
||||||
|
pending.withUnsafeBufferPointer { feed($0.baseAddress!, n, UInt32(sc)) }
|
||||||
|
} else if sc == 1 && rc == 2 {
|
||||||
|
var mono = [Int16](repeating: 0, count: n)
|
||||||
|
pending.withUnsafeBufferPointer { buf in
|
||||||
|
let p = buf.baseAddress!
|
||||||
|
for i in 0..<n { mono[i] = Int16((Int(p[i * 2]) + Int(p[i * 2 + 1])) / 2) }
|
||||||
|
}
|
||||||
|
mono.withUnsafeBufferPointer { feed($0.baseAddress!, n, 1) }
|
||||||
|
} else if sc == 2 && rc == 1 {
|
||||||
|
var stereo = [Int16](repeating: 0, count: n * 2)
|
||||||
|
pending.withUnsafeBufferPointer { buf in
|
||||||
|
let p = buf.baseAddress!
|
||||||
|
for i in 0..<n { stereo[i * 2] = p[i]; stereo[i * 2 + 1] = p[i] }
|
||||||
|
}
|
||||||
|
stereo.withUnsafeBufferPointer { feed($0.baseAddress!, n, 2) }
|
||||||
|
}
|
||||||
|
pending.removeFirst(inFrame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Darwin notifications
|
||||||
|
|
||||||
|
private func registerDarwin() {
|
||||||
|
let observer = Unmanaged.passUnretained(self).toOpaque()
|
||||||
|
let center = CFNotificationCenterGetDarwinNotifyCenter()
|
||||||
|
let callback: CFNotificationCallback = { _, observer, name, _, _ in
|
||||||
|
guard let observer, let name else { return }
|
||||||
|
let pump = Unmanaged<BroadcastAudioPump>.fromOpaque(observer).takeUnretainedValue()
|
||||||
|
let raw = name.rawValue as String
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if raw == BroadcastNotification.started { pump.onBroadcastStarted?() }
|
||||||
|
else if raw == BroadcastNotification.finished { pump.onBroadcastFinished?() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CFNotificationCenterAddObserver(center, observer, callback,
|
||||||
|
BroadcastNotification.started as CFString, nil, .deliverImmediately)
|
||||||
|
CFNotificationCenterAddObserver(center, observer, callback,
|
||||||
|
BroadcastNotification.finished as CFString, nil, .deliverImmediately)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func unregisterDarwin() {
|
||||||
|
CFNotificationCenterRemoveEveryObserver(
|
||||||
|
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||||
|
Unmanaged.passUnretained(self).toOpaque())
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit { if started { unregisterDarwin() } }
|
||||||
|
}
|
||||||
663
clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift
Normal file
663
clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift
Normal file
@@ -0,0 +1,663 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import os
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "IOSAudioRouter")
|
||||||
|
|
||||||
|
/// iOS audio routing layer — drives all iOS audio route selection via `AVAudioSession`
|
||||||
|
/// *before* the core (miniaudio) opens its device. This class is the sole owner of the
|
||||||
|
/// session: miniaudio does NOT touch `AVAudioSession` on iOS, because the core opens its
|
||||||
|
/// devices through a `ma_context` configured with `sessionCategory = none` +
|
||||||
|
/// `noAudioSessionActivate/Deactivate` (see `AudioEngine::make_context_config` in
|
||||||
|
/// `core/src/audio/audio_engine.cpp`). Without that, miniaudio's default path resets the
|
||||||
|
/// category to `Record`/`Playback` with no options on every device open, wiping
|
||||||
|
/// `.allowBluetoothA2DP`/`.playAndRecord` and killing headphone/A2DP output — so that
|
||||||
|
/// config must stay in place. All iOS audio routing (input port selection, mic
|
||||||
|
/// orientation/polar patterns, HFP vs A2DP, measurement/raw mode, stereo capture) must be
|
||||||
|
/// driven from here.
|
||||||
|
///
|
||||||
|
/// The three user-facing choices:
|
||||||
|
/// 1. **Input port** — which physical input (built-in mic, Bluetooth HFP, headset,
|
||||||
|
/// USB, AirPlay). For the built-in mic, a sub-selection of **data source**
|
||||||
|
/// (orientation: front/back/top/bottom) and **polar pattern**
|
||||||
|
/// (omni/cardioid/subcardioid/bidirectional).
|
||||||
|
/// 2. **Bluetooth mode** — how Bluetooth headsets are handled:
|
||||||
|
/// - "BT HFP voice" (`.allowBluetoothHFP` + `.allowBluetoothA2DP`): both profiles
|
||||||
|
/// allowed, iOS picks HFP for two-way mic or A2DP for output-only. Mono, AEC on.
|
||||||
|
/// - "Built-in Mic + BT A2DP stereo" (`.allowBluetoothA2DP` only): stereo output,
|
||||||
|
/// built-in mic, no HFP processing.
|
||||||
|
/// - "Built-in Mic + Speaker" (neither): no Bluetooth at all.
|
||||||
|
/// 3. **Mic processing mode** — Standard (`.voiceChat`: AEC/AGC/HPF on) or
|
||||||
|
/// Raw/Studio (`.measurement`: all processing off). Raw mode is allowed always
|
||||||
|
/// but shows a warning when the output route is the speaker (echo risk, no AEC).
|
||||||
|
///
|
||||||
|
/// Additionally, **stereo capture** (2-channel built-in mic) is enabled by switching the
|
||||||
|
/// built-in mic's data source to the `.stereo` polar pattern. The recipe is:
|
||||||
|
/// `setPreferredDataSource(.stereo source)` + `setPreferredPolarPattern(.stereo)` +
|
||||||
|
/// `setPreferredInput(built-in mic)` + `setInputDataSource(stereo source)`. The channel
|
||||||
|
/// count itself must NOT be requested via `setPreferredInputNumberOfChannels(2)` — that
|
||||||
|
/// session-level call collapses the A2DP output route. Instead the core is told to open the
|
||||||
|
/// device with 2 channels via `vc_set_capture_channels(streamId, 2)`, and the AVAudioSession
|
||||||
|
/// input anchor (`setPreferredInput` + `setInputDataSource`) keeps the route stable during
|
||||||
|
/// the HFP→A2DP and mono→stereo reconfigurations.
|
||||||
|
///
|
||||||
|
/// Voice Isolation / Wide Spectrum (iOS 17+/18+) are user-toggleable in Control Center
|
||||||
|
/// for `.voiceChat` apps — surfaced as a hint, not a programmatic toggle.
|
||||||
|
///
|
||||||
|
/// All choices are persisted in `UserDefaults` and re-applied on route changes.
|
||||||
|
@MainActor
|
||||||
|
final class IOSAudioRouter: ObservableObject {
|
||||||
|
|
||||||
|
static let shared = IOSAudioRouter()
|
||||||
|
|
||||||
|
// MARK: - Published state (drives SettingsView)
|
||||||
|
|
||||||
|
@Published var inputPorts: [IOSAudioInputPort] = []
|
||||||
|
@Published var outputRoutes: [IOSAudioOutputRoute] = []
|
||||||
|
@Published var bluetoothMode: BluetoothMode = .btHfpVoice
|
||||||
|
@Published var micMode: MicMode = .standard
|
||||||
|
@Published var captureChannels: CaptureChannels = .mono
|
||||||
|
@Published var selectedInputPortId: String?
|
||||||
|
@Published var selectedDataSourceId: String?
|
||||||
|
@Published var selectedPolarPattern: String?
|
||||||
|
@Published var showsRawModeSpeakerWarning: Bool = false
|
||||||
|
@Published var showsA2dpNoAecWarning: Bool = false
|
||||||
|
@Published var hasBluetoothDevice: Bool = false
|
||||||
|
@Published var hasWiredHeadset: Bool = false
|
||||||
|
|
||||||
|
/// Audio presets — sensible combinations of settings for common scenarios.
|
||||||
|
/// The app is about choice: users can pick a preset for a quick start, then
|
||||||
|
/// fine-tune individual settings under "Advanced Audio".
|
||||||
|
enum AudioPreset: String, CaseIterable, Identifiable {
|
||||||
|
/// Standard iOS VoIP experience: AEC/AGC/HPF on, mono, system picks best route
|
||||||
|
/// (BT HFP if connected, wired if connected, speaker if nothing). Always available.
|
||||||
|
case voiceChat = "Voice Chat"
|
||||||
|
/// Stereo built-in mic capture (front+back capsules). A2DP output if BT is connected,
|
||||||
|
/// else built-in speaker / wired. Standard processing (no AEC — stereo needs a non-VPIO
|
||||||
|
/// mode). Always available.
|
||||||
|
case stereoMic = "Stereo Mic"
|
||||||
|
/// Maximum fidelity: stereo mic, no AEC/AGC/HPF (raw mode). A2DP output if BT connected,
|
||||||
|
/// else speaker/wired. Always available. Echo risk on speaker.
|
||||||
|
case studio = "Studio (No Processing)"
|
||||||
|
/// Bluetooth HFP: BT mic + BT output, AEC on, mono. Only when BT is connected.
|
||||||
|
case bluetoothHeadset = "Bluetooth Headset (HFP)"
|
||||||
|
/// A2DP stereo output + built-in mono mic, AEC off. Only when BT is connected. (For
|
||||||
|
/// A2DP output + stereo mic, use the Stereo Mic preset while BT is connected.)
|
||||||
|
case btHeadphonesMonoMic = "BT Headphones + Mono Mic"
|
||||||
|
/// Wired headset/earpods: wired output + wired mic (or built-in), AEC on, mono.
|
||||||
|
/// Only when a wired audio device is connected.
|
||||||
|
case wiredHeadset = "Wired Headset"
|
||||||
|
/// Settings don't match any preset — user has tweaked advanced controls.
|
||||||
|
case custom = "Custom"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var requiresBluetooth: Bool {
|
||||||
|
switch self {
|
||||||
|
case .bluetoothHeadset, .btHeadphonesMonoMic: return true
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var requiresWired: Bool {
|
||||||
|
self == .wiredHeadset
|
||||||
|
}
|
||||||
|
|
||||||
|
var bluetoothMode: BluetoothMode {
|
||||||
|
switch self {
|
||||||
|
case .voiceChat, .bluetoothHeadset: return .btHfpVoice
|
||||||
|
// A2DP output when BT is connected; falls back to speaker/wired when it isn't.
|
||||||
|
case .stereoMic, .studio, .btHeadphonesMonoMic: return .builtInMicBtA2dp
|
||||||
|
case .wiredHeadset: return .builtInMicSpeaker
|
||||||
|
case .custom: return .builtInMicSpeaker // placeholder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var captureChannels: CaptureChannels {
|
||||||
|
switch self {
|
||||||
|
case .stereoMic, .studio: return .stereo
|
||||||
|
default: return .mono
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var micMode: MicMode {
|
||||||
|
switch self {
|
||||||
|
case .studio: return .raw
|
||||||
|
default: return .standard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this preset explicitly selects the built-in mic port.
|
||||||
|
var usesBuiltInMic: Bool {
|
||||||
|
switch self {
|
||||||
|
case .stereoMic, .studio, .btHeadphonesMonoMic: return true
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BluetoothMode: String, CaseIterable, Identifiable {
|
||||||
|
case btHfpVoice = "BT HFP Voice"
|
||||||
|
case builtInMicBtA2dp = "Built-in Mic + BT A2DP"
|
||||||
|
case builtInMicSpeaker = "Built-in Mic + Speaker"
|
||||||
|
var id: String { rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MicMode: String, CaseIterable, Identifiable {
|
||||||
|
case standard = "Standard"
|
||||||
|
case raw = "Raw / Studio"
|
||||||
|
var id: String { rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CaptureChannels: String, CaseIterable, Identifiable {
|
||||||
|
case mono = "Mono"
|
||||||
|
case stereo = "Stereo"
|
||||||
|
var id: String { rawValue }
|
||||||
|
var channelCount: UInt32 { self == .stereo ? 2 : 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UserDefaults keys
|
||||||
|
|
||||||
|
private let kBluetoothMode = "cat.voice.audio.bluetoothMode"
|
||||||
|
private let kMicMode = "cat.voice.audio.micMode"
|
||||||
|
private let kCaptureChannels = "cat.voice.audio.captureChannels"
|
||||||
|
private let kInputPortId = "cat.voice.audio.inputPortId"
|
||||||
|
private let kDataSourceId = "cat.voice.audio.dataSourceId"
|
||||||
|
private let kPolarPattern = "cat.voice.audio.polarPattern"
|
||||||
|
private let kPreset = "cat.voice.audio.preset"
|
||||||
|
|
||||||
|
/// Re-entrancy guard: setCategory/setPreferredInput/etc. trigger route-change
|
||||||
|
/// notifications synchronously on the same thread. Without this guard,
|
||||||
|
/// handleRouteChange → applyConfiguration → setCategory → route-change notification
|
||||||
|
/// → handleRouteChange → applyConfiguration → ... creates an infinite loop that
|
||||||
|
/// burns CPU and cycles the audio session on/off (the "glitching" bug).
|
||||||
|
private var isApplyingConfiguration = false
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Load / refresh from AVAudioSession
|
||||||
|
|
||||||
|
/// Refresh the published input port list and output route list from the current
|
||||||
|
/// AVAudioSession state. Call after any route change or when the settings view appears.
|
||||||
|
func refreshRoutes() {
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
let currentInput = session.preferredInput
|
||||||
|
let currentDataSource = currentInput?.preferredDataSource?.dataSourceID ?? nil
|
||||||
|
let currentPolarPattern = currentInput?.preferredDataSource?.preferredPolarPattern?.rawValue
|
||||||
|
|
||||||
|
inputPorts = (session.availableInputs ?? []).map { port in
|
||||||
|
let dataSources = port.dataSources?.map { ds in
|
||||||
|
IOSAudioDataSource(
|
||||||
|
id: String(describing: ds.dataSourceID),
|
||||||
|
name: ds.dataSourceName,
|
||||||
|
polarPatterns: ds.supportedPolarPatterns?.map { $0.rawValue },
|
||||||
|
isSelected: currentDataSource == ds.dataSourceID,
|
||||||
|
selectedPolarPattern: currentPolarPattern
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return IOSAudioInputPort(
|
||||||
|
id: port.uid,
|
||||||
|
name: port.portName,
|
||||||
|
portType: port.portType.rawValue,
|
||||||
|
dataSources: dataSources,
|
||||||
|
isSelected: currentInput?.uid == port.uid
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
outputRoutes = session.currentRoute.outputs.map { port in
|
||||||
|
IOSAudioOutputRoute(
|
||||||
|
id: port.uid,
|
||||||
|
name: port.portName,
|
||||||
|
portType: port.portType.rawValue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if selectedInputPortId == nil {
|
||||||
|
selectedInputPortId = currentInput?.uid ?? inputPorts.first?.id
|
||||||
|
}
|
||||||
|
if selectedDataSourceId == nil {
|
||||||
|
selectedDataSourceId = currentDataSource.map { String(describing: $0) }
|
||||||
|
}
|
||||||
|
if selectedPolarPattern == nil {
|
||||||
|
selectedPolarPattern = currentPolarPattern
|
||||||
|
}
|
||||||
|
|
||||||
|
updateWarnings()
|
||||||
|
detectAudioDevices()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect connected audio devices — Bluetooth (A2DP/HFP) and wired (headphones,
|
||||||
|
/// headset mic, USB audio). Drives which presets are shown: BT presets only appear
|
||||||
|
/// when a BT device is connected, wired presets only when a wired device is connected.
|
||||||
|
/// This avoids confusing users with irrelevant options.
|
||||||
|
private func detectAudioDevices() {
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
let route = session.currentRoute
|
||||||
|
let inputs = session.availableInputs ?? []
|
||||||
|
|
||||||
|
// Bluetooth: check current route + available inputs
|
||||||
|
let hasBTOutput = route.outputs.contains {
|
||||||
|
$0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP
|
||||||
|
}
|
||||||
|
let hasBTInput = route.inputs.contains { $0.portType == .bluetoothHFP }
|
||||||
|
let hasBTAvailable = inputs.contains {
|
||||||
|
$0.portType == .bluetoothHFP || $0.portType == .bluetoothA2DP
|
||||||
|
}
|
||||||
|
let wasBT = hasBluetoothDevice
|
||||||
|
hasBluetoothDevice = hasBTOutput || hasBTInput || hasBTAvailable
|
||||||
|
if hasBluetoothDevice != wasBT {
|
||||||
|
logger.info("bluetooth device \(self.hasBluetoothDevice ? "connected" : "disconnected")")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wired: headphones, headset mic, USB audio (earpods, Lightning/USB-C headsets)
|
||||||
|
let hasWiredOutput = route.outputs.contains {
|
||||||
|
$0.portType == .headphones || $0.portType == .usbAudio
|
||||||
|
}
|
||||||
|
let hasWiredInput = route.inputs.contains {
|
||||||
|
$0.portType == .headsetMic || $0.portType == .usbAudio
|
||||||
|
}
|
||||||
|
let hasWiredAvailable = inputs.contains {
|
||||||
|
$0.portType == .headphones || $0.portType == .headsetMic || $0.portType == .usbAudio
|
||||||
|
}
|
||||||
|
let wasWired = hasWiredHeadset
|
||||||
|
hasWiredHeadset = hasWiredOutput || hasWiredInput || hasWiredAvailable
|
||||||
|
if hasWiredHeadset != wasWired {
|
||||||
|
logger.info("wired headset \(self.hasWiredHeadset ? "connected" : "disconnected")")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The presets available given the current device connection state.
|
||||||
|
/// Always includes Voice Chat, Stereo Mic, Studio, and Custom. BT presets only when
|
||||||
|
/// a Bluetooth device is connected. Wired preset only when a wired device is connected.
|
||||||
|
var availablePresets: [AudioPreset] {
|
||||||
|
AudioPreset.allCases.filter { preset in
|
||||||
|
if preset == .custom { return true }
|
||||||
|
if preset.requiresBluetooth && !hasBluetoothDevice { return false }
|
||||||
|
if preset.requiresWired && !hasWiredHeadset { return false }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which preset matches the current settings, or .custom if nothing matches.
|
||||||
|
/// Checks device-specific presets first (BT, wired) so that e.g. when BT is connected
|
||||||
|
/// and settings match "Bluetooth Headset", it returns that instead of the equivalent
|
||||||
|
/// "Voice Chat" (which has the same bluetoothMode/micMode/channels but is more general).
|
||||||
|
var activePreset: AudioPreset {
|
||||||
|
// Check device-specific presets first (most specific → least specific)
|
||||||
|
let order: [AudioPreset] = [
|
||||||
|
.bluetoothHeadset, .btHeadphonesMonoMic,
|
||||||
|
.wiredHeadset,
|
||||||
|
.voiceChat, .stereoMic, .studio,
|
||||||
|
]
|
||||||
|
for preset in order {
|
||||||
|
if bluetoothMode == preset.bluetoothMode
|
||||||
|
&& captureChannels == preset.captureChannels
|
||||||
|
&& micMode == preset.micMode {
|
||||||
|
// Don't match a BT preset if no BT is connected — fall through to Voice Chat
|
||||||
|
if preset.requiresBluetooth && !hasBluetoothDevice { continue }
|
||||||
|
if preset.requiresWired && !hasWiredHeadset { continue }
|
||||||
|
return preset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return .custom
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Apply configuration
|
||||||
|
|
||||||
|
/// Apply the full audio configuration to AVAudioSession. Call this before the core
|
||||||
|
/// opens its capture device (i.e. before `startMicStream` → `activateForStreaming`).
|
||||||
|
/// Re-entrant-safe: if a route-change notification fires synchronously during a
|
||||||
|
/// `setCategory`/`setPreferredInput` call, the guard prevents re-entry.
|
||||||
|
func applyConfiguration() {
|
||||||
|
guard !isApplyingConfiguration else {
|
||||||
|
logger.debug("applyConfiguration skipped — already applying (re-entrancy guard)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isApplyingConfiguration = true
|
||||||
|
defer { isApplyingConfiguration = false }
|
||||||
|
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
|
||||||
|
// 1. Build category options from bluetooth mode.
|
||||||
|
// .mixWithOthers is ALWAYS set — it keeps other audio (notably VoiceOver, which a
|
||||||
|
// blind user needs to operate the phone) audible while our session is active. Never
|
||||||
|
// drop it.
|
||||||
|
// .defaultToSpeaker is set ONLY for the speaker preset. It forces output to the
|
||||||
|
// built-in speaker instead of the receiver — but it also actively breaks A2DP
|
||||||
|
// routing in .playAndRecord, so it must NOT be set for the A2DP or HFP presets.
|
||||||
|
// .allowAirPlay is added to the Bluetooth presets so AirPlay output also works.
|
||||||
|
var options: AVAudioSession.CategoryOptions = [.mixWithOthers]
|
||||||
|
switch bluetoothMode {
|
||||||
|
case .btHfpVoice:
|
||||||
|
// Voice Chat: allow BOTH HFP and A2DP, let iOS pick the right profile for the
|
||||||
|
// connected device. HFP and A2DP must NOT be made mutually exclusive (HFP-only)
|
||||||
|
// — that blocks A2DP headphones from receiving audio. HFP is preferred (the system
|
||||||
|
// uses it when a two-way mic path is needed); A2DP stays available for output-only.
|
||||||
|
options.insert(.allowBluetoothHFP)
|
||||||
|
options.insert(.allowBluetoothA2DP)
|
||||||
|
options.insert(.allowAirPlay)
|
||||||
|
case .builtInMicBtA2dp:
|
||||||
|
// A2DP output only (no HFP). With HFP disabled the Bluetooth device can only be
|
||||||
|
// an OUTPUT (A2DP), so the system routes the mic to the built-in mic — exactly
|
||||||
|
// what we want for "built-in mic + A2DP output", in either mono OR stereo.
|
||||||
|
options.insert(.allowBluetoothA2DP)
|
||||||
|
options.insert(.allowAirPlay)
|
||||||
|
case .builtInMicSpeaker:
|
||||||
|
// Built-in mic + speaker/wired output only. Prefer speaker over the receiver.
|
||||||
|
options.insert(.defaultToSpeaker)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Set category + mode, chosen per scenario:
|
||||||
|
// - Stereo capture: .default — .voiceChat (the AEC/VPIO path) forces MONO, so stereo
|
||||||
|
// is only possible in a non-VPIO mode. .default supports multi-capsule stereo AND
|
||||||
|
// keeps the A2DP output route alive.
|
||||||
|
// - Mono raw/studio: .measurement — all system processing off.
|
||||||
|
// - Mono + A2DP output: .videoRecording — keeps A2DP output without VPIO (no AEC).
|
||||||
|
// - Mono standard (HFP or speaker): .voiceChat — hardware AEC/AGC/HPF.
|
||||||
|
let mode: AVAudioSession.Mode
|
||||||
|
if captureChannels == .stereo {
|
||||||
|
mode = .default
|
||||||
|
} else if micMode == .raw {
|
||||||
|
mode = .measurement
|
||||||
|
} else if bluetoothMode == .builtInMicBtA2dp {
|
||||||
|
mode = .videoRecording
|
||||||
|
} else {
|
||||||
|
mode = .voiceChat
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try session.setCategory(.playAndRecord, mode: mode, options: options)
|
||||||
|
logger.info("setCategory ok — mode=\(self.modeLabel(mode)), bt=\(self.bluetoothMode.rawValue), ch=\(self.captureChannels.rawValue), options=\(self.optionsLabel(options))")
|
||||||
|
} catch {
|
||||||
|
logger.error("setCategory failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Input & mic-capsule configuration.
|
||||||
|
if captureChannels == .stereo {
|
||||||
|
// Stereo: enable the built-in mic's .stereo polar pattern AND anchor the input
|
||||||
|
// route explicitly via setPreferredInput + setInputDataSource. With HFP disabled
|
||||||
|
// the system routes input to the built-in mic, but without the explicit
|
||||||
|
// preferred-input anchor the route can collapse during the mode switch
|
||||||
|
// (.voiceChat → .default) and the output dies. The channel count is requested by
|
||||||
|
// miniaudio at the audio-unit level (vc_set_capture_channels), NOT via
|
||||||
|
// setPreferredInputNumberOfChannels(2) — that call collapses the A2DP output route.
|
||||||
|
configureStereoCapture(session: session)
|
||||||
|
} else if let portId = selectedInputPortId, !portId.isEmpty,
|
||||||
|
let port = session.availableInputs?.first(where: { $0.uid == portId }) {
|
||||||
|
// Mono with an explicit input-port selection (advanced settings).
|
||||||
|
do {
|
||||||
|
try session.setPreferredInput(port)
|
||||||
|
logger.info("setPreferredInput ok — \(port.portName)")
|
||||||
|
} catch {
|
||||||
|
logger.error("setPreferredInput failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
configureMonoCapture(session: session, port: port)
|
||||||
|
} else {
|
||||||
|
// Mono, system-default input. Still clear any leftover .stereo capsule from a
|
||||||
|
// prior stereo session so we actually return to mono.
|
||||||
|
clearStereoPolarPattern(session: session)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateWarnings()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable 2-channel capture on the built-in mic. The recipe that achieves stereo mic +
|
||||||
|
/// A2DP Bluetooth output simultaneously:
|
||||||
|
/// 1. `setPreferredDataSource(stereoSource)` on the built-in mic port
|
||||||
|
/// 2. `setPreferredPolarPattern(.stereo)` on that data source
|
||||||
|
/// 3. `setPreferredInput(builtIn)` — anchor the input route explicitly. Without this
|
||||||
|
/// anchor the route can collapse during the mode switch (.voiceChat → .default).
|
||||||
|
/// 4. `setInputDataSource(stereoSource)` — commit the data source at the session level
|
||||||
|
/// The channel count itself is requested by miniaudio at the audio-unit level via
|
||||||
|
/// `vc_set_capture_channels(2)`. We must NOT call `setPreferredInputNumberOfChannels(2)`
|
||||||
|
/// — that session-level call collapses the A2DP output route.
|
||||||
|
private func configureStereoCapture(session: AVAudioSession) {
|
||||||
|
guard let builtIn = session.availableInputs?.first(where: { $0.portType == .builtInMic })
|
||||||
|
else {
|
||||||
|
logger.warning("stereo requested but no built-in mic available — staying mono")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let stereoSource = builtIn.dataSources?.first(where: {
|
||||||
|
$0.supportedPolarPatterns?.contains(.stereo) == true
|
||||||
|
}) else {
|
||||||
|
logger.warning("stereo requested but built-in mic has no .stereo data source — staying mono")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try builtIn.setPreferredDataSource(stereoSource)
|
||||||
|
try stereoSource.setPreferredPolarPattern(.stereo)
|
||||||
|
// Anchor the input route explicitly; without it the route can collapse during
|
||||||
|
// the mode switch (.voiceChat → .default) and the A2DP output dies.
|
||||||
|
try session.setPreferredInput(builtIn)
|
||||||
|
// Commit the data source at the session level. setPreferredDataSource alone only
|
||||||
|
// sets the port-level preference; setInputDataSource makes it the active source.
|
||||||
|
try session.setInputDataSource(stereoSource)
|
||||||
|
logger.info("stereo capsule enabled — source=\(stereoSource.dataSourceName), pattern=.stereo, input anchored")
|
||||||
|
} catch {
|
||||||
|
logger.error("stereo capsule setup failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure mono capture on an explicitly selected port: apply the user's chosen data source
|
||||||
|
/// (orientation) and polar pattern, resetting any prior `.stereo` pattern back to default.
|
||||||
|
private func configureMonoCapture(session: AVAudioSession, port: AVAudioSessionPortDescription) {
|
||||||
|
guard let dataSourceId = selectedDataSourceId, !dataSourceId.isEmpty,
|
||||||
|
let dataSource = port.dataSources?.first(where: {
|
||||||
|
String(describing: $0.dataSourceID) == dataSourceId
|
||||||
|
}) else {
|
||||||
|
// No explicit capsule choice — make sure we're not stuck on a prior .stereo pattern.
|
||||||
|
clearStereoPolarPattern(session: session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try port.setPreferredDataSource(dataSource)
|
||||||
|
logger.info("setPreferredDataSource ok — \(dataSource.dataSourceName)")
|
||||||
|
} catch {
|
||||||
|
logger.error("setPreferredDataSource failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let polarPattern = selectedPolarPattern, !polarPattern.isEmpty {
|
||||||
|
let pattern = AVAudioSession.PolarPattern(rawValue: polarPattern)
|
||||||
|
try? dataSource.setPreferredPolarPattern(pattern)
|
||||||
|
logger.info("setPreferredPolarPattern ok — \(polarPattern)")
|
||||||
|
} else {
|
||||||
|
// Clear any prior .stereo selection so mono capture returns to a mono capsule.
|
||||||
|
try? dataSource.setPreferredPolarPattern(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset any built-in-mic data source that's currently on the `.stereo` polar pattern back to
|
||||||
|
/// the default (mono) pattern. Used when switching from a stereo session back to mono with no
|
||||||
|
/// explicit capsule selection, so the prior stereo capsule doesn't linger.
|
||||||
|
private func clearStereoPolarPattern(session: AVAudioSession) {
|
||||||
|
guard let builtIn = session.availableInputs?.first(where: { $0.portType == .builtInMic })
|
||||||
|
else { return }
|
||||||
|
for ds in builtIn.dataSources ?? [] where ds.selectedPolarPattern == .stereo {
|
||||||
|
try? ds.setPreferredPolarPattern(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func modeLabel(_ mode: AVAudioSession.Mode) -> String {
|
||||||
|
switch mode {
|
||||||
|
case .voiceChat: return "voiceChat"
|
||||||
|
case .measurement: return "measurement"
|
||||||
|
case .videoRecording: return "videoRecording"
|
||||||
|
case .default: return "default"
|
||||||
|
default: return "other"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func optionsLabel(_ opts: AVAudioSession.CategoryOptions) -> String {
|
||||||
|
var parts: [String] = []
|
||||||
|
if opts.contains(.defaultToSpeaker) { parts.append("defaultToSpeaker") }
|
||||||
|
if opts.contains(.mixWithOthers) { parts.append("mixWithOthers") }
|
||||||
|
if opts.contains(.allowBluetoothHFP) { parts.append("allowBluetoothHFP") }
|
||||||
|
if opts.contains(.allowBluetoothA2DP) { parts.append("allowBluetoothA2DP") }
|
||||||
|
return parts.joined(separator: ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply stored preferences from UserDefaults. Called at app launch (before any
|
||||||
|
/// audio session activation).
|
||||||
|
func loadStoredPreferences() {
|
||||||
|
if let raw = UserDefaults.standard.string(forKey: kBluetoothMode),
|
||||||
|
let mode = BluetoothMode(rawValue: raw) {
|
||||||
|
bluetoothMode = mode
|
||||||
|
}
|
||||||
|
if let raw = UserDefaults.standard.string(forKey: kMicMode),
|
||||||
|
let mode = MicMode(rawValue: raw) {
|
||||||
|
micMode = mode
|
||||||
|
}
|
||||||
|
if let raw = UserDefaults.standard.string(forKey: kCaptureChannels),
|
||||||
|
let ch = CaptureChannels(rawValue: raw) {
|
||||||
|
captureChannels = ch
|
||||||
|
}
|
||||||
|
selectedInputPortId = UserDefaults.standard.string(forKey: kInputPortId)
|
||||||
|
selectedDataSourceId = UserDefaults.standard.string(forKey: kDataSourceId)
|
||||||
|
selectedPolarPattern = UserDefaults.standard.string(forKey: kPolarPattern)
|
||||||
|
}
|
||||||
|
/// Persist current selections to UserDefaults.
|
||||||
|
func savePreferences() {
|
||||||
|
UserDefaults.standard.set(bluetoothMode.rawValue, forKey: kBluetoothMode)
|
||||||
|
UserDefaults.standard.set(micMode.rawValue, forKey: kMicMode)
|
||||||
|
UserDefaults.standard.set(captureChannels.rawValue, forKey: kCaptureChannels)
|
||||||
|
UserDefaults.standard.set(selectedInputPortId, forKey: kInputPortId)
|
||||||
|
UserDefaults.standard.set(selectedDataSourceId, forKey: kDataSourceId)
|
||||||
|
UserDefaults.standard.set(selectedPolarPattern, forKey: kPolarPattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Selection setters (called from SettingsView pickers)
|
||||||
|
|
||||||
|
func selectInputPort(_ portId: String) {
|
||||||
|
selectedInputPortId = portId
|
||||||
|
selectedDataSourceId = nil
|
||||||
|
selectedPolarPattern = nil
|
||||||
|
savePreferences()
|
||||||
|
applyConfiguration()
|
||||||
|
refreshRoutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectDataSource(_ dataSourceId: String) {
|
||||||
|
selectedDataSourceId = dataSourceId
|
||||||
|
selectedPolarPattern = nil
|
||||||
|
savePreferences()
|
||||||
|
applyConfiguration()
|
||||||
|
refreshRoutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectPolarPattern(_ pattern: String) {
|
||||||
|
selectedPolarPattern = pattern
|
||||||
|
savePreferences()
|
||||||
|
applyConfiguration()
|
||||||
|
refreshRoutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectBluetoothMode(_ mode: BluetoothMode) {
|
||||||
|
bluetoothMode = mode
|
||||||
|
savePreferences()
|
||||||
|
applyConfiguration()
|
||||||
|
refreshRoutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectMicMode(_ mode: MicMode) {
|
||||||
|
micMode = mode
|
||||||
|
savePreferences()
|
||||||
|
applyConfiguration()
|
||||||
|
updateWarnings()
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectCaptureChannels(_ channels: CaptureChannels) {
|
||||||
|
captureChannels = channels
|
||||||
|
savePreferences()
|
||||||
|
applyConfiguration()
|
||||||
|
// Update the core's stored capture channel count (does not restart the engine).
|
||||||
|
if let streamId = AudioSessionManager.shared.activeMicStreamId {
|
||||||
|
_ = AudioSessionManager.shared.client?.setCaptureChannels(
|
||||||
|
streamId: streamId, channels: channels.channelCount)
|
||||||
|
}
|
||||||
|
// Restart the engine AFTER AVAudioSession routing has settled and the channel
|
||||||
|
// count is stored. The engine reopens playback first (committing the A2DP/output
|
||||||
|
// route), then capture — avoiding the race where stereo capture activation drops
|
||||||
|
// A2DP before the playback device has a chance to claim the route.
|
||||||
|
_ = AudioSessionManager.shared.client?.audioRestart()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Presets
|
||||||
|
|
||||||
|
/// Apply a preset — sets all individual audio settings to the preset's values, then
|
||||||
|
/// applies the configuration. For presets that use the built-in mic (A2DP presets),
|
||||||
|
/// finds the built-in mic port UID from availableInputs.
|
||||||
|
func applyPreset(_ preset: AudioPreset) {
|
||||||
|
guard preset != .custom else { return } // can't "apply" custom — it's a display state
|
||||||
|
|
||||||
|
bluetoothMode = preset.bluetoothMode
|
||||||
|
micMode = preset.micMode
|
||||||
|
captureChannels = preset.captureChannels
|
||||||
|
|
||||||
|
if preset.usesBuiltInMic {
|
||||||
|
// Find the built-in mic port from available inputs and select it.
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
if let builtInMic = (session.availableInputs ?? []).first(where: {
|
||||||
|
$0.portType == .builtInMic
|
||||||
|
}) {
|
||||||
|
selectedInputPortId = builtInMic.uid
|
||||||
|
}
|
||||||
|
// Don't set a specific data source — in stereo mode, iOS uses multiple mic
|
||||||
|
// capsules automatically. In mono, the default orientation is fine.
|
||||||
|
selectedDataSourceId = nil
|
||||||
|
selectedPolarPattern = nil
|
||||||
|
} else {
|
||||||
|
// For Default and Bluetooth Headset presets, let the system pick the input.
|
||||||
|
selectedInputPortId = nil
|
||||||
|
selectedDataSourceId = nil
|
||||||
|
selectedPolarPattern = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
UserDefaults.standard.set(preset.rawValue, forKey: kPreset)
|
||||||
|
savePreferences()
|
||||||
|
applyConfiguration()
|
||||||
|
// Update the core's stored capture channel count (does not restart the engine).
|
||||||
|
if let streamId = AudioSessionManager.shared.activeMicStreamId {
|
||||||
|
_ = AudioSessionManager.shared.client?.setCaptureChannels(
|
||||||
|
streamId: streamId, channels: preset.captureChannels.channelCount)
|
||||||
|
}
|
||||||
|
// Restart the engine AFTER AVAudioSession routing has settled and the channel
|
||||||
|
// count is stored. Playback opens first (commits A2DP route), then capture.
|
||||||
|
_ = AudioSessionManager.shared.client?.audioRestart()
|
||||||
|
refreshRoutes()
|
||||||
|
logger.info("applyPreset — \(preset.rawValue)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
/// Update warning indicators for the Settings UI.
|
||||||
|
private func updateWarnings() {
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
let outputIsSpeaker = session.currentRoute.outputs.contains { $0.portType == .builtInSpeaker }
|
||||||
|
// Raw/Studio mode + speaker = echo risk (no AEC in .measurement mode)
|
||||||
|
showsRawModeSpeakerWarning = (micMode == .raw && outputIsSpeaker)
|
||||||
|
// A2DP output runs without hardware AEC (the .voiceChat AEC path isn't available on an
|
||||||
|
// A2DP route). Applies to both mono and stereo A2DP. Stereo also has no AEC (it can't
|
||||||
|
// use .voiceChat at all), but the message is the same and the warning already shows when
|
||||||
|
// the bluetooth mode is A2DP.
|
||||||
|
showsA2dpNoAecWarning = (bluetoothMode == .builtInMicBtA2dp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the current configuration wants Bluetooth A2DP output. Used after session
|
||||||
|
/// activation to clear any lingering speaker override that would pin output to the speaker.
|
||||||
|
var wantsA2dpOutput: Bool { bluetoothMode == .builtInMicBtA2dp }
|
||||||
|
|
||||||
|
/// The selected input port object, if any.
|
||||||
|
var selectedPort: IOSAudioInputPort? {
|
||||||
|
inputPorts.first(where: { $0.id == selectedInputPortId })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The data sources of the selected input port, if it's the built-in mic.
|
||||||
|
var selectedPortDataSources: [IOSAudioDataSource]? {
|
||||||
|
selectedPort?.dataSources
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the selected input port is the built-in mic (has data sources / orientation).
|
||||||
|
var selectedPortIsBuiltInMic: Bool {
|
||||||
|
selectedPort?.portType == AVAudioSession.Port.builtInMic.rawValue
|
||||||
|
}
|
||||||
|
}
|
||||||
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)" }
|
||||||
|
}
|
||||||
92
clients/apple/iOS/VoiceCatiOS/ServerListStore.swift
Normal file
92
clients/apple/iOS/VoiceCatiOS/ServerListStore.swift
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
371
clients/apple/iOS/VoiceCatiOS/SessionState.swift
Normal file
371
clients/apple/iOS/VoiceCatiOS/SessionState.swift
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
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 serverMuted = false
|
||||||
|
var serverDeafened = false
|
||||||
|
var inputMode: VoiceCatInputMode = .voiceActivation
|
||||||
|
var vadThreshold: Float = 0.025
|
||||||
|
var level: Float = 0.0
|
||||||
|
var currentDeviceId: String?
|
||||||
|
var localStreamId: UInt32 = 0
|
||||||
|
var screenSharing = false
|
||||||
|
var screenStreamId: UInt32 = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SessionState
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class SessionState {
|
||||||
|
let client: VoiceCatClient
|
||||||
|
let selfUserId: UInt32
|
||||||
|
|
||||||
|
var channels: [Channel] = []
|
||||||
|
var users: [User] = []
|
||||||
|
var currentChannelId: UInt32 = 0
|
||||||
|
var messages: [ChatMessage] = []
|
||||||
|
var activityLog: [ActivityEntry] = []
|
||||||
|
var voiceState = VoiceState()
|
||||||
|
var permissions: Permissions
|
||||||
|
var accounts: [Account] = []
|
||||||
|
var devices: [Device] = []
|
||||||
|
|
||||||
|
/// Host side of iOS screen-audio sharing — drains the broadcast extension's App Group ring
|
||||||
|
/// and feeds the SCREEN_AUDIO stream this session owns. See BroadcastAudioPump.
|
||||||
|
private let broadcastPump = BroadcastAudioPump()
|
||||||
|
|
||||||
|
init(client: VoiceCatClient, selfUserId: UInt32, permissions: Permissions) {
|
||||||
|
self.client = client
|
||||||
|
self.selfUserId = selfUserId
|
||||||
|
self.permissions = permissions
|
||||||
|
AudioSessionManager.shared.client = client
|
||||||
|
refreshChannels()
|
||||||
|
refreshUsers()
|
||||||
|
syncSelfChannel()
|
||||||
|
refreshDevices()
|
||||||
|
client.onEvent = { [weak self] ev in
|
||||||
|
Task { @MainActor [weak self] in self?.handleEvent(ev) }
|
||||||
|
}
|
||||||
|
client.onLevel = { [weak self] _, rms in
|
||||||
|
Task { @MainActor [weak self] in self?.voiceState.level = rms }
|
||||||
|
}
|
||||||
|
broadcastPump.onBroadcastStarted = { [weak self] in self?.startScreenShare() }
|
||||||
|
broadcastPump.onBroadcastFinished = { [weak self] in self?.stopScreenShare() }
|
||||||
|
broadcastPump.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
broadcastPump.stop()
|
||||||
|
MainActor.assumeIsolated {
|
||||||
|
AudioSessionManager.shared.client = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Event dispatch
|
||||||
|
|
||||||
|
func handleEvent(_ ev: VoiceCatEvent) {
|
||||||
|
switch ev.type {
|
||||||
|
case .channelList:
|
||||||
|
refreshChannels()
|
||||||
|
syncSelfChannel()
|
||||||
|
case .userJoined, .userLeft:
|
||||||
|
refreshUsers()
|
||||||
|
syncSelfChannel()
|
||||||
|
case .userUpdated:
|
||||||
|
refreshUsers()
|
||||||
|
syncSelfChannel()
|
||||||
|
if let me = users.first(where: { $0.id == selfUserId }) {
|
||||||
|
applyServerMuteState(muted: me.serverMuted, deafened: me.serverDeafened)
|
||||||
|
}
|
||||||
|
case .textMessage:
|
||||||
|
let sender = users.first(where: { $0.id == ev.userId })?.nickname ?? "Unknown"
|
||||||
|
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:
|
||||||
|
// Our own SCREEN_AUDIO stream is live — begin draining the broadcast ring into it,
|
||||||
|
// in the stream's effective channel mode (downmix to mono if the channel is mono).
|
||||||
|
if ev.userId == selfUserId && ev.streamId == voiceState.screenStreamId {
|
||||||
|
let sid = voiceState.screenStreamId
|
||||||
|
let (r, cfg) = client.getStreamAudioConfig(userId: selfUserId, streamId: sid)
|
||||||
|
let channels: UInt32 = (r == .ok && cfg?.stereo == true) ? 2 : 1
|
||||||
|
let c = client
|
||||||
|
broadcastPump.beginFeeding(streamChannels: channels) { pcm, samples, ch in
|
||||||
|
c.feedPcm(streamId: sid, pcm: pcm, samplesPerChannel: samples, channels: ch)
|
||||||
|
}
|
||||||
|
addActivity("Sharing screen audio (\(channels == 2 ? "stereo" : "mono"))")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// A remote user started a stream — ensure the audio session is active so we can
|
||||||
|
// hear them even if we haven't joined voice ourselves.
|
||||||
|
if ev.userId != selfUserId {
|
||||||
|
do {
|
||||||
|
try AudioSessionManager.shared.ensureSessionActive()
|
||||||
|
} catch {
|
||||||
|
addActivity("Audio session activate failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AudioSessionManager.shared.logSessionState("stream started (user \(ev.userId))")
|
||||||
|
addActivity("Stream started (user \(ev.userId))")
|
||||||
|
case .streamStopped:
|
||||||
|
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: - Self-channel / server-mute sync
|
||||||
|
|
||||||
|
/// Sync currentChannelId from the self user's channelId in the user list. Mirrors macOS
|
||||||
|
/// MainWindowController.swift:461,491,522. The server auto-places every authed user into
|
||||||
|
/// the Lobby (channel 1) on connect, but without this sync currentChannelId stays 0 and
|
||||||
|
/// the mic button (gated on currentChannelId == 0) stays permanently dimmed.
|
||||||
|
private func syncSelfChannel() {
|
||||||
|
if let me = users.first(where: { $0.id == selfUserId }) {
|
||||||
|
currentChannelId = me.channelId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply server-side mute/deafen state — mirrors macOS MainWindowController.swift:693-700.
|
||||||
|
/// iOS was previously ignoring server mute/deafen entirely.
|
||||||
|
private func applyServerMuteState(muted: Bool, deafened: Bool) {
|
||||||
|
if muted && !voiceState.serverMuted { addActivity("You have been server-muted") }
|
||||||
|
if deafened && !voiceState.serverDeafened { addActivity("You have been server-deafened") }
|
||||||
|
if !muted && voiceState.serverMuted { addActivity("Server mute cleared") }
|
||||||
|
if !deafened && voiceState.serverDeafened { addActivity("Server deafen cleared") }
|
||||||
|
voiceState.serverMuted = muted
|
||||||
|
voiceState.serverDeafened = deafened
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data refresh
|
||||||
|
|
||||||
|
func refreshChannels() { channels = client.listChannels() }
|
||||||
|
func refreshUsers() { users = client.listUsers() }
|
||||||
|
func refreshDevices() { devices = client.listDevices(.input) }
|
||||||
|
|
||||||
|
// MARK: - Voice controls
|
||||||
|
|
||||||
|
func joinChannel(_ channelId: UInt32, password: String = "") {
|
||||||
|
client.joinChannel(channelId, password: password.isEmpty ? nil : password)
|
||||||
|
}
|
||||||
|
|
||||||
|
func leaveChannel() {
|
||||||
|
client.leaveChannel()
|
||||||
|
currentChannelId = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func 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.ensureSessionActive()
|
||||||
|
} 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
|
||||||
|
// Publish the active mic stream ID so IOSAudioRouter can reset the core's capture
|
||||||
|
// channel count when the user switches mono↔stereo (selectCaptureChannels /
|
||||||
|
// applyPreset). Without this, switching stereo→mono leaves the LocalStream's
|
||||||
|
// capture_channels field at 2 and the next engine start still opens stereo.
|
||||||
|
AudioSessionManager.shared.activeMicStreamId = streamId
|
||||||
|
// Store the user's capture channel selection before the server acknowledges
|
||||||
|
// the stream. The engine hasn't started yet at this point (it starts when
|
||||||
|
// handle_stream_announce_result fires), so vc_set_capture_channels just
|
||||||
|
// stores the value — no restart. ensure_audio_running() picks it up when
|
||||||
|
// the stream is confirmed and opens the device with the right channel count.
|
||||||
|
let channels = IOSAudioRouter.shared.captureChannels.channelCount
|
||||||
|
if channels != 1 {
|
||||||
|
client.setCaptureChannels(streamId: streamId, channels: channels)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
addActivity("Failed to start mic: \(result.description)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopMicStream() {
|
||||||
|
if voiceState.localStreamId != 0 {
|
||||||
|
client.stopStream(voiceState.localStreamId)
|
||||||
|
voiceState.localStreamId = 0
|
||||||
|
AudioSessionManager.shared.activeMicStreamId = nil
|
||||||
|
}
|
||||||
|
voiceState.micActive = false
|
||||||
|
voiceState.level = 0
|
||||||
|
// Do NOT deactivate the AVAudioSession here — the user may still want to hear
|
||||||
|
// remote audio (other people talking). The session is deactivated only when
|
||||||
|
// disconnecting from the server (see AppState.disconnect / .disconnected event).
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Screen audio share
|
||||||
|
|
||||||
|
/// Called when the broadcast extension becomes active. Announces the SCREEN_AUDIO stream;
|
||||||
|
/// feeding begins on the resulting `.streamStarted` event (see handleEvent). The actual
|
||||||
|
/// system-audio capture happens in the ReplayKit upload extension (a separate process).
|
||||||
|
private func startScreenShare() {
|
||||||
|
guard voiceState.screenStreamId == 0 else { return }
|
||||||
|
guard currentChannelId != 0 else {
|
||||||
|
addActivity("Screen audio ignored — join a channel first")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let (result, streamId) = client.startStream(
|
||||||
|
StreamDescriptor(kind: .screenAudio, deviceId: nil, label: "Screen audio"))
|
||||||
|
if result == .ok {
|
||||||
|
voiceState.screenStreamId = streamId
|
||||||
|
voiceState.screenSharing = true
|
||||||
|
addActivity("Screen audio share starting…")
|
||||||
|
} else {
|
||||||
|
addActivity("Failed to start screen audio: \(result.description)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called when the broadcast ends (or on disconnect). Stops feeding and the stream.
|
||||||
|
private func stopScreenShare() {
|
||||||
|
broadcastPump.endFeeding()
|
||||||
|
if voiceState.screenStreamId != 0 {
|
||||||
|
client.stopStream(voiceState.screenStreamId)
|
||||||
|
voiceState.screenStreamId = 0
|
||||||
|
}
|
||||||
|
if voiceState.screenSharing {
|
||||||
|
voiceState.screenSharing = false
|
||||||
|
addActivity("Stopped sharing screen audio")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setMute(_ muted: Bool, deafened: Bool) {
|
||||||
|
client.setSelfMute(micMuted: muted, deafened: deafened)
|
||||||
|
voiceState.selfMuted = muted
|
||||||
|
voiceState.selfDeafened = deafened
|
||||||
|
}
|
||||||
|
|
||||||
|
func setInputMode(_ mode: VoiceCatInputMode) {
|
||||||
|
client.setInputMode(mode)
|
||||||
|
voiceState.inputMode = mode
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
91
clients/apple/iOS/VoiceCatiOS/Views/ChannelBrowserView.swift
Normal file
91
clients/apple/iOS/VoiceCatiOS/Views/ChannelBrowserView.swift
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
/// iPhone channels tab: a drill-down browser. The root list shows only top-level channels;
|
||||||
|
/// tapping (or VoiceOver-activating) a channel pushes `ChannelDetailView`, which shows the
|
||||||
|
/// people in it and any sub-channels. Joining is an explicit action inside the detail view.
|
||||||
|
struct ChannelBrowserView: View {
|
||||||
|
@Bindable var session: SessionState
|
||||||
|
@State private var showCreateChannel = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
ForEach(rootChannels) { ch in
|
||||||
|
NavigationLink {
|
||||||
|
ChannelDetailView(channel: ch, session: session)
|
||||||
|
} label: {
|
||||||
|
ChannelRow(channel: ch, session: session)
|
||||||
|
}
|
||||||
|
.swipeActions(edge: .trailing) {
|
||||||
|
if session.permissions.isAdmin {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
session.deleteChannel(ch.id)
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Channels")
|
||||||
|
.toolbar {
|
||||||
|
if session.permissions.canCreateTempChannel || session.permissions.isAdmin {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button {
|
||||||
|
showCreateChannel = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Create channel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showCreateChannel) {
|
||||||
|
ChannelEditView(channelId: nil, session: session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var rootChannels: [Channel] {
|
||||||
|
session.channels
|
||||||
|
.filter { $0.parentId == 0 }
|
||||||
|
.sorted { $0.name < $1.name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared channel row used by the iPhone browser and detail views. Mirrors the look of the
|
||||||
|
/// iPad `ChannelTreeView` row but keyed on a `Channel` rather than a tree `ChannelNode`.
|
||||||
|
struct ChannelRow: View {
|
||||||
|
let channel: Channel
|
||||||
|
let session: SessionState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let isCurrent = session.currentChannelId == channel.id
|
||||||
|
let usersHere = session.users.filter { $0.channelId == channel.id }
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: channel.passwordProtected ? "lock.fill" : "number")
|
||||||
|
.foregroundStyle(isCurrent ? .blue : .secondary)
|
||||||
|
.imageScale(.small)
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(channel.name)
|
||||||
|
.fontWeight(isCurrent ? .semibold : .regular)
|
||||||
|
if !channel.topic.isEmpty {
|
||||||
|
Text(channel.topic)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if !usersHere.isEmpty {
|
||||||
|
Text("\(usersHere.count)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityLabel("\(channel.name)\(isCurrent ? ", current" : "")\(channel.passwordProtected ? ", password protected" : "")\(!usersHere.isEmpty ? ", \(usersHere.count) users" : "")")
|
||||||
|
}
|
||||||
|
}
|
||||||
88
clients/apple/iOS/VoiceCatiOS/Views/ChannelDetailView.swift
Normal file
88
clients/apple/iOS/VoiceCatiOS/Views/ChannelDetailView.swift
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
/// The drilled-into view for a single channel: a Join control, the people currently in the
|
||||||
|
/// channel, and any sub-channels (each drilling deeper via a nested `ChannelDetailView`).
|
||||||
|
struct ChannelDetailView: View {
|
||||||
|
let channel: Channel
|
||||||
|
@Bindable var session: SessionState
|
||||||
|
|
||||||
|
@State private var showPasswordPrompt = false
|
||||||
|
@State private var password = ""
|
||||||
|
|
||||||
|
private var isCurrent: Bool { session.currentChannelId == channel.id }
|
||||||
|
private var people: [User] { session.users.filter { $0.channelId == channel.id } }
|
||||||
|
private var subchannels: [Channel] {
|
||||||
|
session.channels.filter { $0.parentId == channel.id }.sorted { $0.name < $1.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
if isCurrent {
|
||||||
|
Label("You're here", systemImage: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
.accessibilityLabel("You are in this channel")
|
||||||
|
} else {
|
||||||
|
Button {
|
||||||
|
join()
|
||||||
|
} label: {
|
||||||
|
Label("Join Channel", systemImage: "arrow.right.circle.fill")
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Join \(channel.name)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("People") {
|
||||||
|
if people.isEmpty {
|
||||||
|
Text("No one here yet.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(people) { user in
|
||||||
|
UserRow(user: user, session: session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !subchannels.isEmpty {
|
||||||
|
Section("Channels") {
|
||||||
|
ForEach(subchannels) { sub in
|
||||||
|
NavigationLink {
|
||||||
|
ChannelDetailView(channel: sub, session: session)
|
||||||
|
} label: {
|
||||||
|
ChannelRow(channel: sub, session: session)
|
||||||
|
}
|
||||||
|
.swipeActions(edge: .trailing) {
|
||||||
|
if session.permissions.isAdmin {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
session.deleteChannel(sub.id)
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(channel.name)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.alert("Channel Password", isPresented: $showPasswordPrompt) {
|
||||||
|
SecureField("Password", text: $password)
|
||||||
|
.accessibilityLabel("Channel password")
|
||||||
|
Button("Join") {
|
||||||
|
session.joinChannel(channel.id, password: password)
|
||||||
|
password = ""
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) { password = "" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func join() {
|
||||||
|
if channel.passwordProtected {
|
||||||
|
showPasswordPrompt = true
|
||||||
|
} else {
|
||||||
|
session.joinChannel(channel.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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" : "")")
|
||||||
|
}
|
||||||
|
}
|
||||||
159
clients/apple/iOS/VoiceCatiOS/Views/ChatView.swift
Normal file
159
clients/apple/iOS/VoiceCatiOS/Views/ChatView.swift
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
/// One row of the combined chat/activity timeline. Mirrors the macOS/Windows clients, which
|
||||||
|
/// collapse chat messages and activity events into a single scrolling log (chat in normal
|
||||||
|
/// text, activity events in gray).
|
||||||
|
private enum TimelineItem: Identifiable {
|
||||||
|
case message(ChatMessage)
|
||||||
|
case activity(ActivityEntry)
|
||||||
|
|
||||||
|
var id: UUID {
|
||||||
|
switch self {
|
||||||
|
case .message(let m): return m.id
|
||||||
|
case .activity(let a): return a.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var timestamp: Date {
|
||||||
|
switch self {
|
||||||
|
case .message(let m): return m.timestamp
|
||||||
|
case .activity(let a): return a.timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChatView: View {
|
||||||
|
@Bindable var session: SessionState
|
||||||
|
@State private var composeText = ""
|
||||||
|
@State private var scope: VoiceCatTextScope = .channel
|
||||||
|
@State private var privateTargetId: UInt32 = 0
|
||||||
|
|
||||||
|
private var timeline: [TimelineItem] {
|
||||||
|
let merged = session.messages.map(TimelineItem.message)
|
||||||
|
+ session.activityLog.map(TimelineItem.activity)
|
||||||
|
return merged.sorted { $0.timestamp < $1.timestamp }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Combined chat + activity timeline
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
|
ForEach(timeline) { item in
|
||||||
|
switch item {
|
||||||
|
case .message(let msg):
|
||||||
|
ChatBubble(message: msg)
|
||||||
|
.id(item.id)
|
||||||
|
case .activity(let entry):
|
||||||
|
ActivityRow(entry: entry)
|
||||||
|
.id(item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.onChange(of: session.messages.count + session.activityLog.count) { _, _ in
|
||||||
|
if let last = timeline.last {
|
||||||
|
proxy.scrollTo(last.id, anchor: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Compose bar
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
TextField("Message…", text: $composeText, axis: .vertical)
|
||||||
|
.lineLimit(1...5)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.accessibilityLabel("Message text field")
|
||||||
|
.onSubmit { sendMessage() }
|
||||||
|
|
||||||
|
Button {
|
||||||
|
sendMessage()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.up.circle.fill")
|
||||||
|
.imageScale(.large)
|
||||||
|
}
|
||||||
|
.disabled(composeText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
|| session.currentChannelId == 0)
|
||||||
|
.accessibilityLabel("Send message")
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
.navigationTitle("Chat")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendMessage() {
|
||||||
|
let text = composeText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !text.isEmpty else { return }
|
||||||
|
session.sendText(text, scope: .channel)
|
||||||
|
composeText = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ChatBubble: View {
|
||||||
|
let message: ChatMessage
|
||||||
|
|
||||||
|
private var timeString: String {
|
||||||
|
let fmt = DateFormatter()
|
||||||
|
fmt.dateStyle = .none
|
||||||
|
fmt.timeStyle = .short
|
||||||
|
return fmt.string(from: message.timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(message.senderName)
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(timeString)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
Text(message.text)
|
||||||
|
.font(.body)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityLabel("\(message.senderName) at \(timeString): \(message.text)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A compact, gray activity row interleaved into the chat timeline (joins/leaves, talk state,
|
||||||
|
/// streams, server mute, etc.). Matches the "activity = gray" convention of the macOS/Windows
|
||||||
|
/// unified logs.
|
||||||
|
private struct ActivityRow: View {
|
||||||
|
let entry: ActivityEntry
|
||||||
|
|
||||||
|
private static let timeFormatter: DateFormatter = {
|
||||||
|
let fmt = DateFormatter()
|
||||||
|
fmt.dateStyle = .none
|
||||||
|
fmt.timeStyle = .short
|
||||||
|
return fmt
|
||||||
|
}()
|
||||||
|
|
||||||
|
private var timeString: String { Self.timeFormatter.string(from: entry.timestamp) }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top, spacing: 6) {
|
||||||
|
Text(timeString)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.monospacedDigit()
|
||||||
|
Text(entry.text)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityLabel("\(timeString): \(entry.text)")
|
||||||
|
}
|
||||||
|
}
|
||||||
79
clients/apple/iOS/VoiceCatiOS/Views/MainView.swift
Normal file
79
clients/apple/iOS/VoiceCatiOS/Views/MainView.swift
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MainView: View {
|
||||||
|
@Environment(AppState.self) private var appState
|
||||||
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let session = appState.session {
|
||||||
|
if sizeClass == .regular {
|
||||||
|
iPadMainView(session: session)
|
||||||
|
} else {
|
||||||
|
iPhoneMainView(session: session)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ServerListView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - iPhone layout: TabView
|
||||||
|
|
||||||
|
private struct iPhoneMainView: View {
|
||||||
|
let session: SessionState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TabView {
|
||||||
|
ChannelBrowserView(session: session)
|
||||||
|
.voiceControlsBar(session)
|
||||||
|
.tabItem {
|
||||||
|
Label("Channels", systemImage: "list.bullet.indent")
|
||||||
|
}
|
||||||
|
ChatView(session: session)
|
||||||
|
.voiceControlsBar(session)
|
||||||
|
.tabItem {
|
||||||
|
Label("Chat", systemImage: "message")
|
||||||
|
}
|
||||||
|
SettingsView(session: session)
|
||||||
|
.voiceControlsBar(session)
|
||||||
|
.tabItem {
|
||||||
|
Label("Settings", systemImage: "gear")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension View {
|
||||||
|
/// Pin the shared voice controls just above the tab bar, *inside each tab's content area*.
|
||||||
|
/// Applying this per-tab (rather than to the TabView itself) reserves layout space above
|
||||||
|
/// the tab bar — keeping ChatView's compose box visible and cooperating with keyboard
|
||||||
|
/// avoidance — without the bar covering the tab bar's buttons.
|
||||||
|
func voiceControlsBar(_ session: SessionState) -> some View {
|
||||||
|
safeAreaInset(edge: .bottom) {
|
||||||
|
VoiceControlsView(session: session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - iPad layout: NavigationSplitView
|
||||||
|
|
||||||
|
private struct iPadMainView: View {
|
||||||
|
let session: SessionState
|
||||||
|
@State private var columnVisibility = NavigationSplitViewVisibility.all
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||||
|
ChannelTreeView(session: session)
|
||||||
|
.navigationTitle("Channels")
|
||||||
|
} content: {
|
||||||
|
UserListView(session: session)
|
||||||
|
.navigationTitle("Users")
|
||||||
|
} detail: {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ChatView(session: session)
|
||||||
|
VoiceControlsView(session: session)
|
||||||
|
}
|
||||||
|
.navigationTitle("Chat")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
253
clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift
Normal file
253
clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AVKit
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
struct SettingsView: View {
|
||||||
|
@Environment(AppState.self) private var appState
|
||||||
|
@Bindable var session: SessionState
|
||||||
|
@StateObject private var router = IOSAudioRouter.shared
|
||||||
|
@State private var showAdvanced = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
// MARK: - Audio Preset
|
||||||
|
Section("Audio") {
|
||||||
|
Picker("Preset", selection: Binding(
|
||||||
|
get: { router.activePreset },
|
||||||
|
set: { preset in router.applyPreset(preset) }
|
||||||
|
)) {
|
||||||
|
ForEach(router.availablePresets) { preset in
|
||||||
|
Text(preset.rawValue).tag(preset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Audio preset")
|
||||||
|
|
||||||
|
if !router.hasBluetoothDevice && !router.hasWiredHeadset {
|
||||||
|
Text("Connect Bluetooth headphones or a wired headset for more presets.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityLabel("No external audio device connected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Advanced Audio
|
||||||
|
Section {
|
||||||
|
DisclosureGroup("Advanced Audio", isExpanded: $showAdvanced) {
|
||||||
|
// Input port picker (AVAudioSession.availableInputs)
|
||||||
|
Picker("Input Port", selection: Binding(
|
||||||
|
get: { router.selectedInputPortId ?? "" },
|
||||||
|
set: { id in
|
||||||
|
if !id.isEmpty { router.selectInputPort(id) }
|
||||||
|
}
|
||||||
|
)) {
|
||||||
|
Text("Default").tag("")
|
||||||
|
ForEach(router.inputPorts) { port in
|
||||||
|
Text(port.name).tag(port.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Audio input port selection")
|
||||||
|
|
||||||
|
// Built-in mic sub-options: orientation (data source) + polar pattern
|
||||||
|
if router.selectedPortIsBuiltInMic,
|
||||||
|
let dataSources = router.selectedPortDataSources,
|
||||||
|
!dataSources.isEmpty {
|
||||||
|
Picker("Mic Orientation", selection: Binding(
|
||||||
|
get: { router.selectedDataSourceId ?? "" },
|
||||||
|
set: { id in
|
||||||
|
if !id.isEmpty { router.selectDataSource(id) }
|
||||||
|
}
|
||||||
|
)) {
|
||||||
|
Text("Default").tag("")
|
||||||
|
ForEach(dataSources) { ds in
|
||||||
|
Text(ds.name).tag(ds.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Microphone orientation")
|
||||||
|
|
||||||
|
// Polar pattern sub-picker
|
||||||
|
if let selectedDs = dataSources.first(where: { $0.id == router.selectedDataSourceId }),
|
||||||
|
let patterns = selectedDs.polarPatterns,
|
||||||
|
!patterns.isEmpty {
|
||||||
|
Picker("Polar Pattern", selection: Binding(
|
||||||
|
get: { router.selectedPolarPattern ?? "" },
|
||||||
|
set: { pattern in
|
||||||
|
if !pattern.isEmpty { router.selectPolarPattern(pattern) }
|
||||||
|
}
|
||||||
|
)) {
|
||||||
|
Text("Default").tag("")
|
||||||
|
ForEach(patterns, id: \.self) { pattern in
|
||||||
|
Text(polarPatternLabel(pattern)).tag(pattern)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Microphone polar pattern")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mic processing mode: Standard vs Raw/Studio
|
||||||
|
Picker("Mic Mode", selection: Binding(
|
||||||
|
get: { router.micMode },
|
||||||
|
set: { router.selectMicMode($0) }
|
||||||
|
)) {
|
||||||
|
ForEach(IOSAudioRouter.MicMode.allCases) { mode in
|
||||||
|
Text(mode.rawValue).tag(mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Microphone processing mode")
|
||||||
|
|
||||||
|
if router.showsRawModeSpeakerWarning {
|
||||||
|
Label(
|
||||||
|
"Raw mode on speaker — echo risk (no AEC)",
|
||||||
|
systemImage: "exclamationmark.triangle.fill"
|
||||||
|
)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.font(.caption)
|
||||||
|
.accessibilityLabel("Warning: Raw mode with speaker output may cause echo")
|
||||||
|
}
|
||||||
|
|
||||||
|
if router.showsA2dpNoAecWarning {
|
||||||
|
Label(
|
||||||
|
"A2DP mode — no echo cancellation (hardware AEC unavailable)",
|
||||||
|
systemImage: "info.circle.fill"
|
||||||
|
)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
.font(.caption)
|
||||||
|
.accessibilityLabel("Info: A2DP output mode does not support hardware echo cancellation")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Capture channels: Mono vs Stereo
|
||||||
|
Picker("Channels", selection: Binding(
|
||||||
|
get: { router.captureChannels },
|
||||||
|
set: { router.selectCaptureChannels($0) }
|
||||||
|
)) {
|
||||||
|
ForEach(IOSAudioRouter.CaptureChannels.allCases) { ch in
|
||||||
|
Text(ch.rawValue).tag(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Capture channel count")
|
||||||
|
|
||||||
|
// Bluetooth mode
|
||||||
|
Picker("Bluetooth Mode", selection: Binding(
|
||||||
|
get: { router.bluetoothMode },
|
||||||
|
set: { router.selectBluetoothMode($0) }
|
||||||
|
)) {
|
||||||
|
ForEach(IOSAudioRouter.BluetoothMode.allCases) { mode in
|
||||||
|
Text(mode.rawValue).tag(mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Bluetooth audio mode")
|
||||||
|
|
||||||
|
// Current output route (read-only)
|
||||||
|
if !router.outputRoutes.isEmpty {
|
||||||
|
ForEach(router.outputRoutes) { route in
|
||||||
|
HStack {
|
||||||
|
Text(route.name)
|
||||||
|
Spacer()
|
||||||
|
Text(route.portType)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Current output: \(route.name)")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("No output route")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AirPlay button
|
||||||
|
HStack {
|
||||||
|
Text("AirPlay")
|
||||||
|
Spacer()
|
||||||
|
RoutePickerButton()
|
||||||
|
}
|
||||||
|
.accessibilityLabel("AirPlay output selector")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Voice
|
||||||
|
Section("Voice") {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Admin
|
||||||
|
if session.permissions.canAdminAccounts || session.permissions.isAdmin {
|
||||||
|
Section("Administration") {
|
||||||
|
NavigationLink("Manage Accounts") {
|
||||||
|
AccountsView(session: session)
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Manage server accounts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Server
|
||||||
|
Section("Server") {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
session.stopMicStream()
|
||||||
|
appState.disconnect()
|
||||||
|
} label: {
|
||||||
|
Label("Disconnect", systemImage: "phone.down")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Disconnect from server")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - About
|
||||||
|
Section("About") {
|
||||||
|
Text(VoiceCatClient.versionString)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityLabel("Version: \(VoiceCatClient.versionString)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Settings")
|
||||||
|
.onAppear {
|
||||||
|
router.refreshRoutes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human-readable label for AVAudioSession.PolarPattern raw values.
|
||||||
|
private func polarPatternLabel(_ rawValue: String) -> String {
|
||||||
|
switch rawValue {
|
||||||
|
case AVAudioSession.PolarPattern.omnidirectional.rawValue: return "Omnidirectional"
|
||||||
|
case AVAudioSession.PolarPattern.cardioid.rawValue: return "Cardioid"
|
||||||
|
case AVAudioSession.PolarPattern.subcardioid.rawValue: return "Subcardioid"
|
||||||
|
default: return rawValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SwiftUI wrapper for AVRoutePickerView (AVKit's UIView for AirPlay route selection).
|
||||||
|
private struct RoutePickerButton: UIViewRepresentable {
|
||||||
|
func makeUIView(context: Context) -> AVRoutePickerView {
|
||||||
|
let view = AVRoutePickerView()
|
||||||
|
view.tintColor = .systemBlue
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: AVRoutePickerView, context: Context) {}
|
||||||
|
}
|
||||||
26
clients/apple/iOS/VoiceCatiOS/Views/UserListView.swift
Normal file
26
clients/apple/iOS/VoiceCatiOS/Views/UserListView.swift
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
struct UserListView: View {
|
||||||
|
@Bindable var session: SessionState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let channelUsers = session.currentChannelId == 0
|
||||||
|
? session.users
|
||||||
|
: session.users.filter { $0.channelId == session.currentChannelId }
|
||||||
|
|
||||||
|
List(channelUsers) { user in
|
||||||
|
UserRow(user: user, session: session)
|
||||||
|
}
|
||||||
|
.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.")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
121
clients/apple/iOS/VoiceCatiOS/Views/UserRow.swift
Normal file
121
clients/apple/iOS/VoiceCatiOS/Views/UserRow.swift
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
/// A single user row with its admin context menu and the sheets those actions present.
|
||||||
|
/// Self-contained (owns its own sheet state) so it can be reused both by the iPad
|
||||||
|
/// `UserListView` middle column and by the iPhone `ChannelDetailView` drill-down.
|
||||||
|
struct UserRow: View {
|
||||||
|
let user: User
|
||||||
|
@Bindable var session: SessionState
|
||||||
|
|
||||||
|
@State private var activeSheet: ActiveSheet?
|
||||||
|
|
||||||
|
private enum ActiveSheet: Identifiable {
|
||||||
|
case tuning, ban, move, permissions
|
||||||
|
var id: Int { hashValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
UserRowView(user: user, isSelf: user.id == session.selfUserId)
|
||||||
|
.contextMenu { contextMenu }
|
||||||
|
.sheet(item: $activeSheet) { sheet in
|
||||||
|
switch sheet {
|
||||||
|
case .tuning: PerUserTuningView(user: user, session: session)
|
||||||
|
case .ban: BanUserView(user: user, session: session)
|
||||||
|
case .move: MoveUserView(user: user, session: session)
|
||||||
|
case .permissions: PermissionsView(user: user, session: session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var contextMenu: some View {
|
||||||
|
if user.id != session.selfUserId {
|
||||||
|
Button {
|
||||||
|
activeSheet = .tuning
|
||||||
|
} 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) {
|
||||||
|
activeSheet = .ban
|
||||||
|
} label: {
|
||||||
|
Label("Ban…", systemImage: "nosign")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if session.permissions.canMoveUsers || session.permissions.isAdmin {
|
||||||
|
Button {
|
||||||
|
activeSheet = .move
|
||||||
|
} 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 {
|
||||||
|
activeSheet = .permissions
|
||||||
|
} 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" : "")")
|
||||||
|
}
|
||||||
|
}
|
||||||
168
clients/apple/iOS/VoiceCatiOS/Views/VoiceControlsView.swift
Normal file
168
clients/apple/iOS/VoiceCatiOS/Views/VoiceControlsView.swift
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import VoiceCatCore
|
||||||
|
import ReplayKit
|
||||||
|
|
||||||
|
struct VoiceControlsView: View {
|
||||||
|
@Bindable var session: SessionState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
// Join/Leave Voice button (mirrors macOS micToggleButton)
|
||||||
|
if session.voiceState.inputMode == .pushToTalk {
|
||||||
|
PTTButton(session: session)
|
||||||
|
} else {
|
||||||
|
Button {
|
||||||
|
if session.voiceState.micActive {
|
||||||
|
session.stopMicStream()
|
||||||
|
} else {
|
||||||
|
session.startMicStream()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text(session.voiceState.micActive ? "Leave Voice" : "Join Voice")
|
||||||
|
.font(.body.weight(.semibold))
|
||||||
|
.frame(minWidth: 110)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.background(session.voiceState.micActive ? Color.green.opacity(0.2) : Color.accentColor.opacity(0.15), in: RoundedRectangle(cornerRadius: 8))
|
||||||
|
.foregroundStyle(session.voiceState.micActive ? .green : .accentColor)
|
||||||
|
}
|
||||||
|
.disabled(session.currentChannelId == 0)
|
||||||
|
.accessibilityLabel(session.voiceState.micActive ? "Leave Voice — stop sending microphone audio" : "Join Voice — start sending microphone audio")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Level meter
|
||||||
|
LevelMeterView(level: session.voiceState.level)
|
||||||
|
.frame(width: 80, height: 8)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Self mute (disabled when not in voice)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
.disabled(!session.voiceState.micActive)
|
||||||
|
.accessibilityLabel(session.voiceState.selfMuted ? "Unmute microphone" : "Mute microphone")
|
||||||
|
|
||||||
|
// Self deafen (disabled when not in voice)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
.disabled(!session.voiceState.micActive)
|
||||||
|
.accessibilityLabel(session.voiceState.selfDeafened ? "Undeafen" : "Deafen")
|
||||||
|
|
||||||
|
// Share screen audio (ReplayKit system broadcast picker). The picker launches the
|
||||||
|
// broadcast upload extension; the host's BroadcastAudioPump then owns + feeds the
|
||||||
|
// SCREEN_AUDIO stream. Tinted while sharing.
|
||||||
|
BroadcastPickerButton(isSharing: session.voiceState.screenSharing)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
|
||||||
|
// 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: - Broadcast picker
|
||||||
|
|
||||||
|
/// Wraps `RPSystemBroadcastPickerView` (which contains its own button) and points it at our
|
||||||
|
/// broadcast upload extension. Tapping it shows the system broadcast picker; the user starts the
|
||||||
|
/// broadcast and our extension launches.
|
||||||
|
private struct BroadcastPickerButton: UIViewRepresentable {
|
||||||
|
let isSharing: Bool
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> RPSystemBroadcastPickerView {
|
||||||
|
let picker = RPSystemBroadcastPickerView(frame: CGRect(x: 0, y: 0, width: 32, height: 32))
|
||||||
|
picker.preferredExtension = "cat.voice.VoiceCatiOS.broadcast"
|
||||||
|
picker.showsMicrophoneButton = false
|
||||||
|
return picker
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: RPSystemBroadcastPickerView, context: Context) {
|
||||||
|
uiView.tintColor = isSharing ? .systemGreen : .label
|
||||||
|
// RPSystemBroadcastPickerView owns an inner UIButton that VoiceOver focuses; its default
|
||||||
|
// label is the picker's image name ("module icon"). Label the inner button directly —
|
||||||
|
// a SwiftUI .accessibilityLabel on the representable doesn't reach it.
|
||||||
|
let label = isSharing ? "Stop sharing screen audio" : "Share screen audio"
|
||||||
|
for case let button as UIButton in uiView.subviews {
|
||||||
|
button.accessibilityLabel = label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
456
clients/apple/macOS/VoiceCatMac.xcodeproj/project.pbxproj
Normal file
456
clients/apple/macOS/VoiceCatMac.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 56;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
AAAA00000000000000000030 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000015 /* main.swift */; };
|
||||||
|
AAAA00000000000000000031 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000016 /* AppDelegate.swift */; };
|
||||||
|
AAAA00000000000000000032 /* SavedServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000017 /* SavedServer.swift */; };
|
||||||
|
AAAA00000000000000000033 /* ServerListStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000018 /* ServerListStore.swift */; };
|
||||||
|
AAAA00000000000000000034 /* ConnectWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000019 /* ConnectWindowController.swift */; };
|
||||||
|
AAAA00000000000000000035 /* MainWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA0000000000000000001A /* MainWindowController.swift */; };
|
||||||
|
AAAA00000000000000000036 /* AddServerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA0000000000000000001B /* AddServerSheet.swift */; };
|
||||||
|
AAAA00000000000000000037 /* ServerIdentitySheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA0000000000000000001C /* ServerIdentitySheet.swift */; };
|
||||||
|
AAAA00000000000000000038 /* PasswordPromptSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA0000000000000000001D /* PasswordPromptSheet.swift */; };
|
||||||
|
AAAA00000000000000000039 /* PerUserTuningSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA0000000000000000001E /* PerUserTuningSheet.swift */; };
|
||||||
|
AAAA0000000000000000003A /* ChannelEditSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA0000000000000000001F /* ChannelEditSheet.swift */; };
|
||||||
|
AAAA0000000000000000003B /* AccountsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000020 /* AccountsSheet.swift */; };
|
||||||
|
AAAA0000000000000000003C /* MoveUserSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000021 /* MoveUserSheet.swift */; };
|
||||||
|
AAAA0000000000000000003D /* InputSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000022 /* InputSheet.swift */; };
|
||||||
|
AAAA0000000000000000003E /* BanUserSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000023 /* BanUserSheet.swift */; };
|
||||||
|
AAAA0000000000000000003F /* PermissionsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000024 /* PermissionsSheet.swift */; };
|
||||||
|
AAAA00000000000000000040 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000025 /* Security.framework */; };
|
||||||
|
AAAA00000000000000000041 /* VoiceCatCore in Frameworks */ = {isa = PBXBuildFile; productRef = AAAA00000000000000000027 /* VoiceCatCore */; };
|
||||||
|
AAAA00000000000000000042 /* Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000013 /* Info.plist */; };
|
||||||
|
AAAA00000000000000000043 /* PttKeyCaptureSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000044 /* PttKeyCaptureSheet.swift */; };
|
||||||
|
AAAA00000000000000000046 /* PrivateMessageWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000045 /* PrivateMessageWindowController.swift */; };
|
||||||
|
AAAA00000000000000000048 /* UserPickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000047 /* UserPickerSheet.swift */; };
|
||||||
|
AAAA0000000000000000004A /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000049 /* SettingsWindowController.swift */; };
|
||||||
|
AAAA0000000000000000004C /* ScreenAudioCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA0000000000000000004B /* ScreenAudioCapture.swift */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
AAAA00000000000000000012 /* VoiceCatMac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VoiceCatMac.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
AAAA00000000000000000013 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
AAAA00000000000000000014 /* VoiceCatMac.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = VoiceCatMac.entitlements; sourceTree = "<group>"; };
|
||||||
|
AAAA00000000000000000015 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
|
||||||
|
AAAA00000000000000000016 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
AAAA00000000000000000017 /* SavedServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedServer.swift; sourceTree = "<group>"; };
|
||||||
|
AAAA00000000000000000018 /* ServerListStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListStore.swift; sourceTree = "<group>"; };
|
||||||
|
AAAA00000000000000000019 /* ConnectWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectWindowController.swift; sourceTree = "<group>"; };
|
||||||
|
AAAA0000000000000000001A /* MainWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindowController.swift; sourceTree = "<group>"; };
|
||||||
|
AAAA0000000000000000004B /* ScreenAudioCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenAudioCapture.swift; sourceTree = "<group>"; };
|
||||||
|
AAAA0000000000000000001B /* AddServerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddServerSheet.swift; sourceTree = "<group>"; };
|
||||||
|
AAAA0000000000000000001C /* ServerIdentitySheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerIdentitySheet.swift; sourceTree = "<group>"; };
|
||||||
|
AAAA0000000000000000001D /* PasswordPromptSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordPromptSheet.swift; sourceTree = "<group>"; };
|
||||||
|
AAAA0000000000000000001E /* PerUserTuningSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerUserTuningSheet.swift; sourceTree = "<group>"; };
|
||||||
|
AAAA0000000000000000001F /* ChannelEditSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelEditSheet.swift; sourceTree = "<group>"; };
|
||||||
|
AAAA00000000000000000020 /* AccountsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsSheet.swift; sourceTree = "<group>"; };
|
||||||
|
AAAA00000000000000000021 /* MoveUserSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveUserSheet.swift; sourceTree = "<group>"; };
|
||||||
|
AAAA00000000000000000022 /* InputSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputSheet.swift; sourceTree = "<group>"; };
|
||||||
|
AAAA00000000000000000023 /* BanUserSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BanUserSheet.swift; sourceTree = "<group>"; };
|
||||||
|
AAAA00000000000000000024 /* PermissionsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsSheet.swift; sourceTree = "<group>"; };
|
||||||
|
AAAA00000000000000000044 /* PttKeyCaptureSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PttKeyCaptureSheet.swift; sourceTree = "<group>"; };
|
||||||
|
AAAA00000000000000000045 /* PrivateMessageWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateMessageWindowController.swift; sourceTree = "<group>"; };
|
||||||
|
AAAA00000000000000000047 /* UserPickerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPickerSheet.swift; sourceTree = "<group>"; };
|
||||||
|
AAAA00000000000000000049 /* SettingsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindowController.swift; sourceTree = "<group>"; };
|
||||||
|
AAAA00000000000000000025 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
AAAA00000000000000000011 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
AAAA00000000000000000040 /* Security.framework in Frameworks */,
|
||||||
|
AAAA00000000000000000041 /* VoiceCatCore in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
AAAA00000000000000000002 /* mainGroup */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
AAAA00000000000000000003 /* VoiceCatMac */,
|
||||||
|
AAAA00000000000000000007 /* Products */,
|
||||||
|
AAAA00000000000000000025 /* Security.framework */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
AAAA00000000000000000003 /* VoiceCatMac */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
AAAA00000000000000000013 /* Info.plist */,
|
||||||
|
AAAA00000000000000000014 /* VoiceCatMac.entitlements */,
|
||||||
|
AAAA00000000000000000015 /* main.swift */,
|
||||||
|
AAAA00000000000000000016 /* AppDelegate.swift */,
|
||||||
|
AAAA00000000000000000004 /* Models */,
|
||||||
|
AAAA0000000000000000004D /* Audio */,
|
||||||
|
AAAA00000000000000000005 /* Windows */,
|
||||||
|
AAAA00000000000000000006 /* Sheets */,
|
||||||
|
);
|
||||||
|
path = VoiceCatMac;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
AAAA0000000000000000004D /* Audio */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
AAAA0000000000000000004B /* ScreenAudioCapture.swift */,
|
||||||
|
);
|
||||||
|
path = Audio;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
AAAA00000000000000000004 /* Models */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
AAAA00000000000000000017 /* SavedServer.swift */,
|
||||||
|
AAAA00000000000000000018 /* ServerListStore.swift */,
|
||||||
|
);
|
||||||
|
path = Models;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
AAAA00000000000000000005 /* Windows */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
AAAA00000000000000000019 /* ConnectWindowController.swift */,
|
||||||
|
AAAA0000000000000000001A /* MainWindowController.swift */,
|
||||||
|
AAAA00000000000000000045 /* PrivateMessageWindowController.swift */,
|
||||||
|
AAAA00000000000000000049 /* SettingsWindowController.swift */,
|
||||||
|
);
|
||||||
|
path = Windows;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
AAAA00000000000000000006 /* Sheets */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
AAAA0000000000000000001B /* AddServerSheet.swift */,
|
||||||
|
AAAA0000000000000000001C /* ServerIdentitySheet.swift */,
|
||||||
|
AAAA0000000000000000001D /* PasswordPromptSheet.swift */,
|
||||||
|
AAAA0000000000000000001E /* PerUserTuningSheet.swift */,
|
||||||
|
AAAA0000000000000000001F /* ChannelEditSheet.swift */,
|
||||||
|
AAAA00000000000000000020 /* AccountsSheet.swift */,
|
||||||
|
AAAA00000000000000000021 /* MoveUserSheet.swift */,
|
||||||
|
AAAA00000000000000000022 /* InputSheet.swift */,
|
||||||
|
AAAA00000000000000000023 /* BanUserSheet.swift */,
|
||||||
|
AAAA00000000000000000024 /* PermissionsSheet.swift */,
|
||||||
|
AAAA00000000000000000044 /* PttKeyCaptureSheet.swift */,
|
||||||
|
AAAA00000000000000000047 /* UserPickerSheet.swift */,
|
||||||
|
);
|
||||||
|
path = Sheets;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
AAAA00000000000000000007 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
AAAA00000000000000000012 /* VoiceCatMac.app */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
AAAA00000000000000000008 /* VoiceCatMac */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = AAAA0000000000000000000A /* Build configuration list for PBXNativeTarget "VoiceCatMac" */;
|
||||||
|
buildPhases = (
|
||||||
|
AAAA0000000000000000000F /* Sources */,
|
||||||
|
AAAA00000000000000000010 /* Resources */,
|
||||||
|
AAAA00000000000000000011 /* Frameworks */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = VoiceCatMac;
|
||||||
|
packageProductDependencies = (
|
||||||
|
AAAA00000000000000000027 /* VoiceCatCore */,
|
||||||
|
);
|
||||||
|
productName = VoiceCatMac;
|
||||||
|
productReference = AAAA00000000000000000012 /* VoiceCatMac.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
AAAA00000000000000000001 /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = 1;
|
||||||
|
LastSwiftUpdateCheck = 1500;
|
||||||
|
LastUpgradeCheck = 1500;
|
||||||
|
};
|
||||||
|
buildConfigurationList = AAAA00000000000000000009 /* Build configuration list for PBXProject "VoiceCatMac" */;
|
||||||
|
compatibilityVersion = "Xcode 14.0";
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
en,
|
||||||
|
Base,
|
||||||
|
);
|
||||||
|
mainGroup = AAAA00000000000000000002 /* mainGroup */;
|
||||||
|
packageReferences = (
|
||||||
|
AAAA00000000000000000026 /* XCLocalSwiftPackageReference "../" */,
|
||||||
|
);
|
||||||
|
productRefGroup = AAAA00000000000000000007 /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
AAAA00000000000000000008 /* VoiceCatMac */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
AAAA00000000000000000010 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
AAAA0000000000000000000F /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
AAAA00000000000000000030 /* main.swift in Sources */,
|
||||||
|
AAAA00000000000000000031 /* AppDelegate.swift in Sources */,
|
||||||
|
AAAA00000000000000000032 /* SavedServer.swift in Sources */,
|
||||||
|
AAAA00000000000000000033 /* ServerListStore.swift in Sources */,
|
||||||
|
AAAA00000000000000000034 /* ConnectWindowController.swift in Sources */,
|
||||||
|
AAAA00000000000000000035 /* MainWindowController.swift in Sources */,
|
||||||
|
AAAA00000000000000000036 /* AddServerSheet.swift in Sources */,
|
||||||
|
AAAA00000000000000000037 /* ServerIdentitySheet.swift in Sources */,
|
||||||
|
AAAA00000000000000000038 /* PasswordPromptSheet.swift in Sources */,
|
||||||
|
AAAA00000000000000000039 /* PerUserTuningSheet.swift in Sources */,
|
||||||
|
AAAA0000000000000000003A /* ChannelEditSheet.swift in Sources */,
|
||||||
|
AAAA0000000000000000003B /* AccountsSheet.swift in Sources */,
|
||||||
|
AAAA0000000000000000003C /* MoveUserSheet.swift in Sources */,
|
||||||
|
AAAA0000000000000000003D /* InputSheet.swift in Sources */,
|
||||||
|
AAAA0000000000000000003E /* BanUserSheet.swift in Sources */,
|
||||||
|
AAAA0000000000000000003F /* PermissionsSheet.swift in Sources */,
|
||||||
|
AAAA00000000000000000043 /* PttKeyCaptureSheet.swift in Sources */,
|
||||||
|
AAAA00000000000000000046 /* PrivateMessageWindowController.swift in Sources */,
|
||||||
|
AAAA00000000000000000048 /* UserPickerSheet.swift in Sources */,
|
||||||
|
AAAA0000000000000000004A /* SettingsWindowController.swift in Sources */,
|
||||||
|
AAAA0000000000000000004C /* ScreenAudioCapture.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
AAAA0000000000000000000B /* 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;
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
SDKROOT = macosx;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
AAAA0000000000000000000C /* 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;
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
SDKROOT = macosx;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
AAAA0000000000000000000D /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = VoiceCatMac/VoiceCatMac.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
|
DEVELOPMENT_TEAM = "";
|
||||||
|
ENABLE_APP_SANDBOX = NO;
|
||||||
|
INFOPLIST_FILE = VoiceCatMac/Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/../Frameworks",
|
||||||
|
);
|
||||||
|
OTHER_LDFLAGS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"-lc++",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = cat.voice.VoiceCatMac;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.9;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
AAAA0000000000000000000E /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = VoiceCatMac/VoiceCatMac.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
|
DEVELOPMENT_TEAM = "";
|
||||||
|
ENABLE_APP_SANDBOX = NO;
|
||||||
|
INFOPLIST_FILE = VoiceCatMac/Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/../Frameworks",
|
||||||
|
);
|
||||||
|
OTHER_LDFLAGS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"-lc++",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = cat.voice.VoiceCatMac;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.9;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
AAAA00000000000000000009 /* Build configuration list for PBXProject "VoiceCatMac" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
AAAA0000000000000000000B /* Debug */,
|
||||||
|
AAAA0000000000000000000C /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
AAAA0000000000000000000A /* Build configuration list for PBXNativeTarget "VoiceCatMac" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
AAAA0000000000000000000D /* Debug */,
|
||||||
|
AAAA0000000000000000000E /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
|
AAAA00000000000000000026 /* XCLocalSwiftPackageReference "../" */ = {
|
||||||
|
isa = XCLocalSwiftPackageReference;
|
||||||
|
relativePath = "../";
|
||||||
|
};
|
||||||
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
AAAA00000000000000000027 /* VoiceCatCore */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = AAAA00000000000000000026 /* XCLocalSwiftPackageReference "../" */;
|
||||||
|
productName = VoiceCatCore;
|
||||||
|
};
|
||||||
|
/* End XCSwiftPackageProductDependency section */
|
||||||
|
|
||||||
|
};
|
||||||
|
rootObject = AAAA00000000000000000001 /* Project object */;
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme LastUpgradeVersion="1500" version="1.7">
|
||||||
|
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "AAAA00000000000000000008"
|
||||||
|
BuildableName = "VoiceCatMac.app"
|
||||||
|
BlueprintName = "VoiceCatMac"
|
||||||
|
ReferencedContainer = "container:VoiceCatMac.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
|
<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 = "AAAA00000000000000000008"
|
||||||
|
BuildableName = "VoiceCatMac.app"
|
||||||
|
BlueprintName = "VoiceCatMac"
|
||||||
|
ReferencedContainer = "container:VoiceCatMac.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "AAAA00000000000000000008"
|
||||||
|
BuildableName = "VoiceCatMac.app"
|
||||||
|
BlueprintName = "VoiceCatMac"
|
||||||
|
ReferencedContainer = "container:VoiceCatMac.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction buildConfiguration = "Debug"/>
|
||||||
|
<ArchiveAction buildConfiguration = "Release" revealArchiveInOrganizer = "YES"/>
|
||||||
|
</Scheme>
|
||||||
38
clients/apple/macOS/VoiceCatMac/AppDelegate.swift
Normal file
38
clients/apple/macOS/VoiceCatMac/AppDelegate.swift
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
private var connectWindowController: ConnectWindowController?
|
||||||
|
|
||||||
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
|
buildMenuBar()
|
||||||
|
NSApp.setActivationPolicy(.regular)
|
||||||
|
connectWindowController = ConnectWindowController()
|
||||||
|
connectWindowController?.showWindow(nil)
|
||||||
|
NSApp.activate(ignoringOtherApps: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }
|
||||||
|
|
||||||
|
private func buildMenuBar() {
|
||||||
|
let mainMenu = NSMenu()
|
||||||
|
|
||||||
|
let appItem = NSMenuItem()
|
||||||
|
mainMenu.addItem(appItem)
|
||||||
|
let appMenu = NSMenu()
|
||||||
|
appMenu.addItem(NSMenuItem(title: "Quit VoiceCat",
|
||||||
|
action: #selector(NSApplication.terminate(_:)),
|
||||||
|
keyEquivalent: "q"))
|
||||||
|
appItem.submenu = appMenu
|
||||||
|
|
||||||
|
let editItem = NSMenuItem()
|
||||||
|
mainMenu.addItem(editItem)
|
||||||
|
let editMenu = NSMenu(title: "Edit")
|
||||||
|
editMenu.addItem(NSMenuItem(title: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x"))
|
||||||
|
editMenu.addItem(NSMenuItem(title: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c"))
|
||||||
|
editMenu.addItem(NSMenuItem(title: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v"))
|
||||||
|
editMenu.addItem(NSMenuItem(title: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a"))
|
||||||
|
editItem.submenu = editMenu
|
||||||
|
|
||||||
|
NSApp.mainMenu = mainMenu
|
||||||
|
}
|
||||||
|
}
|
||||||
158
clients/apple/macOS/VoiceCatMac/Audio/ScreenAudioCapture.swift
Normal file
158
clients/apple/macOS/VoiceCatMac/Audio/ScreenAudioCapture.swift
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import ScreenCaptureKit
|
||||||
|
|
||||||
|
// ScreenAudioCapture — macOS system/desktop audio capture for the SCREEN_AUDIO stream.
|
||||||
|
//
|
||||||
|
// The macOS analog of the Windows WASAPI loopback path (docs/voice.md §9). ScreenCaptureKit
|
||||||
|
// (macOS 13+) captures whatever the system is playing; we convert each audio CMSampleBuffer
|
||||||
|
// (Float32) → int16 interleaved and push 20 ms frames (960 samples/channel @ 48 kHz) into the
|
||||||
|
// core via `vc_stream_feed_pcm` (exposed as `VoiceCatClient.feedPcm`). The core then runs the
|
||||||
|
// same Opus-encode → media-AEAD → UDP path as any other stream — only the *source* is
|
||||||
|
// platform-specific (architecture.md §4).
|
||||||
|
//
|
||||||
|
// Audio-only: we request a 2×2 video plane at 1 fps purely because SCStream needs a video
|
||||||
|
// configuration, and we never add a `.screen` output — only `.audio`. `excludesCurrentProcess
|
||||||
|
// Audio` prevents the self-echo loop of re-capturing our own incoming voice mix.
|
||||||
|
//
|
||||||
|
// `feedPcm` is thread-safe (any thread), so we forward straight from the sample-handler queue.
|
||||||
|
final class ScreenAudioCapture: NSObject, SCStreamOutput, SCStreamDelegate {
|
||||||
|
|
||||||
|
/// Receives a full 20 ms frame: (interleaved int16 PCM, samplesPerChannel = 960, channels).
|
||||||
|
typealias PcmHandler = (UnsafePointer<Int16>, Int, UInt32) -> Void
|
||||||
|
|
||||||
|
enum CaptureError: Error { case noDisplay }
|
||||||
|
|
||||||
|
private static let frameSamplesPerChannel = 960 // 20 ms @ 48 kHz
|
||||||
|
|
||||||
|
private let onPcm: PcmHandler
|
||||||
|
private let channels: Int // 1 (mono) or 2 (stereo interleaved), matches the stream's mode
|
||||||
|
private let sampleQueue = DispatchQueue(label: "cat.voice.screenaudio.samples")
|
||||||
|
private var stream: SCStream?
|
||||||
|
|
||||||
|
/// Interleaved int16 carry-over between callbacks (ScreenCaptureKit buffers don't align to
|
||||||
|
/// 20 ms), drained in whole `frameSamplesPerChannel * channels` chunks. Only touched on
|
||||||
|
/// `sampleQueue`.
|
||||||
|
private var pending: [Int16] = []
|
||||||
|
|
||||||
|
init(channels: UInt32, onPcm: @escaping PcmHandler) {
|
||||||
|
self.channels = max(1, min(2, Int(channels)))
|
||||||
|
self.onPcm = onPcm
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Begin capture. Throws if Screen Recording permission is denied (the first
|
||||||
|
/// `SCShareableContent.current` access is what surfaces the TCC prompt) or no display exists.
|
||||||
|
func start() async throws {
|
||||||
|
let content = try await SCShareableContent.current
|
||||||
|
guard let display = content.displays.first else { throw CaptureError.noDisplay }
|
||||||
|
|
||||||
|
let filter = SCContentFilter(display: display, excludingWindows: [])
|
||||||
|
|
||||||
|
let config = SCStreamConfiguration()
|
||||||
|
config.capturesAudio = true
|
||||||
|
config.excludesCurrentProcessAudio = true
|
||||||
|
config.sampleRate = 48000
|
||||||
|
config.channelCount = channels
|
||||||
|
// SCStream requires a video config even when we only consume audio — keep it minimal.
|
||||||
|
config.width = 2
|
||||||
|
config.height = 2
|
||||||
|
config.minimumFrameInterval = CMTime(value: 1, timescale: 1) // ~1 fps
|
||||||
|
config.queueDepth = 6
|
||||||
|
|
||||||
|
let stream = SCStream(filter: filter, configuration: config, delegate: self)
|
||||||
|
try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: sampleQueue)
|
||||||
|
try await stream.startCapture()
|
||||||
|
self.stream = stream
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop capture and release the stream. Safe to call multiple times.
|
||||||
|
func stop() {
|
||||||
|
guard let stream else { return }
|
||||||
|
self.stream = nil
|
||||||
|
Task { try? await stream.stopCapture() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SCStreamOutput
|
||||||
|
|
||||||
|
func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer,
|
||||||
|
of type: SCStreamOutputType) {
|
||||||
|
guard type == .audio, CMSampleBufferDataIsReady(sampleBuffer) else { return }
|
||||||
|
guard let fmt = sampleBuffer.formatDescription,
|
||||||
|
let asbd = fmt.audioStreamBasicDescription else { return }
|
||||||
|
// ScreenCaptureKit always delivers Float32 PCM; bail on anything unexpected.
|
||||||
|
guard asbd.mFormatFlags & kAudioFormatFlagIsFloat != 0 else { return }
|
||||||
|
|
||||||
|
let nonInterleaved = asbd.mFormatFlags & kAudioFormatFlagIsNonInterleaved != 0
|
||||||
|
let srcChannels = max(1, Int(asbd.mChannelsPerFrame))
|
||||||
|
|
||||||
|
try? sampleBuffer.withAudioBufferList { ablPtr, _ in
|
||||||
|
convert(ablPtr, nonInterleaved: nonInterleaved, srcChannels: srcChannels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conversion (called on sampleQueue)
|
||||||
|
|
||||||
|
private func convert(_ abl: UnsafeMutableAudioBufferListPointer,
|
||||||
|
nonInterleaved: Bool, srcChannels: Int) {
|
||||||
|
guard let first = abl.first, first.mData != nil else { return }
|
||||||
|
|
||||||
|
let out = channels
|
||||||
|
var interleaved: [Int16]
|
||||||
|
|
||||||
|
if nonInterleaved {
|
||||||
|
// One buffer per source channel; each is `frames` Float32 samples.
|
||||||
|
let frames = Int(first.mDataByteSize) / MemoryLayout<Float>.size
|
||||||
|
if frames == 0 { return }
|
||||||
|
let ch0 = first.mData!.assumingMemoryBound(to: Float.self)
|
||||||
|
let ch1: UnsafePointer<Float>? = (abl.count > 1)
|
||||||
|
? UnsafePointer(abl[1].mData!.assumingMemoryBound(to: Float.self)) : nil
|
||||||
|
interleaved = [Int16](repeating: 0, count: frames * out)
|
||||||
|
for i in 0..<frames {
|
||||||
|
let l = ch0[i]
|
||||||
|
let r = ch1?[i] ?? l
|
||||||
|
if out == 2 {
|
||||||
|
interleaved[i * 2] = Self.f2i(l)
|
||||||
|
interleaved[i * 2 + 1] = Self.f2i(srcChannels >= 2 ? r : l)
|
||||||
|
} else {
|
||||||
|
interleaved[i] = Self.f2i(srcChannels >= 2 ? (l + r) * 0.5 : l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Single interleaved Float32 buffer, srcChannels wide.
|
||||||
|
let total = Int(first.mDataByteSize) / MemoryLayout<Float>.size
|
||||||
|
let frames = total / srcChannels
|
||||||
|
if frames == 0 { return }
|
||||||
|
let src = first.mData!.assumingMemoryBound(to: Float.self)
|
||||||
|
interleaved = [Int16](repeating: 0, count: frames * out)
|
||||||
|
for i in 0..<frames {
|
||||||
|
let l = src[i * srcChannels]
|
||||||
|
let r = srcChannels >= 2 ? src[i * srcChannels + 1] : l
|
||||||
|
if out == 2 {
|
||||||
|
interleaved[i * 2] = Self.f2i(l)
|
||||||
|
interleaved[i * 2 + 1] = Self.f2i(r)
|
||||||
|
} else {
|
||||||
|
interleaved[i] = Self.f2i(srcChannels >= 2 ? (l + r) * 0.5 : l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(interleaved)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accumulate interleaved int16 and fire `onPcm` for every whole 20 ms frame.
|
||||||
|
private func emit(_ interleaved: [Int16]) {
|
||||||
|
pending.append(contentsOf: interleaved)
|
||||||
|
let full = Self.frameSamplesPerChannel * channels
|
||||||
|
while pending.count >= full {
|
||||||
|
pending.withUnsafeBufferPointer { buf in
|
||||||
|
onPcm(buf.baseAddress!, Self.frameSamplesPerChannel, UInt32(channels))
|
||||||
|
}
|
||||||
|
pending.removeFirst(full)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func f2i(_ f: Float) -> Int16 {
|
||||||
|
let v = max(-1.0, min(1.0, f)) * 32767.0
|
||||||
|
return Int16(v.rounded())
|
||||||
|
}
|
||||||
|
}
|
||||||
28
clients/apple/macOS/VoiceCatMac/Info.plist
Normal file
28
clients/apple/macOS/VoiceCatMac/Info.plist
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?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>LSMinimumSystemVersion</key>
|
||||||
|
<string>14.0</string>
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>Copyright © 2026 VoiceCat contributors. All rights reserved.</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>VoiceCat uses your microphone to transmit voice audio to other participants in the current channel.</string>
|
||||||
|
<key>NSPrincipalClass</key>
|
||||||
|
<string>NSApplication</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
24
clients/apple/macOS/VoiceCatMac/Models/SavedServer.swift
Normal file
24
clients/apple/macOS/VoiceCatMac/Models/SavedServer.swift
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum AuthMode: String, Codable, CaseIterable {
|
||||||
|
case guest
|
||||||
|
case password
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SavedServer: Codable, Identifiable {
|
||||||
|
var id: UUID = UUID()
|
||||||
|
var host: String
|
||||||
|
var port: UInt16
|
||||||
|
var authMode: AuthMode
|
||||||
|
var savedUsername: String?
|
||||||
|
var keychainTag: String?
|
||||||
|
|
||||||
|
var displayString: String {
|
||||||
|
switch authMode {
|
||||||
|
case .guest:
|
||||||
|
return "\(host):\(port) (Guest)"
|
||||||
|
case .password:
|
||||||
|
return "\(savedUsername ?? "")@\(host):\(port)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
69
clients/apple/macOS/VoiceCatMac/Models/ServerListStore.swift
Normal file
69
clients/apple/macOS/VoiceCatMac/Models/ServerListStore.swift
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
enum ServerListStore {
|
||||||
|
static var appSupportURL: URL {
|
||||||
|
let url = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||||
|
return url.appendingPathComponent("VoiceCat", isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
static var serversURL: URL {
|
||||||
|
appSupportURL.appendingPathComponent("servers.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
static var tofuStorePath: String {
|
||||||
|
appSupportURL.appendingPathComponent("tofu_pins.txt").path
|
||||||
|
}
|
||||||
|
|
||||||
|
static func load() -> [SavedServer] {
|
||||||
|
try? FileManager.default.createDirectory(at: appSupportURL, withIntermediateDirectories: true)
|
||||||
|
guard let data = try? Data(contentsOf: serversURL),
|
||||||
|
let list = try? JSONDecoder().decode([SavedServer].self, from: data) else { return [] }
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
static func save(_ servers: [SavedServer]) {
|
||||||
|
try? FileManager.default.createDirectory(at: appSupportURL, withIntermediateDirectories: true)
|
||||||
|
if let data = try? JSONEncoder().encode(servers) {
|
||||||
|
try? data.write(to: serversURL, options: .atomic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Keychain
|
||||||
|
|
||||||
|
static func savePassword(_ password: String, tag: String) {
|
||||||
|
guard let data = password.data(using: .utf8) else { return }
|
||||||
|
deletePassword(tag: tag)
|
||||||
|
let query: [CFString: Any] = [
|
||||||
|
kSecClass: kSecClassGenericPassword,
|
||||||
|
kSecAttrService: "cat.voice.VoiceCatMac",
|
||||||
|
kSecAttrAccount: tag,
|
||||||
|
kSecValueData: data,
|
||||||
|
]
|
||||||
|
SecItemAdd(query as CFDictionary, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadPassword(tag: String) -> String? {
|
||||||
|
let query: [CFString: Any] = [
|
||||||
|
kSecClass: kSecClassGenericPassword,
|
||||||
|
kSecAttrService: "cat.voice.VoiceCatMac",
|
||||||
|
kSecAttrAccount: tag,
|
||||||
|
kSecReturnData: true,
|
||||||
|
kSecMatchLimit: kSecMatchLimitOne,
|
||||||
|
]
|
||||||
|
var result: AnyObject?
|
||||||
|
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
||||||
|
let data = result as? Data,
|
||||||
|
let password = String(data: data, encoding: .utf8) else { return nil }
|
||||||
|
return password
|
||||||
|
}
|
||||||
|
|
||||||
|
static func deletePassword(tag: String) {
|
||||||
|
let query: [CFString: Any] = [
|
||||||
|
kSecClass: kSecClassGenericPassword,
|
||||||
|
kSecAttrService: "cat.voice.VoiceCatMac",
|
||||||
|
kSecAttrAccount: tag,
|
||||||
|
]
|
||||||
|
SecItemDelete(query as CFDictionary)
|
||||||
|
}
|
||||||
|
}
|
||||||
209
clients/apple/macOS/VoiceCatMac/Sheets/AccountsSheet.swift
Normal file
209
clients/apple/macOS/VoiceCatMac/Sheets/AccountsSheet.swift
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import AppKit
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
final class AccountsSheet: NSViewController {
|
||||||
|
|
||||||
|
private let client: VoiceCatClient
|
||||||
|
private var accounts: [Account] = []
|
||||||
|
private let tableView = NSTableView()
|
||||||
|
private let statusLabel = NSTextField(labelWithString: "Loading…")
|
||||||
|
|
||||||
|
init(client: VoiceCatClient) {
|
||||||
|
self.client = client
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
override func loadView() {
|
||||||
|
view = NSView(frame: NSRect(x: 0, y: 0, width: 480, height: 340))
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
buildUI()
|
||||||
|
refreshAccounts()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildUI() {
|
||||||
|
let titleLabel = NSTextField(labelWithString: "Server Accounts")
|
||||||
|
titleLabel.font = .boldSystemFont(ofSize: 13)
|
||||||
|
|
||||||
|
let userCol = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("username"))
|
||||||
|
userCol.title = "Username"; userCol.width = 160
|
||||||
|
let adminCol = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("admin"))
|
||||||
|
adminCol.title = "Admin"; adminCol.width = 60
|
||||||
|
let createdCol = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("created"))
|
||||||
|
createdCol.title = "Created"; createdCol.width = 120
|
||||||
|
let loginCol = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("login"))
|
||||||
|
loginCol.title = "Last Login"; loginCol.width = 120
|
||||||
|
|
||||||
|
tableView.addTableColumn(userCol)
|
||||||
|
tableView.addTableColumn(adminCol)
|
||||||
|
tableView.addTableColumn(createdCol)
|
||||||
|
tableView.addTableColumn(loginCol)
|
||||||
|
tableView.dataSource = self; tableView.delegate = self
|
||||||
|
tableView.allowsMultipleSelection = false
|
||||||
|
tableView.setAccessibilityLabel("Server accounts")
|
||||||
|
|
||||||
|
let sv = NSScrollView()
|
||||||
|
sv.documentView = tableView; sv.hasVerticalScroller = true
|
||||||
|
sv.borderType = .bezelBorder
|
||||||
|
|
||||||
|
let refreshButton = NSButton(title: "Refresh", target: self, action: #selector(refreshClicked))
|
||||||
|
refreshButton.bezelStyle = .rounded
|
||||||
|
refreshButton.setAccessibilityLabel("Refresh account list")
|
||||||
|
|
||||||
|
let addButton = NSButton(title: "Add…", target: self, action: #selector(addClicked))
|
||||||
|
addButton.bezelStyle = .rounded
|
||||||
|
addButton.setAccessibilityLabel("Add new account")
|
||||||
|
|
||||||
|
let resetPwButton = NSButton(title: "Reset Password…", target: self, action: #selector(resetPwClicked))
|
||||||
|
resetPwButton.bezelStyle = .rounded
|
||||||
|
resetPwButton.setAccessibilityLabel("Reset selected account password")
|
||||||
|
|
||||||
|
let deleteButton = NSButton(title: "Delete…", target: self, action: #selector(deleteClicked))
|
||||||
|
deleteButton.bezelStyle = .rounded
|
||||||
|
deleteButton.setAccessibilityLabel("Delete selected account")
|
||||||
|
|
||||||
|
let doneButton = NSButton(title: "Done", target: self, action: #selector(doneClicked))
|
||||||
|
doneButton.bezelStyle = .rounded; doneButton.keyEquivalent = "\r"
|
||||||
|
doneButton.setAccessibilityLabel("Close accounts sheet")
|
||||||
|
|
||||||
|
statusLabel.textColor = .secondaryLabelColor
|
||||||
|
statusLabel.setAccessibilityLabel("Status")
|
||||||
|
|
||||||
|
let toolbar = NSStackView(views: [refreshButton, addButton, resetPwButton, deleteButton, NSView()])
|
||||||
|
toolbar.orientation = .horizontal; toolbar.spacing = 8
|
||||||
|
|
||||||
|
let bottomRow = NSStackView(views: [statusLabel, NSView(), doneButton])
|
||||||
|
bottomRow.orientation = .horizontal; bottomRow.spacing = 8
|
||||||
|
|
||||||
|
let stack = NSStackView(views: [titleLabel, sv, toolbar, bottomRow])
|
||||||
|
stack.orientation = .vertical; stack.spacing = 10
|
||||||
|
stack.edgeInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(stack)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
stack.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
sv.heightAnchor.constraint(equalToConstant: 200),
|
||||||
|
])
|
||||||
|
|
||||||
|
client.onEvent = { [weak self] event in
|
||||||
|
if event.type == .accountList { self?.pullAccounts() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshAccounts() {
|
||||||
|
statusLabel.stringValue = "Loading…"
|
||||||
|
client.requestAccountList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pullAccounts() {
|
||||||
|
accounts = client.listAccounts()
|
||||||
|
tableView.reloadData()
|
||||||
|
statusLabel.stringValue = "\(accounts.count) account\(accounts.count == 1 ? "" : "s")"
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func refreshClicked() { refreshAccounts() }
|
||||||
|
|
||||||
|
@objc private func addClicked() {
|
||||||
|
let sheet = InputSheet(title: "New Account", prompt: "Username:", defaultValue: "")
|
||||||
|
sheet.onComplete = { [weak self] username in
|
||||||
|
guard let self, let username, !username.isEmpty else { return }
|
||||||
|
let pwSheet = PasswordPromptSheet(prompt: "Password for \(username):")
|
||||||
|
pwSheet.onComplete = { [weak self] password in
|
||||||
|
guard let self, let password, !password.isEmpty else { return }
|
||||||
|
let r = self.client.createAccount(username, password: password)
|
||||||
|
if r == .ok {
|
||||||
|
self.statusLabel.stringValue = "Account '\(username)' created."
|
||||||
|
self.refreshAccounts()
|
||||||
|
} else {
|
||||||
|
self.statusLabel.stringValue = "Failed: \(r.description)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.presentAsSheet(pwSheet)
|
||||||
|
}
|
||||||
|
presentAsSheet(sheet)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func resetPwClicked() {
|
||||||
|
let row = tableView.selectedRow
|
||||||
|
guard row >= 0, row < accounts.count else { return }
|
||||||
|
let account = accounts[row]
|
||||||
|
let sheet = PasswordPromptSheet(prompt: "New password for \(account.username):")
|
||||||
|
sheet.onComplete = { [weak self] password in
|
||||||
|
guard let self, let password, !password.isEmpty else { return }
|
||||||
|
let r = self.client.resetPassword(account.username, newPassword: password)
|
||||||
|
self.statusLabel.stringValue = r == .ok
|
||||||
|
? "Password reset for '\(account.username)'."
|
||||||
|
: "Failed: \(r.description)"
|
||||||
|
}
|
||||||
|
presentAsSheet(sheet)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func deleteClicked() {
|
||||||
|
let row = tableView.selectedRow
|
||||||
|
guard row >= 0, row < accounts.count else { return }
|
||||||
|
let account = accounts[row]
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.messageText = "Delete account '\(account.username)'?"
|
||||||
|
alert.informativeText = "This cannot be undone."
|
||||||
|
alert.addButton(withTitle: "Delete"); alert.addButton(withTitle: "Cancel")
|
||||||
|
alert.alertStyle = .warning
|
||||||
|
guard let window = view.window else { return }
|
||||||
|
alert.beginSheetModal(for: window) { [weak self] response in
|
||||||
|
guard response == .alertFirstButtonReturn, let self else { return }
|
||||||
|
let r = self.client.deleteAccount(account.username)
|
||||||
|
self.statusLabel.stringValue = r == .ok
|
||||||
|
? "Account '\(account.username)' deleted."
|
||||||
|
: "Failed: \(r.description)"
|
||||||
|
if r == .ok { self.refreshAccounts() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func doneClicked() { dismiss(nil) }
|
||||||
|
|
||||||
|
private func dateString(_ ms: UInt64) -> String {
|
||||||
|
guard ms > 0 else { return "—" }
|
||||||
|
let date = Date(timeIntervalSince1970: Double(ms) / 1000.0)
|
||||||
|
return DateFormatter.localizedString(from: date, dateStyle: .short, timeStyle: .none)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - NSTableViewDataSource / Delegate
|
||||||
|
|
||||||
|
extension AccountsSheet: NSTableViewDataSource, NSTableViewDelegate {
|
||||||
|
func numberOfRows(in tableView: NSTableView) -> Int { accounts.count }
|
||||||
|
|
||||||
|
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
|
||||||
|
let acc = accounts[row]
|
||||||
|
let id = tableColumn?.identifier ?? NSUserInterfaceItemIdentifier("cell")
|
||||||
|
let cell = tableView.makeView(withIdentifier: id, owner: nil) as? NSTableCellView
|
||||||
|
?? makeCell(id)
|
||||||
|
switch tableColumn?.identifier.rawValue {
|
||||||
|
case "username": cell.textField?.stringValue = acc.username
|
||||||
|
case "admin": cell.textField?.stringValue = acc.isAdmin ? "Yes" : ""
|
||||||
|
case "created": cell.textField?.stringValue = dateString(acc.createdAtUnixMs)
|
||||||
|
case "login": cell.textField?.stringValue = dateString(acc.lastLoginUnixMs)
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeCell(_ id: NSUserInterfaceItemIdentifier) -> NSTableCellView {
|
||||||
|
let cell = NSTableCellView(); cell.identifier = id
|
||||||
|
let tf = NSTextField(labelWithString: "")
|
||||||
|
tf.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
cell.addSubview(tf); cell.textField = tf
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
tf.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 4),
|
||||||
|
tf.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -4),
|
||||||
|
tf.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
|
||||||
|
])
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
}
|
||||||
154
clients/apple/macOS/VoiceCatMac/Sheets/AddServerSheet.swift
Normal file
154
clients/apple/macOS/VoiceCatMac/Sheets/AddServerSheet.swift
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
final class AddServerSheet: NSViewController {
|
||||||
|
|
||||||
|
var onComplete: ((SavedServer?) -> Void)?
|
||||||
|
|
||||||
|
private var editing: SavedServer?
|
||||||
|
|
||||||
|
private let hostField = NSTextField()
|
||||||
|
private let portField: NSTextField = {
|
||||||
|
let f = NSTextField()
|
||||||
|
f.stringValue = "7878"
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
private let authPicker = NSPopUpButton()
|
||||||
|
private let usernameField = NSTextField()
|
||||||
|
private let passwordField = NSSecureTextField()
|
||||||
|
private let savePwCheckbox = NSButton(checkboxWithTitle: "Save password in Keychain", target: nil, action: nil)
|
||||||
|
|
||||||
|
init(editing: SavedServer?) {
|
||||||
|
self.editing = editing
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
override func loadView() {
|
||||||
|
view = NSView(frame: NSRect(x: 0, y: 0, width: 380, height: 260))
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
buildUI()
|
||||||
|
if let s = editing {
|
||||||
|
hostField.stringValue = s.host
|
||||||
|
portField.stringValue = "\(s.port)"
|
||||||
|
authPicker.selectItem(withTitle: s.authMode == .guest ? "Guest" : "Account")
|
||||||
|
usernameField.stringValue = s.savedUsername ?? ""
|
||||||
|
updateAuthVisibility()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildUI() {
|
||||||
|
let title = NSTextField(labelWithString: editing == nil ? "Add Server" : "Edit Server")
|
||||||
|
title.font = .boldSystemFont(ofSize: 14)
|
||||||
|
|
||||||
|
let hostLabel = NSTextField(labelWithString: "Host:")
|
||||||
|
let portLabel = NSTextField(labelWithString: "Port:")
|
||||||
|
let authLabel = NSTextField(labelWithString: "Auth:")
|
||||||
|
let userLabel = NSTextField(labelWithString: "Username:")
|
||||||
|
let pwLabel = NSTextField(labelWithString: "Password:")
|
||||||
|
|
||||||
|
hostField.placeholderString = "hostname or IP"
|
||||||
|
hostField.setAccessibilityLabel("Server hostname or IP address")
|
||||||
|
|
||||||
|
portField.setAccessibilityLabel("Server port")
|
||||||
|
|
||||||
|
authPicker.addItem(withTitle: "Guest")
|
||||||
|
authPicker.addItem(withTitle: "Account")
|
||||||
|
authPicker.target = self; authPicker.action = #selector(authChanged)
|
||||||
|
authPicker.setAccessibilityLabel("Authentication mode")
|
||||||
|
|
||||||
|
usernameField.placeholderString = "username"
|
||||||
|
usernameField.setAccessibilityLabel("Username")
|
||||||
|
|
||||||
|
passwordField.placeholderString = "leave blank to enter at connect"
|
||||||
|
passwordField.setAccessibilityLabel("Password (optional — enter at connect time if blank)")
|
||||||
|
|
||||||
|
savePwCheckbox.setAccessibilityLabel("Save password in system Keychain")
|
||||||
|
|
||||||
|
let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(cancelClicked))
|
||||||
|
cancelButton.bezelStyle = .rounded
|
||||||
|
let saveButton = NSButton(title: "Save", target: self, action: #selector(saveClicked))
|
||||||
|
saveButton.bezelStyle = .rounded
|
||||||
|
saveButton.keyEquivalent = "\r"
|
||||||
|
saveButton.setAccessibilityLabel("Save server")
|
||||||
|
|
||||||
|
let grid = NSGridView(views: [
|
||||||
|
[hostLabel, hostField],
|
||||||
|
[portLabel, portField],
|
||||||
|
[authLabel, authPicker],
|
||||||
|
[userLabel, usernameField],
|
||||||
|
[pwLabel, passwordField],
|
||||||
|
[NSView(), savePwCheckbox],
|
||||||
|
])
|
||||||
|
grid.rowSpacing = 8
|
||||||
|
grid.columnSpacing = 8
|
||||||
|
grid.column(at: 0).xPlacement = .trailing
|
||||||
|
|
||||||
|
let buttonRow = NSStackView(views: [NSView(), cancelButton, saveButton])
|
||||||
|
buttonRow.orientation = .horizontal; buttonRow.spacing = 8
|
||||||
|
|
||||||
|
let stack = NSStackView(views: [title, grid, buttonRow])
|
||||||
|
stack.orientation = .vertical
|
||||||
|
stack.spacing = 16
|
||||||
|
stack.edgeInsets = NSEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(stack)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
stack.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
])
|
||||||
|
|
||||||
|
updateAuthVisibility()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func authChanged() { updateAuthVisibility() }
|
||||||
|
|
||||||
|
private func updateAuthVisibility() {
|
||||||
|
let isAccount = authPicker.titleOfSelectedItem == "Account"
|
||||||
|
usernameField.isEnabled = isAccount
|
||||||
|
passwordField.isEnabled = isAccount
|
||||||
|
savePwCheckbox.isEnabled = isAccount
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func saveClicked() {
|
||||||
|
let host = hostField.stringValue.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !host.isEmpty else {
|
||||||
|
hostField.becomeFirstResponder(); return
|
||||||
|
}
|
||||||
|
let portStr = portField.stringValue.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard let port = UInt16(portStr), port > 0 else {
|
||||||
|
portField.becomeFirstResponder(); return
|
||||||
|
}
|
||||||
|
let isGuest = authPicker.titleOfSelectedItem == "Guest"
|
||||||
|
var server = editing ?? SavedServer(host: host, port: port,
|
||||||
|
authMode: isGuest ? .guest : .password)
|
||||||
|
server.host = host; server.port = port
|
||||||
|
server.authMode = isGuest ? .guest : .password
|
||||||
|
server.savedUsername = isGuest ? nil : usernameField.stringValue.trimmingCharacters(in: .whitespaces)
|
||||||
|
|
||||||
|
if !isGuest && savePwCheckbox.state == .on {
|
||||||
|
let pw = passwordField.stringValue
|
||||||
|
if !pw.isEmpty {
|
||||||
|
let tag = server.keychainTag ?? "voicecat.server.\(server.id.uuidString)"
|
||||||
|
server.keychainTag = tag
|
||||||
|
ServerListStore.savePassword(pw, tag: tag)
|
||||||
|
}
|
||||||
|
} else if isGuest {
|
||||||
|
if let tag = server.keychainTag { ServerListStore.deletePassword(tag: tag) }
|
||||||
|
server.keychainTag = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dismiss(nil)
|
||||||
|
onComplete?(server)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cancelClicked() {
|
||||||
|
dismiss(nil)
|
||||||
|
onComplete?(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
93
clients/apple/macOS/VoiceCatMac/Sheets/BanUserSheet.swift
Normal file
93
clients/apple/macOS/VoiceCatMac/Sheets/BanUserSheet.swift
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
final class BanUserSheet: NSViewController {
|
||||||
|
|
||||||
|
var onComplete: ((String?, UInt64) -> Void)?
|
||||||
|
|
||||||
|
private let targetNickname: String
|
||||||
|
private let reasonField = NSTextField()
|
||||||
|
private let durationPicker = NSSegmentedControl(
|
||||||
|
labels: ["1 hour", "24 hours", "7 days", "Permanent"],
|
||||||
|
trackingMode: .selectOne,
|
||||||
|
target: nil, action: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
init(nickname: String) {
|
||||||
|
self.targetNickname = nickname
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
override func loadView() {
|
||||||
|
view = NSView(frame: NSRect(x: 0, y: 0, width: 360, height: 160))
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
buildUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildUI() {
|
||||||
|
let titleLabel = NSTextField(labelWithString: "Ban \(targetNickname)")
|
||||||
|
titleLabel.font = .boldSystemFont(ofSize: 13)
|
||||||
|
|
||||||
|
let reasonLabel = NSTextField(labelWithString: "Reason:")
|
||||||
|
reasonField.placeholderString = "Ban reason (optional)"
|
||||||
|
reasonField.setAccessibilityLabel("Ban reason")
|
||||||
|
|
||||||
|
let durationLabel = NSTextField(labelWithString: "Duration:")
|
||||||
|
durationPicker.selectedSegment = 3
|
||||||
|
durationPicker.setAccessibilityLabel("Ban duration")
|
||||||
|
|
||||||
|
let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(cancelClicked))
|
||||||
|
cancelButton.bezelStyle = .rounded
|
||||||
|
|
||||||
|
let banButton = NSButton(title: "Ban", target: self, action: #selector(banClicked))
|
||||||
|
banButton.bezelStyle = .rounded; banButton.keyEquivalent = "\r"
|
||||||
|
banButton.setAccessibilityLabel("Confirm ban")
|
||||||
|
|
||||||
|
let buttonRow = NSStackView(views: [NSView(), cancelButton, banButton])
|
||||||
|
buttonRow.orientation = .horizontal; buttonRow.spacing = 8
|
||||||
|
|
||||||
|
let grid = NSGridView(views: [
|
||||||
|
[reasonLabel, reasonField],
|
||||||
|
[durationLabel, durationPicker],
|
||||||
|
])
|
||||||
|
grid.rowSpacing = 8; grid.columnSpacing = 8
|
||||||
|
grid.column(at: 0).xPlacement = .trailing
|
||||||
|
|
||||||
|
let stack = NSStackView(views: [titleLabel, grid, buttonRow])
|
||||||
|
stack.orientation = .vertical; stack.spacing = 12
|
||||||
|
stack.edgeInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(stack)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
stack.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func banClicked() {
|
||||||
|
let reason = reasonField.stringValue.trimmingCharacters(in: .whitespaces)
|
||||||
|
let expiresUnixMs = expiryFromSelection()
|
||||||
|
dismiss(nil)
|
||||||
|
onComplete?(reason.isEmpty ? nil : reason, expiresUnixMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cancelClicked() {
|
||||||
|
dismiss(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func expiryFromSelection() -> UInt64 {
|
||||||
|
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
switch durationPicker.selectedSegment {
|
||||||
|
case 0: return nowMs + 3_600_000 // 1 hour
|
||||||
|
case 1: return nowMs + 86_400_000 // 24 hours
|
||||||
|
case 2: return nowMs + 604_800_000 // 7 days
|
||||||
|
default: return 0 // permanent (0 = no expiry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
202
clients/apple/macOS/VoiceCatMac/Sheets/ChannelEditSheet.swift
Normal file
202
clients/apple/macOS/VoiceCatMac/Sheets/ChannelEditSheet.swift
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import AppKit
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
final class ChannelEditSheet: NSViewController {
|
||||||
|
|
||||||
|
var onComplete: ((ChannelEdit?) -> Void)?
|
||||||
|
|
||||||
|
private let channels: [Channel]
|
||||||
|
private var editing: ChannelEdit?
|
||||||
|
|
||||||
|
private let nameField = NSTextField()
|
||||||
|
private let topicField = NSTextField()
|
||||||
|
private let parentPicker = NSPopUpButton()
|
||||||
|
private let pwCheckbox = NSButton(checkboxWithTitle: "Password protected", target: nil, action: nil)
|
||||||
|
private let pwField = NSSecureTextField()
|
||||||
|
private let maxUsersField: NSTextField = {
|
||||||
|
let f = NSTextField(); f.stringValue = "0"; return f
|
||||||
|
}()
|
||||||
|
private let sortOrderField: NSTextField = {
|
||||||
|
let f = NSTextField(); f.stringValue = "0"; return f
|
||||||
|
}()
|
||||||
|
private let stereoCheckbox = NSButton(checkboxWithTitle: "Stereo", target: nil, action: nil)
|
||||||
|
private let bitrateField: NSTextField = {
|
||||||
|
let f = NSTextField(); f.stringValue = "64000"; return f
|
||||||
|
}()
|
||||||
|
private let fecCheckbox = NSButton(checkboxWithTitle: "FEC", target: nil, action: nil)
|
||||||
|
private let dtxCheckbox = NSButton(checkboxWithTitle: "DTX", target: nil, action: nil)
|
||||||
|
private let frameMsPicker = NSPopUpButton()
|
||||||
|
|
||||||
|
init(channels: [Channel], editing: ChannelEdit?) {
|
||||||
|
self.channels = channels
|
||||||
|
self.editing = editing
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
override func loadView() {
|
||||||
|
view = NSView(frame: NSRect(x: 0, y: 0, width: 440, height: 360))
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
buildUI()
|
||||||
|
populateIfEditing()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildUI() {
|
||||||
|
let titleText = editing == nil ? "Create Channel" : "Edit Channel"
|
||||||
|
let titleLabel = NSTextField(labelWithString: titleText)
|
||||||
|
titleLabel.font = .boldSystemFont(ofSize: 13)
|
||||||
|
|
||||||
|
nameField.placeholderString = "Channel name"
|
||||||
|
nameField.setAccessibilityLabel("Channel name")
|
||||||
|
topicField.placeholderString = "Topic (optional)"
|
||||||
|
topicField.setAccessibilityLabel("Channel topic")
|
||||||
|
|
||||||
|
parentPicker.addItem(withTitle: "(root)")
|
||||||
|
parentPicker.menu?.items.last?.representedObject = nil as UInt32?
|
||||||
|
for ch in channels.sorted(by: { $0.name < $1.name }) {
|
||||||
|
let item = NSMenuItem(title: ch.name, action: nil, keyEquivalent: "")
|
||||||
|
item.representedObject = ch.id
|
||||||
|
parentPicker.menu?.addItem(item)
|
||||||
|
}
|
||||||
|
parentPicker.setAccessibilityLabel("Parent channel")
|
||||||
|
|
||||||
|
pwCheckbox.target = self; pwCheckbox.action = #selector(pwToggled)
|
||||||
|
pwField.placeholderString = "password (leave blank to keep existing)"
|
||||||
|
pwField.setAccessibilityLabel("Channel password")
|
||||||
|
pwField.isEnabled = false
|
||||||
|
|
||||||
|
maxUsersField.setAccessibilityLabel("Max users (0 = unlimited)")
|
||||||
|
sortOrderField.setAccessibilityLabel("Sort order")
|
||||||
|
|
||||||
|
for ms in ["20", "40", "60"] { frameMsPicker.addItem(withTitle: "\(ms) ms") }
|
||||||
|
frameMsPicker.setAccessibilityLabel("Opus frame duration")
|
||||||
|
|
||||||
|
fecCheckbox.state = .on
|
||||||
|
stereoCheckbox.setAccessibilityLabel("Stereo audio")
|
||||||
|
bitrateField.setAccessibilityLabel("Bitrate in bits per second")
|
||||||
|
fecCheckbox.setAccessibilityLabel("Forward error correction")
|
||||||
|
dtxCheckbox.setAccessibilityLabel("Discontinuous transmission")
|
||||||
|
|
||||||
|
let generalGrid = NSGridView(views: [
|
||||||
|
[NSTextField(labelWithString: "Name:"), nameField],
|
||||||
|
[NSTextField(labelWithString: "Topic:"), topicField],
|
||||||
|
[NSTextField(labelWithString: "Parent:"), parentPicker],
|
||||||
|
[pwCheckbox, pwField],
|
||||||
|
[NSTextField(labelWithString: "Max users:"), maxUsersField],
|
||||||
|
[NSTextField(labelWithString: "Sort order:"), sortOrderField],
|
||||||
|
])
|
||||||
|
generalGrid.rowSpacing = 8; generalGrid.columnSpacing = 8
|
||||||
|
generalGrid.column(at: 0).xPlacement = .trailing
|
||||||
|
|
||||||
|
let audioGrid = NSGridView(views: [
|
||||||
|
[NSTextField(labelWithString: "Bitrate:"), bitrateField],
|
||||||
|
[NSTextField(labelWithString: "Frame:"), frameMsPicker],
|
||||||
|
[stereoCheckbox, fecCheckbox],
|
||||||
|
[dtxCheckbox, NSView()],
|
||||||
|
])
|
||||||
|
audioGrid.rowSpacing = 8; audioGrid.columnSpacing = 8
|
||||||
|
audioGrid.column(at: 0).xPlacement = .trailing
|
||||||
|
|
||||||
|
let tabs = NSTabView()
|
||||||
|
let generalTab = NSTabViewItem(identifier: "general")
|
||||||
|
generalTab.label = "General"
|
||||||
|
generalTab.view = generalGrid
|
||||||
|
let audioTab = NSTabViewItem(identifier: "audio")
|
||||||
|
audioTab.label = "Audio"
|
||||||
|
audioTab.view = audioGrid
|
||||||
|
tabs.addTabViewItem(generalTab)
|
||||||
|
tabs.addTabViewItem(audioTab)
|
||||||
|
|
||||||
|
let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(cancelClicked))
|
||||||
|
cancelButton.bezelStyle = .rounded
|
||||||
|
let saveButton = NSButton(title: editing == nil ? "Create" : "Save",
|
||||||
|
target: self, action: #selector(saveClicked))
|
||||||
|
saveButton.bezelStyle = .rounded; saveButton.keyEquivalent = "\r"
|
||||||
|
|
||||||
|
let buttonRow = NSStackView(views: [NSView(), cancelButton, saveButton])
|
||||||
|
buttonRow.orientation = .horizontal; buttonRow.spacing = 8
|
||||||
|
|
||||||
|
let stack = NSStackView(views: [titleLabel, tabs, buttonRow])
|
||||||
|
stack.orientation = .vertical
|
||||||
|
stack.spacing = 12
|
||||||
|
stack.edgeInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(stack)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
stack.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func populateIfEditing() {
|
||||||
|
guard let e = editing else { return }
|
||||||
|
nameField.stringValue = e.name
|
||||||
|
topicField.stringValue = e.topic
|
||||||
|
if e.parentId != 0 {
|
||||||
|
for item in parentPicker.itemArray where (item.representedObject as? UInt32) == e.parentId {
|
||||||
|
parentPicker.select(item); break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pwCheckbox.state = e.passwordProtected ? .on : .off
|
||||||
|
pwField.isEnabled = e.passwordProtected
|
||||||
|
maxUsersField.stringValue = "\(e.maxUsers)"
|
||||||
|
sortOrderField.stringValue = "\(e.sortOrder)"
|
||||||
|
stereoCheckbox.state = e.audio.stereo ? .on : .off
|
||||||
|
bitrateField.stringValue = "\(e.audio.bitrateBps)"
|
||||||
|
fecCheckbox.state = e.audio.fec ? .on : .off
|
||||||
|
dtxCheckbox.state = e.audio.dtx ? .on : .off
|
||||||
|
let frameStr = "\(e.audio.frameMs) ms"
|
||||||
|
if let item = frameMsPicker.item(withTitle: frameStr) { frameMsPicker.select(item) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func pwToggled() {
|
||||||
|
pwField.isEnabled = pwCheckbox.state == .on
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func saveClicked() {
|
||||||
|
let name = nameField.stringValue.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !name.isEmpty else { nameField.becomeFirstResponder(); return }
|
||||||
|
|
||||||
|
let parentId = parentPicker.selectedItem?.representedObject as? UInt32 ?? 0
|
||||||
|
let maxUsers = UInt32(maxUsersField.stringValue) ?? 0
|
||||||
|
let sortOrder = UInt32(sortOrderField.stringValue) ?? 0
|
||||||
|
let bitrate = UInt32(bitrateField.stringValue) ?? 64000
|
||||||
|
let frameMsStr = frameMsPicker.titleOfSelectedItem?.replacingOccurrences(of: " ms", with: "") ?? "20"
|
||||||
|
let frameMs = UInt32(frameMsStr) ?? 20
|
||||||
|
|
||||||
|
let audio = AudioConfig(
|
||||||
|
stereo: stereoCheckbox.state == .on,
|
||||||
|
bitrateBps: bitrate,
|
||||||
|
frameMs: frameMs,
|
||||||
|
fec: fecCheckbox.state == .on,
|
||||||
|
dtx: dtxCheckbox.state == .on
|
||||||
|
)
|
||||||
|
let pwProtected = pwCheckbox.state == .on
|
||||||
|
let pw: String? = pwProtected ? (pwField.stringValue.isEmpty ? nil : pwField.stringValue) : nil
|
||||||
|
|
||||||
|
let result = ChannelEdit(
|
||||||
|
id: editing?.id ?? 0,
|
||||||
|
parentId: parentId,
|
||||||
|
name: name,
|
||||||
|
topic: topicField.stringValue,
|
||||||
|
passwordProtected: pwProtected,
|
||||||
|
password: pw,
|
||||||
|
maxUsers: maxUsers,
|
||||||
|
sortOrder: sortOrder,
|
||||||
|
audio: audio
|
||||||
|
)
|
||||||
|
dismiss(nil)
|
||||||
|
onComplete?(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cancelClicked() {
|
||||||
|
dismiss(nil)
|
||||||
|
onComplete?(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
79
clients/apple/macOS/VoiceCatMac/Sheets/InputSheet.swift
Normal file
79
clients/apple/macOS/VoiceCatMac/Sheets/InputSheet.swift
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
final class InputSheet: NSViewController {
|
||||||
|
|
||||||
|
var onComplete: ((String?) -> Void)?
|
||||||
|
|
||||||
|
private let sheetTitle: String
|
||||||
|
private let prompt: String
|
||||||
|
private let defaultValue: String
|
||||||
|
private let inputField = NSTextField()
|
||||||
|
|
||||||
|
init(title: String, prompt: String, defaultValue: String = "") {
|
||||||
|
self.sheetTitle = title
|
||||||
|
self.prompt = prompt
|
||||||
|
self.defaultValue = defaultValue
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
override func loadView() {
|
||||||
|
view = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 120))
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
buildUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildUI() {
|
||||||
|
let titleLabel = NSTextField(labelWithString: sheetTitle)
|
||||||
|
titleLabel.font = .boldSystemFont(ofSize: 13)
|
||||||
|
|
||||||
|
let promptLabel = NSTextField(labelWithString: prompt)
|
||||||
|
promptLabel.setAccessibilityLabel(prompt)
|
||||||
|
|
||||||
|
inputField.stringValue = defaultValue
|
||||||
|
inputField.setAccessibilityLabel(prompt)
|
||||||
|
inputField.target = self; inputField.action = #selector(okClicked)
|
||||||
|
|
||||||
|
let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(cancelClicked))
|
||||||
|
cancelButton.bezelStyle = .rounded
|
||||||
|
|
||||||
|
let okButton = NSButton(title: "OK", target: self, action: #selector(okClicked))
|
||||||
|
okButton.bezelStyle = .rounded; okButton.keyEquivalent = "\r"
|
||||||
|
|
||||||
|
let buttonRow = NSStackView(views: [NSView(), cancelButton, okButton])
|
||||||
|
buttonRow.orientation = .horizontal; buttonRow.spacing = 8
|
||||||
|
|
||||||
|
let stack = NSStackView(views: [titleLabel, promptLabel, inputField, buttonRow])
|
||||||
|
stack.orientation = .vertical; stack.spacing = 8
|
||||||
|
stack.edgeInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(stack)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
stack.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear() {
|
||||||
|
super.viewDidAppear()
|
||||||
|
inputField.becomeFirstResponder()
|
||||||
|
inputField.selectText(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func okClicked() {
|
||||||
|
let value = inputField.stringValue
|
||||||
|
dismiss(nil)
|
||||||
|
onComplete?(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cancelClicked() {
|
||||||
|
dismiss(nil)
|
||||||
|
onComplete?(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
73
clients/apple/macOS/VoiceCatMac/Sheets/MoveUserSheet.swift
Normal file
73
clients/apple/macOS/VoiceCatMac/Sheets/MoveUserSheet.swift
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import AppKit
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
final class MoveUserSheet: NSViewController {
|
||||||
|
|
||||||
|
var onComplete: ((UInt32?) -> Void)?
|
||||||
|
|
||||||
|
private let channels: [Channel]
|
||||||
|
private let currentChannelId: UInt32
|
||||||
|
private let picker = NSPopUpButton()
|
||||||
|
|
||||||
|
init(channels: [Channel], currentChannelId: UInt32) {
|
||||||
|
self.channels = channels.sorted(by: { $0.name < $1.name })
|
||||||
|
self.currentChannelId = currentChannelId
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
override func loadView() {
|
||||||
|
view = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
buildUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildUI() {
|
||||||
|
let label = NSTextField(labelWithString: "Move user to channel:")
|
||||||
|
label.setAccessibilityLabel("Select destination channel")
|
||||||
|
|
||||||
|
for ch in channels where ch.id != currentChannelId {
|
||||||
|
let item = NSMenuItem(title: ch.name, action: nil, keyEquivalent: "")
|
||||||
|
item.representedObject = ch.id
|
||||||
|
picker.menu?.addItem(item)
|
||||||
|
}
|
||||||
|
picker.setAccessibilityLabel("Destination channel")
|
||||||
|
|
||||||
|
let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(cancelClicked))
|
||||||
|
cancelButton.bezelStyle = .rounded
|
||||||
|
|
||||||
|
let moveButton = NSButton(title: "Move", target: self, action: #selector(moveClicked))
|
||||||
|
moveButton.bezelStyle = .rounded; moveButton.keyEquivalent = "\r"
|
||||||
|
moveButton.setAccessibilityLabel("Move user to selected channel")
|
||||||
|
|
||||||
|
let buttonRow = NSStackView(views: [NSView(), cancelButton, moveButton])
|
||||||
|
buttonRow.orientation = .horizontal; buttonRow.spacing = 8
|
||||||
|
|
||||||
|
let stack = NSStackView(views: [label, picker, buttonRow])
|
||||||
|
stack.orientation = .vertical; stack.spacing = 10
|
||||||
|
stack.edgeInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(stack)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
stack.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func moveClicked() {
|
||||||
|
let channelId = picker.selectedItem?.representedObject as? UInt32
|
||||||
|
dismiss(nil)
|
||||||
|
onComplete?(channelId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cancelClicked() {
|
||||||
|
dismiss(nil)
|
||||||
|
onComplete?(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
final class PasswordPromptSheet: NSViewController {
|
||||||
|
|
||||||
|
var onComplete: ((String?) -> Void)?
|
||||||
|
|
||||||
|
private let prompt: String
|
||||||
|
private let passwordField = NSSecureTextField()
|
||||||
|
|
||||||
|
init(prompt: String) {
|
||||||
|
self.prompt = prompt
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
override func loadView() {
|
||||||
|
view = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 110))
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
buildUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildUI() {
|
||||||
|
let promptLabel = NSTextField(labelWithString: prompt)
|
||||||
|
promptLabel.lineBreakMode = .byWordWrapping
|
||||||
|
promptLabel.setAccessibilityLabel(prompt)
|
||||||
|
|
||||||
|
passwordField.placeholderString = "password"
|
||||||
|
passwordField.setAccessibilityLabel("Password")
|
||||||
|
passwordField.target = self; passwordField.action = #selector(okClicked)
|
||||||
|
|
||||||
|
let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(cancelClicked))
|
||||||
|
cancelButton.bezelStyle = .rounded
|
||||||
|
cancelButton.setAccessibilityLabel("Cancel")
|
||||||
|
|
||||||
|
let okButton = NSButton(title: "OK", target: self, action: #selector(okClicked))
|
||||||
|
okButton.bezelStyle = .rounded
|
||||||
|
okButton.keyEquivalent = "\r"
|
||||||
|
okButton.setAccessibilityLabel("Submit password")
|
||||||
|
|
||||||
|
let buttonRow = NSStackView(views: [NSView(), cancelButton, okButton])
|
||||||
|
buttonRow.orientation = .horizontal; buttonRow.spacing = 8
|
||||||
|
|
||||||
|
let stack = NSStackView(views: [promptLabel, passwordField, buttonRow])
|
||||||
|
stack.orientation = .vertical
|
||||||
|
stack.spacing = 10
|
||||||
|
stack.edgeInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(stack)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
stack.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear() {
|
||||||
|
super.viewDidAppear()
|
||||||
|
passwordField.becomeFirstResponder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func okClicked() {
|
||||||
|
let pw = passwordField.stringValue
|
||||||
|
dismiss(nil)
|
||||||
|
onComplete?(pw)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cancelClicked() {
|
||||||
|
dismiss(nil)
|
||||||
|
onComplete?(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
123
clients/apple/macOS/VoiceCatMac/Sheets/PerUserTuningSheet.swift
Normal file
123
clients/apple/macOS/VoiceCatMac/Sheets/PerUserTuningSheet.swift
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import AppKit
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
final class PerUserTuningSheet: NSViewController {
|
||||||
|
|
||||||
|
private let client: VoiceCatClient
|
||||||
|
private let userId: UInt32
|
||||||
|
private let nickname: String
|
||||||
|
|
||||||
|
private var streams: [StreamSummary] = []
|
||||||
|
private var rows: [StreamRow] = []
|
||||||
|
|
||||||
|
private struct StreamRow {
|
||||||
|
let streamId: UInt32
|
||||||
|
let label: String
|
||||||
|
let gainSlider: NSSlider
|
||||||
|
let muteCheckbox: NSButton
|
||||||
|
let nrCheckbox: NSButton
|
||||||
|
}
|
||||||
|
|
||||||
|
init(client: VoiceCatClient, userId: UInt32, nickname: String) {
|
||||||
|
self.client = client
|
||||||
|
self.userId = userId
|
||||||
|
self.nickname = nickname
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
override func loadView() {
|
||||||
|
view = NSView(frame: NSRect(x: 0, y: 0, width: 400, height: 200))
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
streams = client.listUserStreams(userId)
|
||||||
|
buildUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildUI() {
|
||||||
|
let titleLabel = NSTextField(labelWithString: "Volume & Noise Settings — \(nickname)")
|
||||||
|
titleLabel.font = .boldSystemFont(ofSize: 13)
|
||||||
|
|
||||||
|
var rowViews: [NSView] = [titleLabel]
|
||||||
|
|
||||||
|
for stream in streams {
|
||||||
|
let (_, state) = client.getRemoteStream(userId: userId, streamId: stream.id)
|
||||||
|
let gain = state?.gain ?? 1.0
|
||||||
|
let muted = state?.muted ?? false
|
||||||
|
let nr = state?.noiseReduction ?? false
|
||||||
|
|
||||||
|
let gainSlider = NSSlider(value: Double(gain * 100), minValue: 0, maxValue: 200, target: self, action: #selector(sliderChanged))
|
||||||
|
gainSlider.tag = Int(stream.id)
|
||||||
|
gainSlider.numberOfTickMarks = 0
|
||||||
|
gainSlider.setAccessibilityLabel("Volume for \(stream.label): \(Int(gain * 100)) percent")
|
||||||
|
|
||||||
|
let muteCheckbox = NSButton(checkboxWithTitle: "Mute", target: self, action: #selector(muteChanged))
|
||||||
|
muteCheckbox.state = muted ? .on : .off
|
||||||
|
muteCheckbox.tag = Int(stream.id)
|
||||||
|
muteCheckbox.setAccessibilityLabel("Mute \(stream.label)")
|
||||||
|
|
||||||
|
let nrCheckbox = NSButton(checkboxWithTitle: "Noise reduction", target: self, action: #selector(nrChanged))
|
||||||
|
nrCheckbox.state = nr ? .on : .off
|
||||||
|
nrCheckbox.tag = Int(stream.id)
|
||||||
|
nrCheckbox.setAccessibilityLabel("Noise reduction for \(stream.label)")
|
||||||
|
|
||||||
|
let streamLabel = NSTextField(labelWithString: "\(stream.label):")
|
||||||
|
let gainLabel = NSTextField(labelWithString: "Volume:")
|
||||||
|
let row = NSStackView(views: [streamLabel, gainLabel, gainSlider, muteCheckbox, nrCheckbox])
|
||||||
|
row.orientation = .horizontal; row.spacing = 8
|
||||||
|
rowViews.append(row)
|
||||||
|
|
||||||
|
rows.append(StreamRow(streamId: stream.id, label: stream.label,
|
||||||
|
gainSlider: gainSlider, muteCheckbox: muteCheckbox,
|
||||||
|
nrCheckbox: nrCheckbox))
|
||||||
|
}
|
||||||
|
|
||||||
|
if streams.isEmpty {
|
||||||
|
rowViews.append(NSTextField(labelWithString: "This user has no active streams."))
|
||||||
|
}
|
||||||
|
|
||||||
|
let doneButton = NSButton(title: "Done", target: self, action: #selector(doneClicked))
|
||||||
|
doneButton.bezelStyle = .rounded; doneButton.keyEquivalent = "\r"
|
||||||
|
doneButton.setAccessibilityLabel("Close settings")
|
||||||
|
rowViews.append(NSStackView(views: [NSView(), doneButton]))
|
||||||
|
|
||||||
|
let stack = NSStackView(views: rowViews)
|
||||||
|
stack.orientation = .vertical
|
||||||
|
stack.spacing = 10
|
||||||
|
stack.edgeInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(stack)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
stack.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func sliderChanged(_ sender: NSSlider) {
|
||||||
|
apply(streamId: UInt32(sender.tag))
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func muteChanged(_ sender: NSButton) {
|
||||||
|
apply(streamId: UInt32(sender.tag))
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func nrChanged(_ sender: NSButton) {
|
||||||
|
apply(streamId: UInt32(sender.tag))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func apply(streamId: UInt32) {
|
||||||
|
guard let row = rows.first(where: { $0.streamId == streamId }) else { return }
|
||||||
|
let gain = Float(row.gainSlider.doubleValue) / 100.0
|
||||||
|
let muted = row.muteCheckbox.state == .on
|
||||||
|
let nr = row.nrCheckbox.state == .on
|
||||||
|
client.setRemoteStream(userId: userId, streamId: streamId,
|
||||||
|
gain: gain, muted: muted, noiseReduction: nr)
|
||||||
|
row.gainSlider.setAccessibilityLabel("Volume for \(row.label): \(Int(row.gainSlider.doubleValue)) percent")
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func doneClicked() { dismiss(nil) }
|
||||||
|
}
|
||||||
100
clients/apple/macOS/VoiceCatMac/Sheets/PermissionsSheet.swift
Normal file
100
clients/apple/macOS/VoiceCatMac/Sheets/PermissionsSheet.swift
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import AppKit
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
final class PermissionsSheet: NSViewController {
|
||||||
|
|
||||||
|
var onComplete: ((Permissions?) -> Void)?
|
||||||
|
|
||||||
|
private let targetNickname: String
|
||||||
|
private let initial: Permissions
|
||||||
|
|
||||||
|
private let canCreateTempChannelCB = NSButton(checkboxWithTitle: "Create temporary channels", target: nil, action: nil)
|
||||||
|
private let canKickCB = NSButton(checkboxWithTitle: "Kick users", target: nil, action: nil)
|
||||||
|
private let canBanCB = NSButton(checkboxWithTitle: "Ban users", target: nil, action: nil)
|
||||||
|
private let canMoveUsersCB = NSButton(checkboxWithTitle: "Move users between channels", target: nil, action: nil)
|
||||||
|
private let canAdminAccountsCB = NSButton(checkboxWithTitle: "Manage accounts", target: nil, action: nil)
|
||||||
|
private let isAdminCB = NSButton(checkboxWithTitle: "Full administrator", target: nil, action: nil)
|
||||||
|
|
||||||
|
init(nickname: String, current: Permissions) {
|
||||||
|
self.targetNickname = nickname
|
||||||
|
self.initial = current
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
override func loadView() {
|
||||||
|
view = NSView(frame: NSRect(x: 0, y: 0, width: 340, height: 250))
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
buildUI()
|
||||||
|
applyInitial()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildUI() {
|
||||||
|
let titleLabel = NSTextField(labelWithString: "Permissions — \(targetNickname)")
|
||||||
|
titleLabel.font = .boldSystemFont(ofSize: 13)
|
||||||
|
|
||||||
|
let checkboxes = [canCreateTempChannelCB, canKickCB, canBanCB,
|
||||||
|
canMoveUsersCB, canAdminAccountsCB, isAdminCB]
|
||||||
|
for cb in checkboxes {
|
||||||
|
cb.setAccessibilityLabel(cb.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(cancelClicked))
|
||||||
|
cancelButton.bezelStyle = .rounded
|
||||||
|
|
||||||
|
let saveButton = NSButton(title: "Save", target: self, action: #selector(saveClicked))
|
||||||
|
saveButton.bezelStyle = .rounded; saveButton.keyEquivalent = "\r"
|
||||||
|
saveButton.setAccessibilityLabel("Save permissions for \(targetNickname)")
|
||||||
|
|
||||||
|
let buttonRow = NSStackView(views: [NSView(), cancelButton, saveButton])
|
||||||
|
buttonRow.orientation = .horizontal; buttonRow.spacing = 8
|
||||||
|
|
||||||
|
var views: [NSView] = [titleLabel]
|
||||||
|
views.append(contentsOf: checkboxes)
|
||||||
|
views.append(buttonRow)
|
||||||
|
|
||||||
|
let stack = NSStackView(views: views)
|
||||||
|
stack.orientation = .vertical; stack.spacing = 8
|
||||||
|
stack.alignment = .leading
|
||||||
|
stack.edgeInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(stack)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
stack.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyInitial() {
|
||||||
|
canCreateTempChannelCB.state = initial.canCreateTempChannel ? .on : .off
|
||||||
|
canKickCB.state = initial.canKick ? .on : .off
|
||||||
|
canBanCB.state = initial.canBan ? .on : .off
|
||||||
|
canMoveUsersCB.state = initial.canMoveUsers ? .on : .off
|
||||||
|
canAdminAccountsCB.state = initial.canAdminAccounts ? .on : .off
|
||||||
|
isAdminCB.state = initial.isAdmin ? .on : .off
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func saveClicked() {
|
||||||
|
let perms = Permissions(
|
||||||
|
canCreateTempChannel: canCreateTempChannelCB.state == .on,
|
||||||
|
canKick: canKickCB.state == .on,
|
||||||
|
canBan: canBanCB.state == .on,
|
||||||
|
canMoveUsers: canMoveUsersCB.state == .on,
|
||||||
|
canAdminAccounts: canAdminAccountsCB.state == .on,
|
||||||
|
isAdmin: isAdminCB.state == .on
|
||||||
|
)
|
||||||
|
dismiss(nil)
|
||||||
|
onComplete?(perms)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cancelClicked() {
|
||||||
|
dismiss(nil)
|
||||||
|
onComplete?(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
103
clients/apple/macOS/VoiceCatMac/Sheets/PttKeyCaptureSheet.swift
Normal file
103
clients/apple/macOS/VoiceCatMac/Sheets/PttKeyCaptureSheet.swift
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
final class PttKeyCaptureSheet: NSViewController {
|
||||||
|
|
||||||
|
var onComplete: ((UInt16?) -> Void)?
|
||||||
|
|
||||||
|
private let currentKeyCode: UInt16
|
||||||
|
private var capturedKeyCode: UInt16?
|
||||||
|
private let instructionLabel = NSTextField(labelWithString: "Press the key you want to use for push-to-talk…")
|
||||||
|
private let captureView = KeyCaptureView()
|
||||||
|
|
||||||
|
init(currentKeyCode: UInt16) {
|
||||||
|
self.currentKeyCode = currentKeyCode
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
override func loadView() {
|
||||||
|
view = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 140))
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
buildUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildUI() {
|
||||||
|
let titleLabel = NSTextField(labelWithString: "Set Push-to-Talk Key")
|
||||||
|
titleLabel.font = .boldSystemFont(ofSize: 13)
|
||||||
|
|
||||||
|
instructionLabel.textColor = .secondaryLabelColor
|
||||||
|
instructionLabel.lineBreakMode = .byWordWrapping
|
||||||
|
instructionLabel.setAccessibilityLabel("Waiting for key press")
|
||||||
|
|
||||||
|
captureView.setAccessibilityLabel("Key capture area — press any key")
|
||||||
|
captureView.setAccessibilityRole(.textArea)
|
||||||
|
captureView.onKeyPressed = { [weak self] keyCode in
|
||||||
|
self?.capturedKeyCode = keyCode
|
||||||
|
self?.instructionLabel.stringValue = "Key captured: \(keyCodeName(keyCode)). Click Set to confirm."
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(cancelClicked))
|
||||||
|
cancelButton.bezelStyle = .rounded
|
||||||
|
|
||||||
|
let setButton = NSButton(title: "Set", target: self, action: #selector(setClicked))
|
||||||
|
setButton.bezelStyle = .rounded; setButton.keyEquivalent = "\r"
|
||||||
|
setButton.setAccessibilityLabel("Set captured key as PTT key")
|
||||||
|
|
||||||
|
let buttonRow = NSStackView(views: [NSView(), cancelButton, setButton])
|
||||||
|
buttonRow.orientation = .horizontal; buttonRow.spacing = 8
|
||||||
|
|
||||||
|
captureView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
captureView.wantsLayer = true
|
||||||
|
captureView.layer?.backgroundColor = NSColor.controlBackgroundColor.cgColor
|
||||||
|
captureView.layer?.cornerRadius = 4
|
||||||
|
|
||||||
|
let stack = NSStackView(views: [titleLabel, instructionLabel, captureView, buttonRow])
|
||||||
|
stack.orientation = .vertical; stack.spacing = 10
|
||||||
|
stack.edgeInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(stack)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
stack.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
captureView.heightAnchor.constraint(equalToConstant: 28),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear() {
|
||||||
|
super.viewDidAppear()
|
||||||
|
view.window?.makeFirstResponder(captureView)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func setClicked() {
|
||||||
|
let key = capturedKeyCode
|
||||||
|
dismiss(nil)
|
||||||
|
onComplete?(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cancelClicked() {
|
||||||
|
dismiss(nil)
|
||||||
|
onComplete?(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Key capture view
|
||||||
|
|
||||||
|
private final class KeyCaptureView: NSView {
|
||||||
|
var onKeyPressed: ((UInt16) -> Void)?
|
||||||
|
override var acceptsFirstResponder: Bool { true }
|
||||||
|
|
||||||
|
override func keyDown(with event: NSEvent) {
|
||||||
|
onKeyPressed?(event.keyCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func drawFocusRingMask() {
|
||||||
|
NSBezierPath(roundedRect: bounds, xRadius: 4, yRadius: 4).fill()
|
||||||
|
}
|
||||||
|
override var focusRingMaskBounds: NSRect { bounds }
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import AppKit
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
final class ServerIdentitySheet: NSViewController {
|
||||||
|
|
||||||
|
var onComplete: ((Bool) -> Void)?
|
||||||
|
|
||||||
|
private let tofuStatus: VoiceCatTofuStatus
|
||||||
|
private let displayFingerprint: String
|
||||||
|
|
||||||
|
init(tofuStatus: VoiceCatTofuStatus, displayFingerprint: String) {
|
||||||
|
self.tofuStatus = tofuStatus
|
||||||
|
self.displayFingerprint = displayFingerprint
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
override func loadView() {
|
||||||
|
view = NSView(frame: NSRect(x: 0, y: 0, width: 440, height: 220))
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
buildUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildUI() {
|
||||||
|
let isMismatch = tofuStatus == .mismatch
|
||||||
|
let titleText = isMismatch ? "Server Identity Mismatch — Possible MITM!" : "New Server Identity"
|
||||||
|
let titleLabel = NSTextField(labelWithString: titleText)
|
||||||
|
titleLabel.font = .boldSystemFont(ofSize: 14)
|
||||||
|
if isMismatch { titleLabel.textColor = .systemRed }
|
||||||
|
titleLabel.setAccessibilityLabel(titleText)
|
||||||
|
|
||||||
|
let bodyText: String
|
||||||
|
if isMismatch {
|
||||||
|
bodyText = "The server's identity has changed since you last connected. 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."
|
||||||
|
} else {
|
||||||
|
bodyText = "This is the first time you are connecting to this server. Verify the fingerprint below with the server administrator before accepting."
|
||||||
|
}
|
||||||
|
let bodyLabel = NSTextField(wrappingLabelWithString: bodyText)
|
||||||
|
bodyLabel.textColor = .labelColor
|
||||||
|
|
||||||
|
let fpLabel = NSTextField(labelWithString: "Server fingerprint:")
|
||||||
|
let fpField = NSTextField(labelWithString: displayFingerprint.isEmpty ? "(not available)" : displayFingerprint)
|
||||||
|
fpField.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular)
|
||||||
|
fpField.isSelectable = true
|
||||||
|
fpField.setAccessibilityLabel("Server identity fingerprint: \(displayFingerprint)")
|
||||||
|
|
||||||
|
let rejectButton = NSButton(title: "Reject (Disconnect)", target: self, action: #selector(rejectClicked))
|
||||||
|
rejectButton.bezelStyle = .rounded
|
||||||
|
rejectButton.setAccessibilityLabel("Reject server identity and disconnect")
|
||||||
|
if isMismatch { rejectButton.keyEquivalent = "\r" }
|
||||||
|
|
||||||
|
let acceptButton = NSButton(title: "Accept", target: self, action: #selector(acceptClicked))
|
||||||
|
acceptButton.bezelStyle = .rounded
|
||||||
|
if !isMismatch { acceptButton.keyEquivalent = "\r" }
|
||||||
|
acceptButton.setAccessibilityLabel("Accept server identity and continue connecting")
|
||||||
|
|
||||||
|
let buttonRow = NSStackView(views: [NSView(), rejectButton, acceptButton])
|
||||||
|
buttonRow.orientation = .horizontal; buttonRow.spacing = 8
|
||||||
|
|
||||||
|
let fpRow = NSStackView(views: [fpLabel, fpField])
|
||||||
|
fpRow.orientation = .horizontal; fpRow.spacing = 8
|
||||||
|
|
||||||
|
let stack = NSStackView(views: [titleLabel, bodyLabel, fpRow, buttonRow])
|
||||||
|
stack.orientation = .vertical
|
||||||
|
stack.spacing = 12
|
||||||
|
stack.edgeInsets = NSEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(stack)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
stack.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func acceptClicked() {
|
||||||
|
dismiss(nil)
|
||||||
|
onComplete?(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func rejectClicked() {
|
||||||
|
dismiss(nil)
|
||||||
|
onComplete?(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
137
clients/apple/macOS/VoiceCatMac/Sheets/UserPickerSheet.swift
Normal file
137
clients/apple/macOS/VoiceCatMac/Sheets/UserPickerSheet.swift
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import AppKit
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
// UserPickerSheet — a modal sheet for picking one user from the list of all connected server
|
||||||
|
// users. Used by the "Messages → New Private Message…" menu item so the user can start a PM
|
||||||
|
// with anyone on the server, not just the current channel. Mirrors the Windows client's
|
||||||
|
// `UserPickerDialog` (clients/windows/VoiceCat.App/Forms/UserPickerDialog.cs), adapted to the
|
||||||
|
// Mac sheet pattern used by the rest of the macOS client (InputSheet, MoveUserSheet, etc.).
|
||||||
|
|
||||||
|
final class UserPickerSheet: NSViewController, NSTableViewDataSource, NSTableViewDelegate {
|
||||||
|
|
||||||
|
/// Called with the selected user's ID, or `nil` if the user cancelled.
|
||||||
|
var onComplete: ((UInt32?) -> Void)?
|
||||||
|
|
||||||
|
private let users: [User]
|
||||||
|
private let tableView = NSTableView()
|
||||||
|
private var okButton: NSButton?
|
||||||
|
private var selectedRow: Int = -1
|
||||||
|
|
||||||
|
init(users: [User]) {
|
||||||
|
self.users = users
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
override func loadView() {
|
||||||
|
view = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: 320))
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
buildUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UI
|
||||||
|
|
||||||
|
private func buildUI() {
|
||||||
|
let titleLabel = NSTextField(labelWithString: "Select a user:")
|
||||||
|
titleLabel.font = .boldSystemFont(ofSize: 13)
|
||||||
|
titleLabel.setAccessibilityLabel("Select a user")
|
||||||
|
|
||||||
|
let col = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("user"))
|
||||||
|
tableView.addTableColumn(col)
|
||||||
|
tableView.headerView = nil
|
||||||
|
tableView.dataSource = self
|
||||||
|
tableView.delegate = self
|
||||||
|
tableView.doubleAction = #selector(okClicked)
|
||||||
|
tableView.target = self
|
||||||
|
tableView.setAccessibilityLabel("User list")
|
||||||
|
|
||||||
|
let scroll = NSScrollView()
|
||||||
|
scroll.documentView = tableView
|
||||||
|
scroll.hasVerticalScroller = true
|
||||||
|
scroll.borderType = .bezelBorder
|
||||||
|
scroll.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(cancelClicked))
|
||||||
|
cancelButton.bezelStyle = .rounded
|
||||||
|
|
||||||
|
let okButton = NSButton(title: "OK", target: self, action: #selector(okClicked))
|
||||||
|
okButton.bezelStyle = .rounded
|
||||||
|
okButton.keyEquivalent = "\r"
|
||||||
|
okButton.isEnabled = false
|
||||||
|
self.okButton = okButton
|
||||||
|
|
||||||
|
let buttonRow = NSStackView(views: [NSView(), cancelButton, okButton])
|
||||||
|
buttonRow.orientation = .horizontal
|
||||||
|
buttonRow.spacing = 8
|
||||||
|
|
||||||
|
let stack = NSStackView(views: [titleLabel, scroll, buttonRow])
|
||||||
|
stack.orientation = .vertical
|
||||||
|
stack.spacing = 8
|
||||||
|
stack.edgeInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(stack)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
stack.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear() {
|
||||||
|
super.viewDidAppear()
|
||||||
|
if users.count == 1 {
|
||||||
|
tableView.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
@objc private func okClicked() {
|
||||||
|
guard selectedRow >= 0, selectedRow < users.count else { return }
|
||||||
|
let userId = users[selectedRow].id
|
||||||
|
dismiss(nil)
|
||||||
|
onComplete?(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cancelClicked() {
|
||||||
|
dismiss(nil)
|
||||||
|
onComplete?(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - NSTableViewDataSource / Delegate
|
||||||
|
|
||||||
|
func numberOfRows(in tableView: NSTableView) -> Int { users.count }
|
||||||
|
|
||||||
|
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
|
||||||
|
let id = NSUserInterfaceItemIdentifier("userCell")
|
||||||
|
let cell = tableView.makeView(withIdentifier: id, owner: nil) as? NSTableCellView
|
||||||
|
?? makeCellView(identifier: id)
|
||||||
|
cell.textField?.stringValue = users[row].nickname
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableViewSelectionDidChange(_ notification: Notification) {
|
||||||
|
selectedRow = tableView.selectedRow
|
||||||
|
okButton?.isEnabled = selectedRow >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeCellView(identifier: NSUserInterfaceItemIdentifier) -> NSTableCellView {
|
||||||
|
let cell = NSTableCellView()
|
||||||
|
cell.identifier = identifier
|
||||||
|
let tf = NSTextField(labelWithString: "")
|
||||||
|
tf.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
cell.addSubview(tf)
|
||||||
|
cell.textField = tf
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
tf.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 4),
|
||||||
|
tf.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -4),
|
||||||
|
tf.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
|
||||||
|
])
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
}
|
||||||
6
clients/apple/macOS/VoiceCatMac/VoiceCatMac.entitlements
Normal file
6
clients/apple/macOS/VoiceCatMac/VoiceCatMac.entitlements
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?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>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
import AppKit
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
final class ConnectWindowController: NSWindowController, NSWindowDelegate {
|
||||||
|
|
||||||
|
// MARK: - UI
|
||||||
|
|
||||||
|
private let serverTableView = NSTableView()
|
||||||
|
private let serverScrollView = NSScrollView()
|
||||||
|
private let addButton = NSButton()
|
||||||
|
private let editButton = NSButton()
|
||||||
|
private let removeButton = NSButton()
|
||||||
|
private let connectButton = NSButton()
|
||||||
|
private let statusLabel = NSTextField(labelWithString: "Select a server and click Connect.")
|
||||||
|
|
||||||
|
// MARK: - State
|
||||||
|
|
||||||
|
private var servers: [SavedServer] = ServerListStore.load()
|
||||||
|
private var client: VoiceCatClient?
|
||||||
|
private var identityDialogShown = false
|
||||||
|
private var mainWindowController: MainWindowController?
|
||||||
|
|
||||||
|
// MARK: - Init
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let window = NSWindow(
|
||||||
|
contentRect: NSRect(x: 0, y: 0, width: 420, height: 320),
|
||||||
|
styleMask: [.titled, .closable, .miniaturizable],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
window.title = "VoiceCat — Connect"
|
||||||
|
window.center()
|
||||||
|
super.init(window: window)
|
||||||
|
window.delegate = self
|
||||||
|
buildUI()
|
||||||
|
refreshServerList()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
// MARK: - UI construction
|
||||||
|
|
||||||
|
private func buildUI() {
|
||||||
|
guard let contentView = window?.contentView else { return }
|
||||||
|
|
||||||
|
// Server list
|
||||||
|
let col = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("server"))
|
||||||
|
col.title = "Saved Servers"
|
||||||
|
serverTableView.addTableColumn(col)
|
||||||
|
serverTableView.headerView = nil
|
||||||
|
serverTableView.dataSource = self
|
||||||
|
serverTableView.delegate = self
|
||||||
|
serverTableView.doubleAction = #selector(connectClicked)
|
||||||
|
serverTableView.target = self
|
||||||
|
serverTableView.setAccessibilityLabel("Saved servers")
|
||||||
|
|
||||||
|
serverScrollView.documentView = serverTableView
|
||||||
|
serverScrollView.hasVerticalScroller = true
|
||||||
|
serverScrollView.borderType = .bezelBorder
|
||||||
|
serverScrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
contentView.addSubview(serverScrollView)
|
||||||
|
|
||||||
|
// Buttons row
|
||||||
|
configureButton(addButton, title: "Add…", action: #selector(addClicked))
|
||||||
|
configureButton(editButton, title: "Edit…", action: #selector(editClicked))
|
||||||
|
configureButton(removeButton, title: "Remove", action: #selector(removeClicked))
|
||||||
|
|
||||||
|
let buttonStack = NSStackView(views: [addButton, editButton, removeButton, NSView()])
|
||||||
|
buttonStack.orientation = .horizontal
|
||||||
|
buttonStack.spacing = 8
|
||||||
|
buttonStack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
contentView.addSubview(buttonStack)
|
||||||
|
|
||||||
|
// Status
|
||||||
|
statusLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
statusLabel.textColor = .secondaryLabelColor
|
||||||
|
statusLabel.setAccessibilityLabel("Connection status")
|
||||||
|
contentView.addSubview(statusLabel)
|
||||||
|
|
||||||
|
// Connect button
|
||||||
|
connectButton.title = "Connect"
|
||||||
|
connectButton.bezelStyle = .rounded
|
||||||
|
connectButton.keyEquivalent = "\r"
|
||||||
|
connectButton.target = self
|
||||||
|
connectButton.action = #selector(connectClicked)
|
||||||
|
connectButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
connectButton.setAccessibilityLabel("Connect to selected server")
|
||||||
|
contentView.addSubview(connectButton)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
serverScrollView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12),
|
||||||
|
serverScrollView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12),
|
||||||
|
serverScrollView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12),
|
||||||
|
serverScrollView.bottomAnchor.constraint(equalTo: buttonStack.topAnchor, constant: -8),
|
||||||
|
|
||||||
|
buttonStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12),
|
||||||
|
buttonStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12),
|
||||||
|
buttonStack.bottomAnchor.constraint(equalTo: statusLabel.topAnchor, constant: -12),
|
||||||
|
|
||||||
|
statusLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12),
|
||||||
|
statusLabel.trailingAnchor.constraint(equalTo: connectButton.leadingAnchor, constant: -8),
|
||||||
|
statusLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -16),
|
||||||
|
|
||||||
|
connectButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12),
|
||||||
|
connectButton.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -12),
|
||||||
|
connectButton.widthAnchor.constraint(equalToConstant: 90),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureButton(_ button: NSButton, title: String, action: Selector) {
|
||||||
|
button.title = title
|
||||||
|
button.bezelStyle = .rounded
|
||||||
|
button.target = self
|
||||||
|
button.action = action
|
||||||
|
button.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Server list management
|
||||||
|
|
||||||
|
private func refreshServerList() {
|
||||||
|
serverTableView.reloadData()
|
||||||
|
updateButtonStates()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateButtonStates() {
|
||||||
|
let hasSelection = serverTableView.selectedRow >= 0
|
||||||
|
connectButton.isEnabled = hasSelection
|
||||||
|
editButton.isEnabled = hasSelection
|
||||||
|
removeButton.isEnabled = hasSelection
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func addClicked() {
|
||||||
|
let sheet = AddServerSheet(editing: nil)
|
||||||
|
sheet.onComplete = { [weak self] server in
|
||||||
|
guard let self, let server else { return }
|
||||||
|
self.servers.append(server)
|
||||||
|
ServerListStore.save(self.servers)
|
||||||
|
self.refreshServerList()
|
||||||
|
let newRow = self.servers.count - 1
|
||||||
|
self.serverTableView.selectRowIndexes(IndexSet(integer: newRow), byExtendingSelection: false)
|
||||||
|
}
|
||||||
|
presentSheet(sheet)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func editClicked() {
|
||||||
|
let row = serverTableView.selectedRow
|
||||||
|
guard row >= 0 else { return }
|
||||||
|
let existing = servers[row]
|
||||||
|
let sheet = AddServerSheet(editing: existing)
|
||||||
|
sheet.onComplete = { [weak self] server in
|
||||||
|
guard let self, let server else { return }
|
||||||
|
self.servers[row] = server
|
||||||
|
ServerListStore.save(self.servers)
|
||||||
|
self.refreshServerList()
|
||||||
|
}
|
||||||
|
presentSheet(sheet)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func removeClicked() {
|
||||||
|
let row = serverTableView.selectedRow
|
||||||
|
guard row >= 0 else { return }
|
||||||
|
let server = servers[row]
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.messageText = "Remove server?"
|
||||||
|
alert.informativeText = "Remove '\(server.displayString)' from the saved-server list?"
|
||||||
|
alert.addButton(withTitle: "Remove")
|
||||||
|
alert.addButton(withTitle: "Cancel")
|
||||||
|
alert.alertStyle = .warning
|
||||||
|
guard let window else { return }
|
||||||
|
alert.beginSheetModal(for: window) { [weak self] response in
|
||||||
|
guard response == .alertFirstButtonReturn, let self else { return }
|
||||||
|
if let tag = self.servers[row].keychainTag {
|
||||||
|
ServerListStore.deletePassword(tag: tag)
|
||||||
|
}
|
||||||
|
self.servers.remove(at: row)
|
||||||
|
ServerListStore.save(self.servers)
|
||||||
|
self.refreshServerList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Connect flow
|
||||||
|
|
||||||
|
@objc private func connectClicked() {
|
||||||
|
let row = serverTableView.selectedRow
|
||||||
|
guard row >= 0 else { return }
|
||||||
|
startConnect(server: servers[row])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startConnect(server: SavedServer) {
|
||||||
|
setBusy(true)
|
||||||
|
setStatus("Connecting…")
|
||||||
|
|
||||||
|
try? FileManager.default.createDirectory(atPath: ServerListStore.appSupportURL.path,
|
||||||
|
withIntermediateDirectories: true)
|
||||||
|
let config = VoiceCatConfig(
|
||||||
|
clientName: "VoiceCat-macOS",
|
||||||
|
clientVersion: "0.0.1",
|
||||||
|
logLevel: .info,
|
||||||
|
tofuStorePath: ServerListStore.tofuStorePath
|
||||||
|
)
|
||||||
|
let newClient = VoiceCatClient(config: config)
|
||||||
|
client = newClient
|
||||||
|
identityDialogShown = false
|
||||||
|
|
||||||
|
newClient.onEvent = { [weak self] event in
|
||||||
|
self?.handleEvent(event, server: server)
|
||||||
|
}
|
||||||
|
|
||||||
|
let connectResult = newClient.connect(host: server.host, port: server.port)
|
||||||
|
guard connectResult == .ok else {
|
||||||
|
setStatus("Connect failed: \(connectResult)")
|
||||||
|
cleanupFailedAttempt()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch server.authMode {
|
||||||
|
case .guest:
|
||||||
|
let nick = server.savedUsername?.isEmpty == false ? server.savedUsername! : NSFullUserName()
|
||||||
|
newClient.authenticateGuest(nick)
|
||||||
|
case .password:
|
||||||
|
let username = server.savedUsername ?? ""
|
||||||
|
if let tag = server.keychainTag, let password = ServerListStore.loadPassword(tag: tag) {
|
||||||
|
newClient.authenticateUser(username, password: password)
|
||||||
|
} else {
|
||||||
|
let pwSheet = PasswordPromptSheet(prompt: "Password for \(username)@\(server.host):")
|
||||||
|
pwSheet.onComplete = { [weak self, weak newClient] password in
|
||||||
|
guard let self, let newClient else { return }
|
||||||
|
guard let password else {
|
||||||
|
self.setStatus("Cancelled.")
|
||||||
|
self.cleanupFailedAttempt()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newClient.authenticateUser(username, password: password)
|
||||||
|
}
|
||||||
|
presentSheet(pwSheet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleEvent(_ event: VoiceCatEvent, server: SavedServer) {
|
||||||
|
switch event.type {
|
||||||
|
case .connectionState:
|
||||||
|
let label: String
|
||||||
|
switch event.connectionState {
|
||||||
|
case .connecting: label = "Connecting…"
|
||||||
|
case .tlsHandshake: label = "TLS handshake…"
|
||||||
|
case .verifyingIdentity: label = "Verifying server identity…"
|
||||||
|
case .authenticating: label = "Authenticating…"
|
||||||
|
case .connected: label = "Connected."
|
||||||
|
default: label = statusLabel.stringValue
|
||||||
|
}
|
||||||
|
setStatus(label)
|
||||||
|
|
||||||
|
case .serverIdentity:
|
||||||
|
handleServerIdentity(tofuStatus: event.tofuStatus ?? .firstConnect,
|
||||||
|
displayText: client?.getServerIdentityDisplay() ?? "")
|
||||||
|
|
||||||
|
case .authResult:
|
||||||
|
if event.result == .ok {
|
||||||
|
let nickname = server.authMode == .guest
|
||||||
|
? (server.savedUsername?.isEmpty == false ? server.savedUsername! : NSFullUserName())
|
||||||
|
: (server.savedUsername ?? "")
|
||||||
|
authSucceeded(client: client!, selfUserId: event.userId, nickname: nickname)
|
||||||
|
} else {
|
||||||
|
setStatus("Authentication failed: \(event.text ?? event.result.description)")
|
||||||
|
cleanupFailedAttempt()
|
||||||
|
}
|
||||||
|
|
||||||
|
case .disconnected:
|
||||||
|
if client != nil {
|
||||||
|
setStatus(event.text.map { "Disconnected: \($0)" } ?? "Disconnected.")
|
||||||
|
cleanupFailedAttempt()
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleServerIdentity(tofuStatus: VoiceCatTofuStatus, displayText: String) {
|
||||||
|
if identityDialogShown { return }
|
||||||
|
if tofuStatus == .matched {
|
||||||
|
client?.confirmServerIdentity(accept: true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
identityDialogShown = true
|
||||||
|
let sheet = ServerIdentitySheet(tofuStatus: tofuStatus, displayFingerprint: displayText)
|
||||||
|
sheet.onComplete = { [weak self] accepted in
|
||||||
|
self?.client?.confirmServerIdentity(accept: accepted)
|
||||||
|
if !accepted {
|
||||||
|
self?.setStatus("Server identity rejected.")
|
||||||
|
self?.cleanupFailedAttempt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
presentSheet(sheet)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func authSucceeded(client: VoiceCatClient, selfUserId: UInt32, nickname: String) {
|
||||||
|
client.onEvent = nil
|
||||||
|
let mainWC = MainWindowController(client: client, selfUserId: selfUserId, nickname: nickname)
|
||||||
|
self.mainWindowController = mainWC
|
||||||
|
mainWC.showWindow(nil)
|
||||||
|
self.client = nil
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cleanupFailedAttempt() {
|
||||||
|
client?.onEvent = nil
|
||||||
|
client = nil
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func setStatus(_ text: String) {
|
||||||
|
statusLabel.stringValue = text
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setBusy(_ busy: Bool) {
|
||||||
|
serverTableView.isEnabled = !busy
|
||||||
|
addButton.isEnabled = !busy
|
||||||
|
connectButton.isEnabled = !busy && serverTableView.selectedRow >= 0
|
||||||
|
editButton.isEnabled = !busy && serverTableView.selectedRow >= 0
|
||||||
|
removeButton.isEnabled = !busy && serverTableView.selectedRow >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentSheet(_ vc: NSViewController) {
|
||||||
|
if let parent = window?.contentViewController {
|
||||||
|
parent.presentAsSheet(vc)
|
||||||
|
} else {
|
||||||
|
let contentVC = NSViewController()
|
||||||
|
contentVC.view = window!.contentView!
|
||||||
|
window?.contentViewController = contentVC
|
||||||
|
contentVC.presentAsSheet(vc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - NSWindowDelegate
|
||||||
|
|
||||||
|
func windowWillClose(_ notification: Notification) {
|
||||||
|
client?.onEvent = nil
|
||||||
|
client = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - NSTableViewDataSource / Delegate
|
||||||
|
|
||||||
|
extension ConnectWindowController: NSTableViewDataSource, NSTableViewDelegate {
|
||||||
|
func numberOfRows(in tableView: NSTableView) -> Int { servers.count }
|
||||||
|
|
||||||
|
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
|
||||||
|
let id = NSUserInterfaceItemIdentifier("serverCell")
|
||||||
|
let cell = tableView.makeView(withIdentifier: id, owner: nil) as? NSTableCellView
|
||||||
|
?? makeTableCellView(identifier: id)
|
||||||
|
cell.textField?.stringValue = servers[row].displayString
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableViewSelectionDidChange(_ notification: Notification) {
|
||||||
|
updateButtonStates()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeTableCellView(identifier: NSUserInterfaceItemIdentifier) -> NSTableCellView {
|
||||||
|
let cell = NSTableCellView()
|
||||||
|
cell.identifier = identifier
|
||||||
|
let tf = NSTextField(labelWithString: "")
|
||||||
|
tf.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
cell.addSubview(tf)
|
||||||
|
cell.textField = tf
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
tf.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 4),
|
||||||
|
tf.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -4),
|
||||||
|
tf.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
|
||||||
|
])
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helper
|
||||||
|
|
||||||
|
|
||||||
1388
clients/apple/macOS/VoiceCatMac/Windows/MainWindowController.swift
Normal file
1388
clients/apple/macOS/VoiceCatMac/Windows/MainWindowController.swift
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,165 @@
|
|||||||
|
import AppKit
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
// PrivateMessageWindowController — a modeless window for a single private message
|
||||||
|
// conversation, owned and routed-to by MainWindowController. Mirrors the Windows client's
|
||||||
|
// `PrivateMessageForm` (clients/windows/VoiceCat.App/Forms/PrivateMessageForm.cs): each PM
|
||||||
|
// conversation opens in its own window instead of sharing the main chat log via a scope
|
||||||
|
// dropdown. MainWindowController routes incoming `.textMessage` events with `.private` scope
|
||||||
|
// to the right window; outgoing PMs are echoed back by the server and arrive through the
|
||||||
|
// same path (no optimistic local echo).
|
||||||
|
//
|
||||||
|
// The window is modeless (`NSWindow`) rather than a sheet because the user should be able to
|
||||||
|
// keep chatting in the main channel while a PM window is open — the same Discord/Slack
|
||||||
|
// pattern the Windows client follows.
|
||||||
|
|
||||||
|
final class PrivateMessageWindowController: NSWindowController, NSWindowDelegate, NSTextViewDelegate {
|
||||||
|
|
||||||
|
// MARK: - Owned state
|
||||||
|
|
||||||
|
private let client: VoiceCatClient
|
||||||
|
let otherUserId: UInt32
|
||||||
|
private let selfUserId: UInt32
|
||||||
|
private let nickname: String
|
||||||
|
|
||||||
|
// MARK: - UI
|
||||||
|
|
||||||
|
private let historyTextView: NSTextView = {
|
||||||
|
let tv = NSTextView()
|
||||||
|
tv.isEditable = false
|
||||||
|
tv.isSelectable = true
|
||||||
|
tv.isAutomaticQuoteSubstitutionEnabled = false
|
||||||
|
tv.textContainerInset = NSSize(width: 4, height: 4)
|
||||||
|
return tv
|
||||||
|
}()
|
||||||
|
|
||||||
|
private let composeField = NSTextField()
|
||||||
|
private let sendButton = NSButton()
|
||||||
|
|
||||||
|
// MARK: - Init
|
||||||
|
|
||||||
|
init(client: VoiceCatClient, otherUserId: UInt32, nickname: String, selfUserId: UInt32) {
|
||||||
|
self.client = client
|
||||||
|
self.otherUserId = otherUserId
|
||||||
|
self.selfUserId = selfUserId
|
||||||
|
self.nickname = nickname
|
||||||
|
|
||||||
|
let window = NSWindow(
|
||||||
|
contentRect: NSRect(x: 0, y: 0, width: 460, height: 360),
|
||||||
|
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
window.title = "Private Message — \(nickname)"
|
||||||
|
window.minSize = NSSize(width: 300, height: 220)
|
||||||
|
window.center()
|
||||||
|
super.init(window: window)
|
||||||
|
window.delegate = self
|
||||||
|
|
||||||
|
buildUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
// MARK: - UI construction
|
||||||
|
|
||||||
|
private func buildUI() {
|
||||||
|
guard let contentView = window?.contentView else { return }
|
||||||
|
|
||||||
|
historyTextView.setAccessibilityLabel("Private message history with \(nickname)")
|
||||||
|
|
||||||
|
let scroll = NSScrollView()
|
||||||
|
scroll.documentView = historyTextView
|
||||||
|
scroll.hasVerticalScroller = true
|
||||||
|
scroll.borderType = .noBorder
|
||||||
|
scroll.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
contentView.addSubview(scroll)
|
||||||
|
|
||||||
|
composeField.placeholderString = "Type a private message…"
|
||||||
|
composeField.setAccessibilityLabel("Private message to \(nickname)")
|
||||||
|
composeField.target = self
|
||||||
|
composeField.action = #selector(sendClicked)
|
||||||
|
|
||||||
|
sendButton.title = "Send"
|
||||||
|
sendButton.bezelStyle = .rounded
|
||||||
|
sendButton.target = self
|
||||||
|
sendButton.action = #selector(sendClicked)
|
||||||
|
sendButton.setAccessibilityLabel("Send private message")
|
||||||
|
sendButton.keyEquivalent = "\r"
|
||||||
|
|
||||||
|
let sep = NSBox()
|
||||||
|
sep.boxType = .separator
|
||||||
|
sep.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
contentView.addSubview(sep)
|
||||||
|
|
||||||
|
let composeBar = NSStackView(views: [composeField, sendButton])
|
||||||
|
composeBar.orientation = .horizontal
|
||||||
|
composeBar.spacing = 6
|
||||||
|
composeBar.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
contentView.addSubview(composeBar)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
scroll.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||||||
|
scroll.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
||||||
|
scroll.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
||||||
|
scroll.bottomAnchor.constraint(equalTo: sep.topAnchor),
|
||||||
|
|
||||||
|
sep.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
||||||
|
sep.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
||||||
|
sep.heightAnchor.constraint(equalToConstant: 1),
|
||||||
|
sep.bottomAnchor.constraint(equalTo: composeBar.topAnchor),
|
||||||
|
|
||||||
|
composeBar.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8),
|
||||||
|
composeBar.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8),
|
||||||
|
composeBar.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8),
|
||||||
|
composeBar.heightAnchor.constraint(equalToConstant: 28),
|
||||||
|
|
||||||
|
sendButton.widthAnchor.constraint(equalToConstant: 70),
|
||||||
|
])
|
||||||
|
|
||||||
|
window?.makeFirstResponder(composeField)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public — called by MainWindowController
|
||||||
|
|
||||||
|
/// Append a message line. `isSelf=true` renders in gray (our own echoed outgoing message);
|
||||||
|
/// `false` renders in default color (incoming from the other user). Mirrors the Windows
|
||||||
|
/// `PrivateMessageForm.AppendMessage`.
|
||||||
|
func appendMessage(time: String, isSelf: Bool, sender: String, text: String) {
|
||||||
|
let line = "[\(time)] \(sender): \(text)\n"
|
||||||
|
let attrs: [NSAttributedString.Key: Any] = isSelf
|
||||||
|
? [.foregroundColor: NSColor.secondaryLabelColor]
|
||||||
|
: [:]
|
||||||
|
let attributed = NSAttributedString(string: line, attributes: attrs)
|
||||||
|
historyTextView.textStorage?.append(attributed)
|
||||||
|
historyTextView.scrollToEndOfDocument(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append a gray status/activity line (e.g. the other user disconnected). Mirrors the
|
||||||
|
/// Windows `PrivateMessageForm.AppendActivity`.
|
||||||
|
func appendActivity(_ text: String) {
|
||||||
|
let time = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .short)
|
||||||
|
let line = "[\(time)] \(text)\n"
|
||||||
|
let attributed = NSAttributedString(string: line, attributes: [
|
||||||
|
.foregroundColor: NSColor.secondaryLabelColor,
|
||||||
|
])
|
||||||
|
historyTextView.textStorage?.append(attributed)
|
||||||
|
historyTextView.scrollToEndOfDocument(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Send
|
||||||
|
|
||||||
|
@objc private func sendClicked() {
|
||||||
|
let msg = composeField.stringValue.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !msg.isEmpty else { return }
|
||||||
|
client.sendText(scope: .private, targetId: otherUserId, text: msg)
|
||||||
|
composeField.stringValue = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - NSWindowDelegate
|
||||||
|
|
||||||
|
func windowWillClose(_ notification: Notification) {
|
||||||
|
// Notify the owner so it can drop this controller from its pmWindows map.
|
||||||
|
// MainWindowController observes NSWindow.willCloseNotification on this window.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import AppKit
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
// SettingsWindowController — a modeless window containing the audio input settings that used
|
||||||
|
// to live in the main window's bottom voice panel: input mode (VAD/PTT/Always On), VAD
|
||||||
|
// sensitivity, PTT key selection, input device picker, and the live microphone level meter.
|
||||||
|
//
|
||||||
|
// The main window is now just toolbar + channels + users + chat; audio settings live here
|
||||||
|
// and are accessed via the app menu's "Settings…" item (⌘,). The window is modeless so the
|
||||||
|
// user can keep it open while interacting with the main window — essential for watching the
|
||||||
|
// level meter while adjusting VAD threshold or testing a device.
|
||||||
|
//
|
||||||
|
// Source-of-truth for the current settings lives in MainWindowController (so voice start can
|
||||||
|
// apply them even before this window has been opened). This window reads from and writes back
|
||||||
|
// to MainWindowController's stored properties, and applies changes to the client immediately
|
||||||
|
// when voice is active.
|
||||||
|
|
||||||
|
final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||||
|
|
||||||
|
// MARK: - References
|
||||||
|
|
||||||
|
private let client: VoiceCatClient
|
||||||
|
weak var mainController: MainWindowController?
|
||||||
|
|
||||||
|
// MARK: - UI
|
||||||
|
|
||||||
|
private let inputModeControl = NSSegmentedControl(labels: ["VAD", "PTT", "Always On"],
|
||||||
|
trackingMode: .selectOne,
|
||||||
|
target: nil, action: nil)
|
||||||
|
private let vadSlider: NSSlider = {
|
||||||
|
let s = NSSlider(value: 50, minValue: 1, maxValue: 100, target: nil, action: nil)
|
||||||
|
s.numberOfTickMarks = 0
|
||||||
|
return s
|
||||||
|
}()
|
||||||
|
private let vadLabel = NSTextField(labelWithString: "Sensitivity:")
|
||||||
|
private let pttKeyLabel = NSTextField(labelWithString: "(F8)")
|
||||||
|
private let changePttButton = NSButton()
|
||||||
|
private let devicePicker = NSPopUpButton()
|
||||||
|
private let refreshDevicesButton = NSButton()
|
||||||
|
private let levelMeter: NSProgressIndicator = {
|
||||||
|
let p = NSProgressIndicator()
|
||||||
|
p.style = .bar
|
||||||
|
p.isIndeterminate = false
|
||||||
|
p.minValue = 0
|
||||||
|
p.maxValue = 100
|
||||||
|
p.doubleValue = 0
|
||||||
|
return p
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Cached VAD slider position so we can restore it when the window reopens.
|
||||||
|
private var vadSliderValue: Double = 50
|
||||||
|
|
||||||
|
// MARK: - Init
|
||||||
|
|
||||||
|
init(client: VoiceCatClient, mainController: MainWindowController) {
|
||||||
|
self.client = client
|
||||||
|
self.mainController = mainController
|
||||||
|
|
||||||
|
let window = NSWindow(
|
||||||
|
contentRect: NSRect(x: 0, y: 0, width: 380, height: 260),
|
||||||
|
styleMask: [.titled, .closable, .miniaturizable],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
window.title = "Audio Settings"
|
||||||
|
window.minSize = NSSize(width: 340, height: 220)
|
||||||
|
window.center()
|
||||||
|
super.init(window: window)
|
||||||
|
window.delegate = self
|
||||||
|
|
||||||
|
buildUI()
|
||||||
|
syncFromMainController()
|
||||||
|
loadInputDevices()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
// MARK: - UI construction
|
||||||
|
|
||||||
|
private func buildUI() {
|
||||||
|
guard let contentView = window?.contentView else { return }
|
||||||
|
|
||||||
|
let inputModeLabel = NSTextField(labelWithString: "Input mode:")
|
||||||
|
inputModeLabel.setAccessibilityLabel("Input mode")
|
||||||
|
|
||||||
|
inputModeControl.target = self
|
||||||
|
inputModeControl.action = #selector(inputModeChanged)
|
||||||
|
inputModeControl.selectedSegment = 0
|
||||||
|
inputModeControl.setAccessibilityLabel("Input mode: VAD, PTT, or Always On")
|
||||||
|
|
||||||
|
vadLabel.setAccessibilityLabel("VAD sensitivity")
|
||||||
|
vadSlider.target = self
|
||||||
|
vadSlider.action = #selector(vadSliderChanged)
|
||||||
|
vadSlider.setAccessibilityLabel("Voice activation sensitivity")
|
||||||
|
vadSlider.setAccessibilityHelp("Drag right for more sensitive, left for less")
|
||||||
|
|
||||||
|
pttKeyLabel.setAccessibilityLabel("Current PTT key")
|
||||||
|
changePttButton.title = "Change…"
|
||||||
|
changePttButton.bezelStyle = .rounded
|
||||||
|
changePttButton.target = self
|
||||||
|
changePttButton.action = #selector(changePttClicked)
|
||||||
|
changePttButton.setAccessibilityLabel("Change push-to-talk key")
|
||||||
|
pttKeyLabel.isHidden = true
|
||||||
|
changePttButton.isHidden = true
|
||||||
|
|
||||||
|
let deviceLabel = NSTextField(labelWithString: "Input device:")
|
||||||
|
deviceLabel.setAccessibilityLabel("Input device")
|
||||||
|
devicePicker.setAccessibilityLabel("Input audio device")
|
||||||
|
devicePicker.target = self
|
||||||
|
devicePicker.action = #selector(deviceChanged)
|
||||||
|
refreshDevicesButton.title = "↺"
|
||||||
|
refreshDevicesButton.bezelStyle = .rounded
|
||||||
|
refreshDevicesButton.target = self
|
||||||
|
refreshDevicesButton.action = #selector(refreshDevicesClicked)
|
||||||
|
refreshDevicesButton.setAccessibilityLabel("Refresh device list")
|
||||||
|
refreshDevicesButton.toolTip = "Refresh"
|
||||||
|
|
||||||
|
let levelLabel = NSTextField(labelWithString: "Level:")
|
||||||
|
levelLabel.setAccessibilityLabel("Microphone input level")
|
||||||
|
levelMeter.setAccessibilityLabel("Microphone input level")
|
||||||
|
levelMeter.setAccessibilityHelp("Shows current microphone volume level")
|
||||||
|
|
||||||
|
let inputModeRow = NSStackView(views: [inputModeLabel, inputModeControl])
|
||||||
|
inputModeRow.orientation = .horizontal
|
||||||
|
inputModeRow.spacing = 8
|
||||||
|
|
||||||
|
let vadRow = NSStackView(views: [vadLabel, vadSlider])
|
||||||
|
vadRow.orientation = .horizontal
|
||||||
|
vadRow.spacing = 8
|
||||||
|
|
||||||
|
let pttRow = NSStackView(views: [pttKeyLabel, changePttButton])
|
||||||
|
pttRow.orientation = .horizontal
|
||||||
|
pttRow.spacing = 8
|
||||||
|
|
||||||
|
let deviceRow = NSStackView(views: [deviceLabel, devicePicker, refreshDevicesButton])
|
||||||
|
deviceRow.orientation = .horizontal
|
||||||
|
deviceRow.spacing = 8
|
||||||
|
|
||||||
|
let levelRow = NSStackView(views: [levelLabel, levelMeter])
|
||||||
|
levelRow.orientation = .horizontal
|
||||||
|
levelRow.spacing = 8
|
||||||
|
|
||||||
|
let stack = NSStackView(views: [inputModeRow, vadRow, pttRow, deviceRow, levelRow])
|
||||||
|
stack.orientation = .vertical
|
||||||
|
stack.spacing = 12
|
||||||
|
stack.alignment = .leading
|
||||||
|
stack.edgeInsets = NSEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
contentView.addSubview(stack)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
stack.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||||||
|
stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||||||
|
|
||||||
|
vadSlider.widthAnchor.constraint(greaterThanOrEqualToConstant: 200),
|
||||||
|
levelMeter.widthAnchor.constraint(equalToConstant: 200),
|
||||||
|
devicePicker.widthAnchor.constraint(greaterThanOrEqualToConstant: 180),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sync from MainWindowController
|
||||||
|
|
||||||
|
/// Read the current settings from MainWindowController and update our UI to match.
|
||||||
|
/// Called on init and whenever the window is re-shown.
|
||||||
|
private func syncFromMainController() {
|
||||||
|
guard let mc = mainController else { return }
|
||||||
|
|
||||||
|
switch mc.selectedInputMode {
|
||||||
|
case .voiceActivation: inputModeControl.selectedSegment = 0
|
||||||
|
case .pushToTalk: inputModeControl.selectedSegment = 1
|
||||||
|
case .alwaysOn: inputModeControl.selectedSegment = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
vadSlider.doubleValue = vadSliderValue
|
||||||
|
pttKeyLabel.stringValue = "(\(keyCodeName(mc.pttKeyCode)))"
|
||||||
|
|
||||||
|
updateConditionalControls()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show/hide VAD and PTT controls based on the selected input mode.
|
||||||
|
private func updateConditionalControls() {
|
||||||
|
let seg = inputModeControl.selectedSegment
|
||||||
|
vadLabel.isHidden = seg != 0
|
||||||
|
vadSlider.isHidden = seg != 0
|
||||||
|
pttKeyLabel.isHidden = seg != 1
|
||||||
|
changePttButton.isHidden = seg != 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
@objc private func inputModeChanged() {
|
||||||
|
updateConditionalControls()
|
||||||
|
let mode = currentInputMode()
|
||||||
|
mainController?.selectedInputMode = mode
|
||||||
|
if let mc = mainController, mc.micStreamId != 0 {
|
||||||
|
client.setInputMode(mode)
|
||||||
|
if mode == .voiceActivation {
|
||||||
|
client.setVadThreshold(vadThresholdFromSlider())
|
||||||
|
} else if mode == .pushToTalk {
|
||||||
|
client.setPushToTalk(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func vadSliderChanged() {
|
||||||
|
vadSliderValue = vadSlider.doubleValue
|
||||||
|
let threshold = vadThresholdFromSlider()
|
||||||
|
mainController?.vadThresholdValue = threshold
|
||||||
|
if let mc = mainController, mc.micStreamId != 0, mc.selectedInputMode == .voiceActivation {
|
||||||
|
client.setVadThreshold(threshold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func changePttClicked() {
|
||||||
|
guard let mc = mainController else { return }
|
||||||
|
let sheet = PttKeyCaptureSheet(currentKeyCode: mc.pttKeyCode)
|
||||||
|
sheet.onComplete = { [weak self] keyCode in
|
||||||
|
guard let self, let keyCode else { return }
|
||||||
|
self.mainController?.pttKeyCode = keyCode
|
||||||
|
self.pttKeyLabel.stringValue = "(\(keyCodeName(keyCode)))"
|
||||||
|
}
|
||||||
|
presentSheet(sheet)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func refreshDevicesClicked() { loadInputDevices() }
|
||||||
|
|
||||||
|
@objc private func deviceChanged() {
|
||||||
|
let devId = devicePicker.selectedItem?.representedObject as? String
|
||||||
|
mainController?.selectedInputDeviceId = devId
|
||||||
|
if let mc = mainController, mc.micStreamId != 0, let devId {
|
||||||
|
client.setInputDevice(streamId: mc.micStreamId, deviceId: devId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Level meter (called by MainWindowController)
|
||||||
|
|
||||||
|
func updateLevel(rms: Float) {
|
||||||
|
levelMeter.doubleValue = min(100, Double(rms * 400))
|
||||||
|
levelMeter.setAccessibilityValue("\(Int(levelMeter.doubleValue)) percent")
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetLevel() {
|
||||||
|
levelMeter.doubleValue = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Device enumeration
|
||||||
|
|
||||||
|
private func loadInputDevices() {
|
||||||
|
let devices = client.listDevices(.input)
|
||||||
|
let prevSelected = devicePicker.selectedItem?.representedObject as? String
|
||||||
|
devicePicker.removeAllItems()
|
||||||
|
for d in devices {
|
||||||
|
let item = NSMenuItem(title: d.name, action: nil, keyEquivalent: "")
|
||||||
|
item.representedObject = d.id
|
||||||
|
devicePicker.menu?.addItem(item)
|
||||||
|
}
|
||||||
|
// Restore previous selection, or pick default, or first
|
||||||
|
if let prev = prevSelected,
|
||||||
|
let item = devicePicker.itemArray.first(where: { ($0.representedObject as? String) == prev }) {
|
||||||
|
devicePicker.select(item)
|
||||||
|
} else if let def = devices.first(where: { $0.isDefault }) {
|
||||||
|
devicePicker.select(devicePicker.item(withTitle: def.name))
|
||||||
|
} else if devicePicker.numberOfItems > 0 {
|
||||||
|
devicePicker.selectItem(at: 0)
|
||||||
|
}
|
||||||
|
// Sync the selected device back to main controller
|
||||||
|
let devId = devicePicker.selectedItem?.representedObject as? String
|
||||||
|
mainController?.selectedInputDeviceId = devId
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func currentInputMode() -> VoiceCatInputMode {
|
||||||
|
switch inputModeControl.selectedSegment {
|
||||||
|
case 1: return .pushToTalk
|
||||||
|
case 2: return .alwaysOn
|
||||||
|
default: return .voiceActivation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func vadThresholdFromSlider() -> Float {
|
||||||
|
0.1 * (1.0 - Float(vadSlider.doubleValue - 1.0) / 99.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentSheet(_ vc: NSViewController) {
|
||||||
|
if let cvc = window?.contentViewController {
|
||||||
|
cvc.presentAsSheet(vc)
|
||||||
|
} else {
|
||||||
|
let cvc = NSViewController()
|
||||||
|
cvc.view = window!.contentView!
|
||||||
|
window?.contentViewController = cvc
|
||||||
|
cvc.presentAsSheet(vc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
clients/apple/macOS/VoiceCatMac/main.swift
Normal file
5
clients/apple/macOS/VoiceCatMac/main.swift
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
let delegate = AppDelegate()
|
||||||
|
NSApplication.shared.delegate = delegate
|
||||||
|
NSApplication.shared.run()
|
||||||
174
clients/apple/scripts/build-xcframework.sh
Executable file
174
clients/apple/scripts/build-xcframework.sh
Executable file
@@ -0,0 +1,174 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# build-xcframework.sh — build libvoicecat as a static .a slice (or slices) and stitch them
|
||||||
|
# into a VoiceCatCore.xcframework that the Swift Package at clients/apple/Package.swift
|
||||||
|
# consumes as a binary target.
|
||||||
|
#
|
||||||
|
# The XCFramework's Headers directory carries a generated module.modulemap alongside
|
||||||
|
# voicecat.h, so Swift gets a clean `import VoiceCatC` module (see docs/architecture.md §4 —
|
||||||
|
# "Swift / Apple. Import the C ABI via a module map"). core/include/ itself stays pure C;
|
||||||
|
# the module map is an Apple-packaging concern that lives only in the staged headers.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/build-xcframework.sh # macOS slice only (default, validated)
|
||||||
|
# scripts/build-xcframework.sh --all # macOS + iOS device + iOS sim (iOS still scaffolding)
|
||||||
|
# scripts/build-xcframework.sh --preset apple-dev
|
||||||
|
# scripts/build-xcframework.sh --no-configure # skip cmake configure, just rebuild + stitch
|
||||||
|
#
|
||||||
|
# Requires: VCPKG_ROOT set (or detectable from an existing build/apple-dev/CMakeCache.txt),
|
||||||
|
# Xcode + macOS SDK. iOS slices additionally require the iOS SDK. Install Homebrew
|
||||||
|
# autoconf-archive for vcpkg's libsodium port (see clients/apple/README.md).
|
||||||
|
#
|
||||||
|
# Mirrors the Windows client's convention: the native binary is a local build artifact, NOT
|
||||||
|
# committed — the C# project references build/windows-client/bin/voicecat.dll the same way
|
||||||
|
# this script's output at clients/apple/VoiceCatCore.xcframework is referenced by Package.swift.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# scripts/ is 3 levels below repo root: voice-cat/clients/apple/scripts/
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||||
|
APPLE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
BUILD_MACOS=true
|
||||||
|
BUILD_IOS_DEVICE=false
|
||||||
|
BUILD_IOS_SIM=false
|
||||||
|
DO_CONFIGURE=true
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--all) BUILD_MACOS=true; BUILD_IOS_DEVICE=true; BUILD_IOS_SIM=true; shift ;;
|
||||||
|
--preset) case "$2" in
|
||||||
|
apple-dev) BUILD_MACOS=true; BUILD_IOS_DEVICE=false; BUILD_IOS_SIM=false ;;
|
||||||
|
apple-ios) BUILD_MACOS=false; BUILD_IOS_DEVICE=true; BUILD_IOS_SIM=false ;;
|
||||||
|
apple-ios-sim) BUILD_MACOS=false; BUILD_IOS_DEVICE=false; BUILD_IOS_SIM=true ;;
|
||||||
|
*) echo "unknown preset: $2" >&2; exit 2 ;;
|
||||||
|
esac; shift 2 ;;
|
||||||
|
--no-configure) DO_CONFIGURE=false; shift ;;
|
||||||
|
-h|--help)
|
||||||
|
sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'
|
||||||
|
exit 0 ;;
|
||||||
|
*) echo "unknown arg: $1" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Resolve VCPKG_ROOT ───────────────────────────────────────────────────────────
|
||||||
|
# The apple-dev CMake cache records the vcpkg root it was configured with (Z_VCPKG_ROOT_DIR);
|
||||||
|
# reuse that so a developer who already configured `cmake --preset dev` doesn't need VCPKG_ROOT
|
||||||
|
# in their shell env to run this script.
|
||||||
|
if [[ -z "${VCPKG_ROOT:-}" ]]; then
|
||||||
|
cache="$REPO_ROOT/build/apple-dev/CMakeCache.txt"
|
||||||
|
if [[ -f "$cache" ]]; then
|
||||||
|
detected="$(grep -m1 '^Z_VCPKG_ROOT_DIR:INTERNAL=' "$cache" | cut -d= -f2-)"
|
||||||
|
if [[ -n "$detected" && -d "$detected" ]]; then
|
||||||
|
export VCPKG_ROOT="$detected"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [[ -z "${VCPKG_ROOT:-}" || ! -d "$VCPKG_ROOT" ]]; then
|
||||||
|
echo "error: VCPKG_ROOT is not set or does not exist." >&2
|
||||||
|
echo " bootstrap vcpkg (https://vcpkg.io) then: export VCPKG_ROOT=/path/to/vcpkg" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "[build-xcframework] VCPKG_ROOT=$VCPKG_ROOT"
|
||||||
|
|
||||||
|
# ── Build each requested slice ────────────────────────────────────────────────────
|
||||||
|
build_slice() {
|
||||||
|
local preset="$1" slice_name="$2" vcpkg_triplet="$3"
|
||||||
|
echo "[build-xcframework] === $slice_name: configure + build ($preset) ==="
|
||||||
|
if $DO_CONFIGURE; then
|
||||||
|
cmake --preset "$preset"
|
||||||
|
fi
|
||||||
|
cmake --build --preset "$preset"
|
||||||
|
local lib="$REPO_ROOT/build/$preset/lib/libvoicecat.a"
|
||||||
|
if [[ ! -f "$lib" ]]; then
|
||||||
|
echo "error: expected output not found: $lib" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "[build-xcframework] $slice_name -> $lib ($(stat -f%z "$lib") bytes)"
|
||||||
|
|
||||||
|
# The static libvoicecat.a only contains voicecat's own object files — vcpkg's static
|
||||||
|
# deps (protobuf, mbedtls, libsodium, opus, sqlite3, spdlog, asio, abseil, …) are
|
||||||
|
# separate .a files under vcpkg_installed/<triplet>/lib/. A Swift Package binary target
|
||||||
|
# can only link ONE .a per XCFramework slice, so we merge them all into a single
|
||||||
|
# self-contained "fat" static library using libtool -static. This is the Apple equivalent
|
||||||
|
# of how the Windows client ships a single voicecat.dll with all deps statically linked
|
||||||
|
# (via MinGW's -static flags in core/CMakeLists.txt).
|
||||||
|
#
|
||||||
|
# Without this, the final executable (test runner / app) would get undefined-symbol
|
||||||
|
# linker errors for protobuf/mbedtls/sodium/opus/… symbols that libvoicecat.a references
|
||||||
|
# but doesn't contain. See clients/apple/README.md "Fat static library" section.
|
||||||
|
#
|
||||||
|
# IMPORTANT: search only the TARGET triplet's lib dir, not all of vcpkg_installed/.
|
||||||
|
# Cross-compile builds install a HOST triplet directory alongside the target (e.g.
|
||||||
|
# arm64-osx/ next to arm64-ios/) — that host directory contains macOS binaries needed
|
||||||
|
# to build protoc/etc at configure time. Merging those macOS .a files into the iOS
|
||||||
|
# fat library triggers xcodebuild's "binaries with multiple platforms" rejection.
|
||||||
|
local vcpkg_target_lib_dir="$REPO_ROOT/build/$preset/vcpkg_installed/$vcpkg_triplet/lib"
|
||||||
|
local fat_lib="$REPO_ROOT/build/$preset/lib/libvoicecat-fat.a"
|
||||||
|
echo "[build-xcframework] $slice_name: merging vcpkg deps into fat static lib (triplet: $vcpkg_triplet)"
|
||||||
|
# Collect all .a files (libvoicecat.a + every vcpkg target .a). libtool -static concatenates
|
||||||
|
# object files from all input archives; duplicate-object warnings are benign (the linker
|
||||||
|
# resolves duplicates at final link time). "no symbols" warnings are for empty AVX2/AVX512
|
||||||
|
# objects on arm64 — also benign.
|
||||||
|
local all_libs=( "$lib" )
|
||||||
|
while IFS= read -r f; do all_libs+=( "$f" ); done < <(find "$vcpkg_target_lib_dir" -name '*.a' -not -name 'libvoicecat*' | sort)
|
||||||
|
libtool -static -o "$fat_lib" "${all_libs[@]}" 2>&1 | grep -v 'has no symbols' || true
|
||||||
|
echo "[build-xcframework] $slice_name -> $fat_lib ($(stat -f%z "$fat_lib") bytes, fat)"
|
||||||
|
}
|
||||||
|
|
||||||
|
args=()
|
||||||
|
if $BUILD_MACOS; then build_slice apple-dev "macOS (arm64-osx)" "arm64-osx"; args+=( -library "$REPO_ROOT/build/apple-dev/lib/libvoicecat-fat.a" -headers "$APPLE_DIR/.staged-headers/macos" ); fi
|
||||||
|
if $BUILD_IOS_DEVICE; then build_slice apple-ios "iOS device (arm64-ios)" "arm64-ios"; args+=( -library "$REPO_ROOT/build/apple-ios/lib/libvoicecat-fat.a" -headers "$APPLE_DIR/.staged-headers/ios" ); fi
|
||||||
|
if $BUILD_IOS_SIM; then build_slice apple-ios-sim "iOS sim (arm64-ios-sim)" "arm64-ios-simulator"; args+=( -library "$REPO_ROOT/build/apple-ios-sim/lib/libvoicecat-fat.a" -headers "$APPLE_DIR/.staged-headers/ios-sim" ); fi
|
||||||
|
|
||||||
|
# ── Stage headers + module map ───────────────────────────────────────────────────
|
||||||
|
# Each slice gets its own headers dir (xcodebuild -create-xcframework requires a -headers
|
||||||
|
# per -library). The module map wraps voicecat.h as `module VoiceCatC` so Swift imports it
|
||||||
|
# as a clean named module rather than a Clang module inferred from the header path.
|
||||||
|
stage_headers() {
|
||||||
|
local dest="$1"
|
||||||
|
mkdir -p "$dest"
|
||||||
|
cp "$REPO_ROOT/core/include/voicecat.h" "$dest/voicecat.h"
|
||||||
|
cat > "$dest/module.modulemap" <<'MODULEMAP'
|
||||||
|
module VoiceCatC {
|
||||||
|
header "voicecat.h"
|
||||||
|
export *
|
||||||
|
}
|
||||||
|
MODULEMAP
|
||||||
|
# voicecat.h is the single public C ABI header (core/include/ has nothing else); exposing
|
||||||
|
# it as `module VoiceCatC` gives Swift a clean named import rather than a path-inferred
|
||||||
|
# Clang module. The export * re-exports all C symbols for Swift access.
|
||||||
|
}
|
||||||
|
|
||||||
|
STAGED_ROOT="$APPLE_DIR/.staged-headers"
|
||||||
|
rm -rf "$STAGED_ROOT"
|
||||||
|
if $BUILD_MACOS; then stage_headers "$STAGED_ROOT/macos"; fi
|
||||||
|
if $BUILD_IOS_DEVICE; then stage_headers "$STAGED_ROOT/ios"; fi
|
||||||
|
if $BUILD_IOS_SIM; then stage_headers "$STAGED_ROOT/ios-sim"; fi
|
||||||
|
|
||||||
|
# ── Stitch the XCFramework ───────────────────────────────────────────────────────
|
||||||
|
OUTPUT="$APPLE_DIR/VoiceCatCore.xcframework"
|
||||||
|
echo "[build-xcframework] === stitching $OUTPUT ==="
|
||||||
|
rm -rf "$OUTPUT"
|
||||||
|
xcodebuild -create-xcframework "${args[@]}" -output "$OUTPUT"
|
||||||
|
|
||||||
|
# Clean up staged headers (the xcframework has its own copy now).
|
||||||
|
rm -rf "$STAGED_ROOT"
|
||||||
|
|
||||||
|
# ── Code-sign ────────────────────────────────────────────────────────────────────
|
||||||
|
# Xcode 15+ rejects unsigned binary XCFrameworks consumed as SwiftPM binary targets
|
||||||
|
# ("The Framework VoiceCatCore.xcframework is unsigned."). Sign with CODESIGN_IDENTITY
|
||||||
|
# if set, else the first "Apple Development" identity in the keychain, else ad-hoc ("-").
|
||||||
|
# The .xcframework is a local build artifact (gitignored), so signing with the local
|
||||||
|
# developer identity is fine and not committed.
|
||||||
|
SIGN_ID="${CODESIGN_IDENTITY:-}"
|
||||||
|
if [[ -z "$SIGN_ID" ]]; then
|
||||||
|
SIGN_ID="$(security find-identity -v -p codesigning 2>/dev/null \
|
||||||
|
| awk '/Apple Development/{print $2; exit}')"
|
||||||
|
fi
|
||||||
|
SIGN_ID="${SIGN_ID:--}" # fall back to ad-hoc
|
||||||
|
codesign --force --timestamp=none --sign "$SIGN_ID" "$OUTPUT"
|
||||||
|
echo "[build-xcframework] signed with identity: $SIGN_ID"
|
||||||
|
|
||||||
|
echo "[build-xcframework] done -> $OUTPUT"
|
||||||
|
xcodebuild -list -xcframework "$OUTPUT" 2>/dev/null || true
|
||||||
8
clients/windows/Directory.Build.props
Normal file
8
clients/windows/Directory.Build.props
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<!-- Where the CMake `windows-client` preset puts the built DLL (see ../../CMakePresets.json
|
||||||
|
and clients/windows/README.md). Override with an environment variable or a
|
||||||
|
Directory.Build.props further down the tree if your build/ lives elsewhere. -->
|
||||||
|
<VoiceCatNativeDir Condition="'$(VoiceCatNativeDir)' == ''">$(MSBuildThisFileDirectory)..\..\build\windows-client\bin</VoiceCatNativeDir>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -1,18 +1,95 @@
|
|||||||
# Windows client — placeholder
|
# VoiceCat — Windows client
|
||||||
|
|
||||||
Built in **M4** (see [`docs/roadmap.md`](../../docs/roadmap.md)). C# / .NET 8+, consuming
|
WinForms (.NET 10 LTS) UI over `voicecat.dll` (MinGW-built `libvoicecat` shared library).
|
||||||
`libvoicecat` through the C ABI ([`core/include/voicecat.h`](../../core/include/voicecat.h)).
|
|
||||||
|
|
||||||
Planned shape (see [`docs/architecture.md`](../../docs/architecture.md) §4 and
|
## Prerequisites
|
||||||
[`docs/tech-stack.md`](../../docs/tech-stack.md) §2):
|
|
||||||
|
|
||||||
- A .NET solution with a P/Invoke interop layer over the C ABI using **`LibraryImport`**
|
| Tool | Version | Notes |
|
||||||
(source-generated, .NET 7+). Build `libvoicecat` as a **shared library**
|
|------|---------|-------|
|
||||||
(`-DVOICECAT_BUILD_SHARED=ON`) so the DLL sits beside the app.
|
| .NET SDK | 10.0.x | `dotnet --version` should report `10.0.*` |
|
||||||
- The `on_event` callback marshaled as a function pointer (`[UnmanagedCallersOnly]`) to avoid
|
| CMake | 3.25+ | For building the C++ DLL |
|
||||||
delegate-lifetime issues; keep the interface "chunky" to minimize managed↔native crossings.
|
| MinGW-w64 / MSYS2 UCRT64 | GCC 13+ | `C:\tools\msys64\ucrt64` is the expected location |
|
||||||
- UI in **WinUI 3** (most native) or **Avalonia** (if a single C# desktop UI is wanted later).
|
| vcpkg | any | `VCPKG_ROOT` env var must point to a bootstrapped clone |
|
||||||
- Audio (capture/playback, WASAPI loopback for `SCREEN_AUDIO`) is handled inside the core; the
|
|
||||||
C# layer only drives device selection, meters, and the VAD/PTT + per-user NR controls.
|
|
||||||
|
|
||||||
Nothing here yet — the core must reach M2 (working voice) before the GUI is worth building.
|
## Build order
|
||||||
|
|
||||||
|
### 1. Build the server (for testing)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cmake --preset dev
|
||||||
|
cmake --build --preset dev --target voicecat-server
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Build the DLL
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cmake --preset windows-client
|
||||||
|
cmake --build --preset windows-client
|
||||||
|
```
|
||||||
|
|
||||||
|
Output: `build/windows-client/bin/voicecat.dll`
|
||||||
|
|
||||||
|
**Verify no MinGW runtime dependencies remain:**
|
||||||
|
```powershell
|
||||||
|
& "C:\tools\msys64\ucrt64\bin\objdump.exe" -p build/windows-client/bin/voicecat.dll |
|
||||||
|
Select-String "DLL Name"
|
||||||
|
```
|
||||||
|
Expected: only Windows system DLLs (`KERNEL32.dll`, `WS2_32.dll`, `BCRYPT.dll`, etc.).
|
||||||
|
If `libgcc_s_seh-1.dll`, `libstdc++-6.dll`, or `libwinpthread-1.dll` appear, the
|
||||||
|
`-static-libgcc -static-libstdc++ -static -lwinpthread` link flags in `core/CMakeLists.txt`
|
||||||
|
are not taking effect — check the CMake log for the `VOICECAT_BUILD_SHARED+WIN32` branch.
|
||||||
|
|
||||||
|
### 3. Build the C# solution
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd clients/windows
|
||||||
|
dotnet build VoiceCat.slnx
|
||||||
|
```
|
||||||
|
|
||||||
|
The app's `Directory.Build.props` copies `voicecat.dll` from `../../build/windows-client/bin/`
|
||||||
|
into the output directory automatically on every build.
|
||||||
|
|
||||||
|
## Running manually
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Terminal 1 — start the server
|
||||||
|
./build/dev/bin/voicecat-server.exe --name "My Server"
|
||||||
|
|
||||||
|
# Terminal 2 — launch the client
|
||||||
|
dotnet run --project clients/windows/VoiceCat.App/VoiceCat.App.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
On first connect to a new server:
|
||||||
|
- Enter `127.0.0.1` as the host (not `localhost` — Windows resolves `localhost` to `::1`
|
||||||
|
first, and while the server now dual-stacks, `127.0.0.1` is cleaner for local testing).
|
||||||
|
- The server identity dialog will appear. The TLS leaf-cert SHA-256 fingerprint is shown;
|
||||||
|
accept to pin it. Subsequent connects to the same server will be silent (MATCHED).
|
||||||
|
|
||||||
|
## M5 — Moderation & admin UI
|
||||||
|
|
||||||
|
The WinForms client now exposes all M5 operations through the main menu and context menus:
|
||||||
|
|
||||||
|
- **Admin → Server accounts…** — create, reset password, and delete server accounts
|
||||||
|
(requires `can_admin_accounts`).
|
||||||
|
- **Channel tree right-click** — create, edit, and delete channels. The edit dialog exposes the
|
||||||
|
full per-channel Opus configuration: mono/stereo, sample rate, bitrate, frame size,
|
||||||
|
application mode, FEC, expected packet loss, DTX, and complexity.
|
||||||
|
- **User list right-click** — move, kick, ban, server mute/deafen, and set permissions
|
||||||
|
(items are gated by your own permissions).
|
||||||
|
- **Activity log** shows async `GenericResult` feedback for every moderation request.
|
||||||
|
- **User list** shows text indicators for self-mute, self-deafen, server-mute, and
|
||||||
|
server-deafen states.
|
||||||
|
|
||||||
|
These operations require an admin-provisioned account with the appropriate permissions; the
|
||||||
|
connect dialog already supports username/password auth.
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- **PTT is focus-scoped** — the push-to-talk key only works while the VoiceCat window has
|
||||||
|
focus. A system-wide `WH_KEYBOARD_LL` hook is not used in v1 (permissions + AV risk).
|
||||||
|
- **Receive-side noise reduction** checkbox in per-user tuning is wired end-to-end but is a
|
||||||
|
passthrough no-op until a real APM/NS backend is built (no working Windows/MSVC port of
|
||||||
|
`webrtc-audio-processing` upstream — see `docs/tech-stack.md §1`).
|
||||||
|
- **TOFU pins the TLS leaf cert**, not the declared Ed25519 identity fingerprint. Both are
|
||||||
|
shown in the identity dialog, but the cert fingerprint is the value that is actually
|
||||||
|
verified on reconnect. See `docs/security.md §1.1`.
|
||||||
|
|||||||
200
clients/windows/VoiceCat.App/Forms/AccountsDialog.cs
Normal file
200
clients/windows/VoiceCat.App/Forms/AccountsDialog.cs
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
using VoiceCat.Interop;
|
||||||
|
|
||||||
|
namespace VoiceCat.App.Forms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Admin account management: list, create, reset password, and delete server accounts.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AccountsDialog : Form
|
||||||
|
{
|
||||||
|
private readonly VoiceCatClient _client;
|
||||||
|
private readonly ListView _lvAccounts;
|
||||||
|
private readonly Button _btnRefresh;
|
||||||
|
private readonly Button _btnAdd;
|
||||||
|
private readonly Button _btnResetPassword;
|
||||||
|
private readonly Button _btnDelete;
|
||||||
|
|
||||||
|
public AccountsDialog(VoiceCatClient client)
|
||||||
|
{
|
||||||
|
_client = client;
|
||||||
|
|
||||||
|
Text = "Server accounts";
|
||||||
|
FormBorderStyle = FormBorderStyle.Sizable;
|
||||||
|
StartPosition = FormStartPosition.CenterParent;
|
||||||
|
AutoScaleMode = AutoScaleMode.Font;
|
||||||
|
ClientSize = new Size(560, 360);
|
||||||
|
MinimumSize = new Size(420, 260);
|
||||||
|
|
||||||
|
_lvAccounts = new ListView
|
||||||
|
{
|
||||||
|
View = View.Details,
|
||||||
|
FullRowSelect = true,
|
||||||
|
GridLines = true,
|
||||||
|
Dock = DockStyle.Fill,
|
||||||
|
TabIndex = 0,
|
||||||
|
};
|
||||||
|
_lvAccounts.Columns.Add("Username", 160);
|
||||||
|
_lvAccounts.Columns.Add("Admin", 50);
|
||||||
|
_lvAccounts.Columns.Add("Created", 130);
|
||||||
|
_lvAccounts.Columns.Add("Last login", 130);
|
||||||
|
_lvAccounts.SelectedIndexChanged += (_, _) => UpdateButtons();
|
||||||
|
|
||||||
|
var pnlButtons = new Panel
|
||||||
|
{
|
||||||
|
Dock = DockStyle.Bottom,
|
||||||
|
Height = 44,
|
||||||
|
};
|
||||||
|
|
||||||
|
_btnRefresh = new Button
|
||||||
|
{
|
||||||
|
Text = "&Refresh",
|
||||||
|
Location = new Point(12, 9),
|
||||||
|
Size = new Size(75, 27),
|
||||||
|
TabIndex = 1,
|
||||||
|
};
|
||||||
|
_btnRefresh.Click += (_, _) => RefreshList();
|
||||||
|
|
||||||
|
_btnAdd = new Button
|
||||||
|
{
|
||||||
|
Text = "&Add...",
|
||||||
|
Location = new Point(100, 9),
|
||||||
|
Size = new Size(75, 27),
|
||||||
|
TabIndex = 2,
|
||||||
|
};
|
||||||
|
_btnAdd.Click += (_, _) => AddAccount();
|
||||||
|
|
||||||
|
_btnResetPassword = new Button
|
||||||
|
{
|
||||||
|
Text = "&Reset password...",
|
||||||
|
Location = new Point(188, 9),
|
||||||
|
Size = new Size(120, 27),
|
||||||
|
TabIndex = 3,
|
||||||
|
Enabled = false,
|
||||||
|
};
|
||||||
|
_btnResetPassword.Click += (_, _) => ResetPassword();
|
||||||
|
|
||||||
|
_btnDelete = new Button
|
||||||
|
{
|
||||||
|
Text = "&Delete",
|
||||||
|
Location = new Point(320, 9),
|
||||||
|
Size = new Size(75, 27),
|
||||||
|
TabIndex = 4,
|
||||||
|
Enabled = false,
|
||||||
|
};
|
||||||
|
_btnDelete.Click += (_, _) => DeleteAccount();
|
||||||
|
|
||||||
|
pnlButtons.Controls.AddRange([_btnRefresh, _btnAdd, _btnResetPassword, _btnDelete]);
|
||||||
|
|
||||||
|
Controls.Add(_lvAccounts);
|
||||||
|
Controls.Add(pnlButtons);
|
||||||
|
|
||||||
|
Load += (_, _) => RefreshList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateButtons()
|
||||||
|
{
|
||||||
|
bool selected = _lvAccounts.SelectedItems.Count > 0;
|
||||||
|
_btnResetPassword.Enabled = selected;
|
||||||
|
_btnDelete.Enabled = selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshList()
|
||||||
|
{
|
||||||
|
_client.RequestAccountList();
|
||||||
|
// The event will come back asynchronously; poll briefly for the list.
|
||||||
|
var deadline = DateTime.UtcNow.AddSeconds(2);
|
||||||
|
List<AccountInfo>? accounts = null;
|
||||||
|
while (DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
_client.PumpEvents();
|
||||||
|
accounts = _client.ListAccounts();
|
||||||
|
if (accounts.Count > 0) break;
|
||||||
|
Thread.Sleep(30);
|
||||||
|
}
|
||||||
|
accounts ??= _client.ListAccounts();
|
||||||
|
|
||||||
|
_lvAccounts.BeginUpdate();
|
||||||
|
_lvAccounts.Items.Clear();
|
||||||
|
foreach (var a in accounts.OrderBy(a => a.Username))
|
||||||
|
{
|
||||||
|
var item = new ListViewItem(a.Username);
|
||||||
|
item.SubItems.Add(a.IsAdmin ? "Yes" : "No");
|
||||||
|
item.SubItems.Add(FormatDate(a.CreatedAtUnixMs));
|
||||||
|
item.SubItems.Add(FormatDate(a.LastLoginUnixMs));
|
||||||
|
_lvAccounts.Items.Add(item);
|
||||||
|
}
|
||||||
|
_lvAccounts.EndUpdate();
|
||||||
|
UpdateButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatDate(ulong ms)
|
||||||
|
{
|
||||||
|
if (ms == 0) return "—";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return DateTimeOffset.FromUnixTimeMilliseconds((long)ms).LocalDateTime.ToString("g");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddAccount()
|
||||||
|
{
|
||||||
|
using var userDlg = new InputDialog("Add account", "&Username:");
|
||||||
|
if (userDlg.ShowDialog(this) != DialogResult.OK || string.IsNullOrWhiteSpace(userDlg.TextValue))
|
||||||
|
return;
|
||||||
|
|
||||||
|
using var pwDlg = new PasswordPromptDialog($"Password for {userDlg.TextValue}:");
|
||||||
|
if (pwDlg.ShowDialog(this) != DialogResult.OK || string.IsNullOrEmpty(pwDlg.Password))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var r = _client.CreateAccount(userDlg.TextValue, pwDlg.Password);
|
||||||
|
if (r != VcResult.Ok)
|
||||||
|
{
|
||||||
|
MessageBox.Show(this, $"Create account failed: {r}", "VoiceCat",
|
||||||
|
MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
RefreshList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResetPassword()
|
||||||
|
{
|
||||||
|
if (_lvAccounts.SelectedItems.Count == 0) return;
|
||||||
|
string username = _lvAccounts.SelectedItems[0].Text;
|
||||||
|
|
||||||
|
using var dlg = new PasswordPromptDialog($"New password for {username}:");
|
||||||
|
if (dlg.ShowDialog(this) != DialogResult.OK || string.IsNullOrEmpty(dlg.Password))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var r = _client.ResetPassword(username, dlg.Password);
|
||||||
|
if (r != VcResult.Ok)
|
||||||
|
{
|
||||||
|
MessageBox.Show(this, $"Reset password failed: {r}", "VoiceCat",
|
||||||
|
MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
RefreshList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DeleteAccount()
|
||||||
|
{
|
||||||
|
if (_lvAccounts.SelectedItems.Count == 0) return;
|
||||||
|
string username = _lvAccounts.SelectedItems[0].Text;
|
||||||
|
|
||||||
|
var confirm = MessageBox.Show(this, $"Delete account '{username}'?",
|
||||||
|
"VoiceCat", MessageBoxButtons.YesNo, MessageBoxIcon.Question);
|
||||||
|
if (confirm != DialogResult.Yes) return;
|
||||||
|
|
||||||
|
var r = _client.DeleteAccount(username);
|
||||||
|
if (r != VcResult.Ok)
|
||||||
|
{
|
||||||
|
MessageBox.Show(this, $"Delete account failed: {r}", "VoiceCat",
|
||||||
|
MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
RefreshList();
|
||||||
|
}
|
||||||
|
}
|
||||||
187
clients/windows/VoiceCat.App/Forms/AddServerDialog.Designer.cs
generated
Normal file
187
clients/windows/VoiceCat.App/Forms/AddServerDialog.Designer.cs
generated
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
namespace VoiceCat.App.Forms;
|
||||||
|
|
||||||
|
partial class AddServerDialog
|
||||||
|
{
|
||||||
|
private System.ComponentModel.IContainer components = null!;
|
||||||
|
private Label lblDisplayName = null!;
|
||||||
|
private TextBox txtDisplayName = null!;
|
||||||
|
private Label lblHost = null!;
|
||||||
|
private TextBox txtHost = null!;
|
||||||
|
private Label lblPort = null!;
|
||||||
|
private NumericUpDown numPort = null!;
|
||||||
|
private GroupBox grpAuth = null!;
|
||||||
|
private RadioButton radioGuest = null!;
|
||||||
|
private RadioButton radioPassword = null!;
|
||||||
|
private Label lblNickname = null!;
|
||||||
|
private TextBox txtNickname = null!;
|
||||||
|
private Label lblUsername = null!;
|
||||||
|
private TextBox txtUsername = null!;
|
||||||
|
private Label lblPassword = null!;
|
||||||
|
private TextBox txtPassword = null!;
|
||||||
|
private CheckBox chkRememberPassword = null!;
|
||||||
|
private Button btnOk = null!;
|
||||||
|
private Button btnCancel = null!;
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing) components?.Dispose();
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeComponent()
|
||||||
|
{
|
||||||
|
components = new System.ComponentModel.Container();
|
||||||
|
lblDisplayName = new Label();
|
||||||
|
txtDisplayName = new TextBox();
|
||||||
|
lblHost = new Label();
|
||||||
|
txtHost = new TextBox();
|
||||||
|
lblPort = new Label();
|
||||||
|
numPort = new NumericUpDown();
|
||||||
|
grpAuth = new GroupBox();
|
||||||
|
radioGuest = new RadioButton();
|
||||||
|
radioPassword = new RadioButton();
|
||||||
|
lblNickname = new Label();
|
||||||
|
txtNickname = new TextBox();
|
||||||
|
lblUsername = new Label();
|
||||||
|
txtUsername = new TextBox();
|
||||||
|
lblPassword = new Label();
|
||||||
|
txtPassword = new TextBox();
|
||||||
|
chkRememberPassword = new CheckBox();
|
||||||
|
btnOk = new Button();
|
||||||
|
btnCancel = new Button();
|
||||||
|
|
||||||
|
int y = 12;
|
||||||
|
const int rowH = 30;
|
||||||
|
|
||||||
|
lblDisplayName.Text = "&Display name:";
|
||||||
|
lblDisplayName.AutoSize = true;
|
||||||
|
lblDisplayName.Location = new Point(12, y + 3);
|
||||||
|
lblDisplayName.TabIndex = 0;
|
||||||
|
txtDisplayName.AccessibleName = "Display name";
|
||||||
|
txtDisplayName.Location = new Point(140, y);
|
||||||
|
txtDisplayName.Size = new Size(280, 23);
|
||||||
|
txtDisplayName.TabIndex = 1;
|
||||||
|
y += rowH;
|
||||||
|
|
||||||
|
lblHost.Text = "&Host:";
|
||||||
|
lblHost.AutoSize = true;
|
||||||
|
lblHost.Location = new Point(12, y + 3);
|
||||||
|
lblHost.TabIndex = 2;
|
||||||
|
txtHost.AccessibleName = "Host";
|
||||||
|
txtHost.Location = new Point(140, y);
|
||||||
|
txtHost.Size = new Size(200, 23);
|
||||||
|
txtHost.TabIndex = 3;
|
||||||
|
txtHost.PlaceholderText = "127.0.0.1";
|
||||||
|
y += rowH;
|
||||||
|
|
||||||
|
lblPort.Text = "&Port:";
|
||||||
|
lblPort.AutoSize = true;
|
||||||
|
lblPort.Location = new Point(12, y + 3);
|
||||||
|
lblPort.TabIndex = 4;
|
||||||
|
numPort.AccessibleName = "Port";
|
||||||
|
numPort.Location = new Point(140, y);
|
||||||
|
numPort.Size = new Size(100, 23);
|
||||||
|
numPort.Minimum = 1;
|
||||||
|
numPort.Maximum = 65535;
|
||||||
|
numPort.Value = 8384;
|
||||||
|
numPort.TabIndex = 5;
|
||||||
|
y += rowH;
|
||||||
|
|
||||||
|
grpAuth.Text = "Authentication";
|
||||||
|
grpAuth.Location = new Point(12, y);
|
||||||
|
grpAuth.Size = new Size(408, 140);
|
||||||
|
grpAuth.TabIndex = 6;
|
||||||
|
|
||||||
|
radioGuest.Text = "Connect as &guest";
|
||||||
|
radioGuest.AutoSize = true;
|
||||||
|
radioGuest.Location = new Point(12, 24);
|
||||||
|
radioGuest.Checked = true;
|
||||||
|
radioGuest.TabIndex = 0;
|
||||||
|
|
||||||
|
lblNickname.Text = "&Nickname:";
|
||||||
|
lblNickname.AutoSize = true;
|
||||||
|
lblNickname.Location = new Point(30, 50);
|
||||||
|
lblNickname.TabIndex = 1;
|
||||||
|
txtNickname.AccessibleName = "Guest nickname";
|
||||||
|
txtNickname.Location = new Point(140, 47);
|
||||||
|
txtNickname.Size = new Size(240, 23);
|
||||||
|
txtNickname.TabIndex = 2;
|
||||||
|
|
||||||
|
radioPassword.Text = "Use a &saved account";
|
||||||
|
radioPassword.AutoSize = true;
|
||||||
|
radioPassword.Location = new Point(12, 76);
|
||||||
|
radioPassword.TabIndex = 3;
|
||||||
|
|
||||||
|
lblUsername.Text = "&Username:";
|
||||||
|
lblUsername.AutoSize = true;
|
||||||
|
lblUsername.Location = new Point(30, 102);
|
||||||
|
lblUsername.TabIndex = 4;
|
||||||
|
txtUsername.AccessibleName = "Username";
|
||||||
|
txtUsername.Location = new Point(140, 99);
|
||||||
|
txtUsername.Size = new Size(240, 23);
|
||||||
|
txtUsername.TabIndex = 5;
|
||||||
|
|
||||||
|
lblPassword.Text = "Pass&word:";
|
||||||
|
lblPassword.AutoSize = true;
|
||||||
|
lblPassword.Location = new Point(30, 132);
|
||||||
|
lblPassword.TabIndex = 6;
|
||||||
|
txtPassword.AccessibleName = "Password";
|
||||||
|
txtPassword.Location = new Point(140, 129);
|
||||||
|
txtPassword.Size = new Size(240, 23);
|
||||||
|
txtPassword.UseSystemPasswordChar = true;
|
||||||
|
txtPassword.TabIndex = 7;
|
||||||
|
|
||||||
|
grpAuth.Controls.Add(radioGuest);
|
||||||
|
grpAuth.Controls.Add(lblNickname);
|
||||||
|
grpAuth.Controls.Add(txtNickname);
|
||||||
|
grpAuth.Controls.Add(radioPassword);
|
||||||
|
grpAuth.Controls.Add(lblUsername);
|
||||||
|
grpAuth.Controls.Add(txtUsername);
|
||||||
|
grpAuth.Controls.Add(lblPassword);
|
||||||
|
grpAuth.Controls.Add(txtPassword);
|
||||||
|
y += 148;
|
||||||
|
|
||||||
|
chkRememberPassword.Text = "&Remember my password on this computer";
|
||||||
|
chkRememberPassword.AutoSize = true;
|
||||||
|
chkRememberPassword.AccessibleDescription =
|
||||||
|
"Stores the password protected by Windows Data Protection (DPAPI), decryptable " +
|
||||||
|
"only by your Windows account on this machine. Leave unchecked to be prompted " +
|
||||||
|
"for the password every time.";
|
||||||
|
chkRememberPassword.Location = new Point(12, y);
|
||||||
|
chkRememberPassword.TabIndex = 7;
|
||||||
|
y += rowH;
|
||||||
|
|
||||||
|
btnOk.Text = "&OK";
|
||||||
|
btnOk.DialogResult = DialogResult.OK;
|
||||||
|
btnOk.Location = new Point(264, y);
|
||||||
|
btnOk.Size = new Size(75, 27);
|
||||||
|
btnOk.TabIndex = 8;
|
||||||
|
|
||||||
|
btnCancel.Text = "&Cancel";
|
||||||
|
btnCancel.DialogResult = DialogResult.Cancel;
|
||||||
|
btnCancel.Location = new Point(345, y);
|
||||||
|
btnCancel.Size = new Size(75, 27);
|
||||||
|
btnCancel.TabIndex = 9;
|
||||||
|
y += rowH + 12;
|
||||||
|
|
||||||
|
AutoScaleMode = AutoScaleMode.Font;
|
||||||
|
ClientSize = new Size(432, y);
|
||||||
|
Controls.Add(lblDisplayName);
|
||||||
|
Controls.Add(txtDisplayName);
|
||||||
|
Controls.Add(lblHost);
|
||||||
|
Controls.Add(txtHost);
|
||||||
|
Controls.Add(lblPort);
|
||||||
|
Controls.Add(numPort);
|
||||||
|
Controls.Add(grpAuth);
|
||||||
|
Controls.Add(chkRememberPassword);
|
||||||
|
Controls.Add(btnOk);
|
||||||
|
Controls.Add(btnCancel);
|
||||||
|
AcceptButton = btnOk;
|
||||||
|
CancelButton = btnCancel;
|
||||||
|
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||||
|
MaximizeBox = false;
|
||||||
|
MinimizeBox = false;
|
||||||
|
StartPosition = FormStartPosition.CenterParent;
|
||||||
|
Text = "Add server";
|
||||||
|
}
|
||||||
|
}
|
||||||
80
clients/windows/VoiceCat.App/Forms/AddServerDialog.cs
Normal file
80
clients/windows/VoiceCat.App/Forms/AddServerDialog.cs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
using VoiceCat.App.Models;
|
||||||
|
|
||||||
|
namespace VoiceCat.App.Forms;
|
||||||
|
|
||||||
|
/// <summary>Add or edit a saved server entry. DialogResult.OK -> read Result.</summary>
|
||||||
|
public partial class AddServerDialog : Form
|
||||||
|
{
|
||||||
|
private readonly SavedServer _editing;
|
||||||
|
private bool _passwordChanged;
|
||||||
|
|
||||||
|
public SavedServer? Result { get; private set; }
|
||||||
|
|
||||||
|
public AddServerDialog(SavedServer? existing = null)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_editing = existing ?? new SavedServer();
|
||||||
|
|
||||||
|
txtDisplayName.Text = _editing.DisplayName;
|
||||||
|
txtHost.Text = _editing.Host;
|
||||||
|
numPort.Value = _editing.Port == 0 ? 8384 : _editing.Port;
|
||||||
|
txtNickname.Text = _editing.LastNickname;
|
||||||
|
radioGuest.Checked = _editing.AuthMode == AuthMode.Guest;
|
||||||
|
radioPassword.Checked = _editing.AuthMode == AuthMode.Password;
|
||||||
|
txtUsername.Text = _editing.SavedUsername ?? "";
|
||||||
|
chkRememberPassword.Checked = _editing.ProtectedPasswordBase64 is not null;
|
||||||
|
if (_editing.ProtectedPasswordBase64 is not null)
|
||||||
|
txtPassword.Text = "********"; // placeholder — never decrypt-and-show; re-typing replaces it
|
||||||
|
|
||||||
|
UpdateAuthFieldsEnabled();
|
||||||
|
radioGuest.CheckedChanged += (_, _) => UpdateAuthFieldsEnabled();
|
||||||
|
radioPassword.CheckedChanged += (_, _) => UpdateAuthFieldsEnabled();
|
||||||
|
txtPassword.TextChanged += (_, _) => _passwordChanged = true;
|
||||||
|
|
||||||
|
btnOk.Click += BtnOk_Click;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateAuthFieldsEnabled()
|
||||||
|
{
|
||||||
|
bool password = radioPassword.Checked;
|
||||||
|
txtNickname.Enabled = !password;
|
||||||
|
txtUsername.Enabled = password;
|
||||||
|
txtPassword.Enabled = password;
|
||||||
|
chkRememberPassword.Enabled = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BtnOk_Click(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(txtHost.Text))
|
||||||
|
{
|
||||||
|
MessageBox.Show(this, "Host is required.", "VoiceCat", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||||
|
DialogResult = DialogResult.None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_editing.DisplayName = txtDisplayName.Text.Trim();
|
||||||
|
_editing.Host = txtHost.Text.Trim();
|
||||||
|
_editing.Port = (ushort)numPort.Value;
|
||||||
|
_editing.AuthMode = radioPassword.Checked ? AuthMode.Password : AuthMode.Guest;
|
||||||
|
|
||||||
|
if (_editing.AuthMode == AuthMode.Guest)
|
||||||
|
{
|
||||||
|
_editing.LastNickname = txtNickname.Text.Trim();
|
||||||
|
_editing.SavedUsername = null;
|
||||||
|
_editing.ProtectedPasswordBase64 = null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_editing.SavedUsername = txtUsername.Text.Trim();
|
||||||
|
if (chkRememberPassword.Checked && _passwordChanged && txtPassword.Text.Length > 0)
|
||||||
|
_editing.ProtectedPasswordBase64 = PasswordProtector.Protect(txtPassword.Text);
|
||||||
|
else if (!chkRememberPassword.Checked)
|
||||||
|
_editing.ProtectedPasswordBase64 = null;
|
||||||
|
// else: remember-password still checked and password box untouched (still shows
|
||||||
|
// the placeholder) — keep whatever was already protected/stored.
|
||||||
|
}
|
||||||
|
|
||||||
|
Result = _editing;
|
||||||
|
DialogResult = DialogResult.OK;
|
||||||
|
}
|
||||||
|
}
|
||||||
94
clients/windows/VoiceCat.App/Forms/BanUserDialog.cs
Normal file
94
clients/windows/VoiceCat.App/Forms/BanUserDialog.cs
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
namespace VoiceCat.App.Forms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prompt for a ban reason and duration.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BanUserDialog : Form
|
||||||
|
{
|
||||||
|
private readonly TextBox _txtReason;
|
||||||
|
private readonly ComboBox _cboDuration;
|
||||||
|
|
||||||
|
public string Reason => _txtReason.Text.Trim();
|
||||||
|
public ulong ExpiresUnixMs { get; private set; }
|
||||||
|
|
||||||
|
public BanUserDialog(string nickname)
|
||||||
|
{
|
||||||
|
Text = $"Ban {nickname}";
|
||||||
|
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||||
|
MaximizeBox = false;
|
||||||
|
MinimizeBox = false;
|
||||||
|
StartPosition = FormStartPosition.CenterParent;
|
||||||
|
AutoScaleMode = AutoScaleMode.Font;
|
||||||
|
ClientSize = new Size(384, 160);
|
||||||
|
|
||||||
|
var lblReason = new Label
|
||||||
|
{
|
||||||
|
Text = "&Reason:",
|
||||||
|
Location = new Point(12, 12),
|
||||||
|
AutoSize = true,
|
||||||
|
};
|
||||||
|
_txtReason = new TextBox
|
||||||
|
{
|
||||||
|
Location = new Point(12, 34),
|
||||||
|
Size = new Size(360, 23),
|
||||||
|
TabIndex = 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
var lblDuration = new Label
|
||||||
|
{
|
||||||
|
Text = "&Duration:",
|
||||||
|
Location = new Point(12, 66),
|
||||||
|
AutoSize = true,
|
||||||
|
};
|
||||||
|
_cboDuration = new ComboBox
|
||||||
|
{
|
||||||
|
Location = new Point(12, 88),
|
||||||
|
Size = new Size(200, 23),
|
||||||
|
DropDownStyle = ComboBoxStyle.DropDownList,
|
||||||
|
TabIndex = 2,
|
||||||
|
};
|
||||||
|
_cboDuration.Items.AddRange(new object[]
|
||||||
|
{
|
||||||
|
new DurationItem("1 hour", TimeSpan.FromHours(1)),
|
||||||
|
new DurationItem("1 day", TimeSpan.FromDays(1)),
|
||||||
|
new DurationItem("1 week", TimeSpan.FromDays(7)),
|
||||||
|
new DurationItem("Permanent", TimeSpan.Zero),
|
||||||
|
});
|
||||||
|
_cboDuration.SelectedIndex = 1;
|
||||||
|
|
||||||
|
var btnOk = new Button
|
||||||
|
{
|
||||||
|
Text = "&Ban",
|
||||||
|
DialogResult = DialogResult.OK,
|
||||||
|
Location = new Point(216, 126),
|
||||||
|
Size = new Size(75, 27),
|
||||||
|
TabIndex = 3,
|
||||||
|
};
|
||||||
|
var btnCancel = new Button
|
||||||
|
{
|
||||||
|
Text = "&Cancel",
|
||||||
|
DialogResult = DialogResult.Cancel,
|
||||||
|
Location = new Point(297, 126),
|
||||||
|
Size = new Size(75, 27),
|
||||||
|
TabIndex = 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
AcceptButton = btnOk;
|
||||||
|
CancelButton = btnCancel;
|
||||||
|
Controls.AddRange([lblReason, _txtReason, lblDuration, _cboDuration, btnOk, btnCancel]);
|
||||||
|
|
||||||
|
btnOk.Click += (_, _) =>
|
||||||
|
{
|
||||||
|
var selected = _cboDuration.SelectedItem as DurationItem;
|
||||||
|
ExpiresUnixMs = selected?.Duration == TimeSpan.Zero
|
||||||
|
? 0
|
||||||
|
: (ulong)DateTimeOffset.UtcNow.Add(selected!.Duration).ToUnixTimeMilliseconds();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class DurationItem(string label, TimeSpan duration)
|
||||||
|
{
|
||||||
|
public TimeSpan Duration { get; } = duration;
|
||||||
|
public override string ToString() => label;
|
||||||
|
}
|
||||||
|
}
|
||||||
384
clients/windows/VoiceCat.App/Forms/ChannelEditDialog.cs
Normal file
384
clients/windows/VoiceCat.App/Forms/ChannelEditDialog.cs
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
using VoiceCat.Interop;
|
||||||
|
|
||||||
|
namespace VoiceCat.App.Forms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create or edit a channel, including its full per-channel Opus audio config.
|
||||||
|
/// Returns DialogResult.OK with Result set.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ChannelEditDialog : Form
|
||||||
|
{
|
||||||
|
private readonly bool _isCreate;
|
||||||
|
private readonly uint _editingId;
|
||||||
|
private readonly List<ChannelInfo> _channels;
|
||||||
|
|
||||||
|
private TextBox _txtName = null!;
|
||||||
|
private TextBox _txtTopic = null!;
|
||||||
|
private ComboBox _cboParent = null!;
|
||||||
|
private NumericUpDown _numMaxUsers = null!;
|
||||||
|
private NumericUpDown _numSortOrder = null!;
|
||||||
|
private CheckBox _chkPassword = null!;
|
||||||
|
private TextBox _txtPassword = null!;
|
||||||
|
|
||||||
|
private ComboBox _cboMode = null!;
|
||||||
|
private NumericUpDown _numSampleRate = null!;
|
||||||
|
private NumericUpDown _numBitrate = null!;
|
||||||
|
private NumericUpDown _numFrameMs = null!;
|
||||||
|
private ComboBox _cboApplication = null!;
|
||||||
|
private CheckBox _chkFec = null!;
|
||||||
|
private NumericUpDown _numExpectedLoss = null!;
|
||||||
|
private CheckBox _chkDtx = null!;
|
||||||
|
private NumericUpDown _numComplexity = null!;
|
||||||
|
|
||||||
|
public ChannelEditInfo? Result { get; private set; }
|
||||||
|
|
||||||
|
public ChannelEditDialog(IEnumerable<ChannelInfo> channels, ChannelEditInfo? existing = null)
|
||||||
|
{
|
||||||
|
_isCreate = existing is null;
|
||||||
|
_editingId = existing?.Id ?? 0;
|
||||||
|
_channels = channels.Where(c => c.Id != _editingId).ToList();
|
||||||
|
|
||||||
|
Text = _isCreate ? "Create channel" : "Edit channel";
|
||||||
|
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||||
|
MaximizeBox = false;
|
||||||
|
MinimizeBox = false;
|
||||||
|
StartPosition = FormStartPosition.CenterParent;
|
||||||
|
AutoScaleMode = AutoScaleMode.Font;
|
||||||
|
ClientSize = new Size(520, 520);
|
||||||
|
|
||||||
|
var tabs = new TabControl
|
||||||
|
{
|
||||||
|
Dock = DockStyle.Fill,
|
||||||
|
TabIndex = 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
var pageGeneral = new TabPage("General");
|
||||||
|
BuildGeneralPage(pageGeneral, existing);
|
||||||
|
var pageAudio = new TabPage("Audio config");
|
||||||
|
BuildAudioPage(pageAudio, existing?.Audio);
|
||||||
|
|
||||||
|
tabs.TabPages.Add(pageGeneral);
|
||||||
|
tabs.TabPages.Add(pageAudio);
|
||||||
|
|
||||||
|
var btnOk = new Button
|
||||||
|
{
|
||||||
|
Text = "&OK",
|
||||||
|
DialogResult = DialogResult.OK,
|
||||||
|
Location = new Point(350, 486),
|
||||||
|
Size = new Size(75, 27),
|
||||||
|
TabIndex = 100,
|
||||||
|
};
|
||||||
|
var btnCancel = new Button
|
||||||
|
{
|
||||||
|
Text = "&Cancel",
|
||||||
|
DialogResult = DialogResult.Cancel,
|
||||||
|
Location = new Point(431, 486),
|
||||||
|
Size = new Size(75, 27),
|
||||||
|
TabIndex = 101,
|
||||||
|
};
|
||||||
|
|
||||||
|
AcceptButton = btnOk;
|
||||||
|
CancelButton = btnCancel;
|
||||||
|
|
||||||
|
Controls.Add(tabs);
|
||||||
|
Controls.Add(btnOk);
|
||||||
|
Controls.Add(btnCancel);
|
||||||
|
|
||||||
|
btnOk.Click += BtnOk_Click;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildGeneralPage(TabPage page, ChannelEditInfo? existing)
|
||||||
|
{
|
||||||
|
int y = 16;
|
||||||
|
int labelWidth = 110;
|
||||||
|
int inputX = 128;
|
||||||
|
|
||||||
|
AddLabel(page, "&Name:", 12, y, labelWidth);
|
||||||
|
_txtName = new TextBox
|
||||||
|
{
|
||||||
|
Location = new Point(inputX, y - 2),
|
||||||
|
Size = new Size(360, 23),
|
||||||
|
Text = existing?.Name ?? "",
|
||||||
|
TabIndex = 1,
|
||||||
|
};
|
||||||
|
page.Controls.Add(_txtName);
|
||||||
|
y += 36;
|
||||||
|
|
||||||
|
AddLabel(page, "&Topic:", 12, y, labelWidth);
|
||||||
|
_txtTopic = new TextBox
|
||||||
|
{
|
||||||
|
Location = new Point(inputX, y - 2),
|
||||||
|
Size = new Size(360, 23),
|
||||||
|
Text = existing?.Topic ?? "",
|
||||||
|
TabIndex = 2,
|
||||||
|
};
|
||||||
|
page.Controls.Add(_txtTopic);
|
||||||
|
y += 36;
|
||||||
|
|
||||||
|
AddLabel(page, "&Parent channel:", 12, y, labelWidth);
|
||||||
|
_cboParent = new ComboBox
|
||||||
|
{
|
||||||
|
Location = new Point(inputX, y - 2),
|
||||||
|
Size = new Size(360, 23),
|
||||||
|
DropDownStyle = ComboBoxStyle.DropDownList,
|
||||||
|
TabIndex = 3,
|
||||||
|
};
|
||||||
|
_cboParent.Items.Add(new ChannelItem("(root)", 0));
|
||||||
|
foreach (var ch in _channels.OrderBy(c => c.Name))
|
||||||
|
_cboParent.Items.Add(new ChannelItem(ch.Name, ch.Id));
|
||||||
|
SelectParent(existing?.ParentId ?? 0);
|
||||||
|
page.Controls.Add(_cboParent);
|
||||||
|
y += 36;
|
||||||
|
|
||||||
|
AddLabel(page, "&Max users:", 12, y, labelWidth);
|
||||||
|
_numMaxUsers = new NumericUpDown
|
||||||
|
{
|
||||||
|
Location = new Point(inputX, y - 2),
|
||||||
|
Size = new Size(120, 23),
|
||||||
|
Minimum = 0,
|
||||||
|
Maximum = 10000,
|
||||||
|
Value = existing?.MaxUsers ?? 0,
|
||||||
|
TabIndex = 4,
|
||||||
|
};
|
||||||
|
page.Controls.Add(_numMaxUsers);
|
||||||
|
AddLabel(page, "(0 = unlimited)", inputX + 128, y, 100);
|
||||||
|
y += 36;
|
||||||
|
|
||||||
|
AddLabel(page, "&Sort order:", 12, y, labelWidth);
|
||||||
|
_numSortOrder = new NumericUpDown
|
||||||
|
{
|
||||||
|
Location = new Point(inputX, y - 2),
|
||||||
|
Size = new Size(120, 23),
|
||||||
|
Minimum = 0,
|
||||||
|
Maximum = uint.MaxValue,
|
||||||
|
Value = existing?.SortOrder ?? 0,
|
||||||
|
TabIndex = 5,
|
||||||
|
};
|
||||||
|
page.Controls.Add(_numSortOrder);
|
||||||
|
y += 36;
|
||||||
|
|
||||||
|
_chkPassword = new CheckBox
|
||||||
|
{
|
||||||
|
Text = "&Password protected",
|
||||||
|
Location = new Point(inputX, y),
|
||||||
|
AutoSize = true,
|
||||||
|
Checked = existing?.PasswordProtected ?? false,
|
||||||
|
TabIndex = 6,
|
||||||
|
};
|
||||||
|
page.Controls.Add(_chkPassword);
|
||||||
|
y += 28;
|
||||||
|
|
||||||
|
_txtPassword = new TextBox
|
||||||
|
{
|
||||||
|
Location = new Point(inputX, y),
|
||||||
|
Size = new Size(360, 23),
|
||||||
|
UseSystemPasswordChar = true,
|
||||||
|
Enabled = _chkPassword.Checked,
|
||||||
|
Text = existing?.Password ?? "",
|
||||||
|
TabIndex = 7,
|
||||||
|
};
|
||||||
|
page.Controls.Add(_txtPassword);
|
||||||
|
_chkPassword.CheckedChanged += (_, _) => _txtPassword.Enabled = _chkPassword.Checked;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildAudioPage(TabPage page, AudioConfigInfo? audio)
|
||||||
|
{
|
||||||
|
audio ??= new AudioConfigInfo(0, false, 48000, 0, 20, 0, true, 0, false, 10);
|
||||||
|
|
||||||
|
int y = 16;
|
||||||
|
int labelWidth = 150;
|
||||||
|
int inputX = 168;
|
||||||
|
|
||||||
|
AddLabel(page, "Codec (0 = Opus):", 12, y, labelWidth);
|
||||||
|
var numCodec = new NumericUpDown
|
||||||
|
{
|
||||||
|
Location = new Point(inputX, y - 2),
|
||||||
|
Size = new Size(120, 23),
|
||||||
|
Minimum = 0,
|
||||||
|
Maximum = 0,
|
||||||
|
Value = audio.Codec,
|
||||||
|
Enabled = false,
|
||||||
|
};
|
||||||
|
page.Controls.Add(numCodec);
|
||||||
|
y += 34;
|
||||||
|
|
||||||
|
AddLabel(page, "&Mode:", 12, y, labelWidth);
|
||||||
|
_cboMode = new ComboBox
|
||||||
|
{
|
||||||
|
Location = new Point(inputX, y - 2),
|
||||||
|
Size = new Size(160, 23),
|
||||||
|
DropDownStyle = ComboBoxStyle.DropDownList,
|
||||||
|
TabIndex = 10,
|
||||||
|
};
|
||||||
|
_cboMode.Items.AddRange(["Mono", "Stereo"]);
|
||||||
|
_cboMode.SelectedIndex = audio.Stereo ? 1 : 0;
|
||||||
|
page.Controls.Add(_cboMode);
|
||||||
|
y += 34;
|
||||||
|
|
||||||
|
AddLabel(page, "Sample &rate (Hz):", 12, y, labelWidth);
|
||||||
|
_numSampleRate = new NumericUpDown
|
||||||
|
{
|
||||||
|
Location = new Point(inputX, y - 2),
|
||||||
|
Size = new Size(120, 23),
|
||||||
|
Minimum = 8000,
|
||||||
|
Maximum = 96000,
|
||||||
|
Value = audio.SampleRate,
|
||||||
|
TabIndex = 11,
|
||||||
|
};
|
||||||
|
page.Controls.Add(_numSampleRate);
|
||||||
|
y += 34;
|
||||||
|
|
||||||
|
AddLabel(page, "&Bitrate (bps, 0 = default):", 12, y, labelWidth);
|
||||||
|
_numBitrate = new NumericUpDown
|
||||||
|
{
|
||||||
|
Location = new Point(inputX, y - 2),
|
||||||
|
Size = new Size(120, 23),
|
||||||
|
Minimum = 0,
|
||||||
|
Maximum = 512000,
|
||||||
|
Increment = 1000,
|
||||||
|
Value = audio.BitrateBps,
|
||||||
|
TabIndex = 12,
|
||||||
|
};
|
||||||
|
page.Controls.Add(_numBitrate);
|
||||||
|
y += 34;
|
||||||
|
|
||||||
|
AddLabel(page, "Frame &ms:", 12, y, labelWidth);
|
||||||
|
_numFrameMs = new NumericUpDown
|
||||||
|
{
|
||||||
|
Location = new Point(inputX, y - 2),
|
||||||
|
Size = new Size(120, 23),
|
||||||
|
Minimum = 5,
|
||||||
|
Maximum = 120,
|
||||||
|
Value = audio.FrameMs,
|
||||||
|
TabIndex = 13,
|
||||||
|
};
|
||||||
|
page.Controls.Add(_numFrameMs);
|
||||||
|
y += 34;
|
||||||
|
|
||||||
|
AddLabel(page, "&Application:", 12, y, labelWidth);
|
||||||
|
_cboApplication = new ComboBox
|
||||||
|
{
|
||||||
|
Location = new Point(inputX, y - 2),
|
||||||
|
Size = new Size(160, 23),
|
||||||
|
DropDownStyle = ComboBoxStyle.DropDownList,
|
||||||
|
TabIndex = 14,
|
||||||
|
};
|
||||||
|
_cboApplication.Items.AddRange(["VoIP", "Audio", "Low delay"]);
|
||||||
|
_cboApplication.SelectedIndex = (int)Math.Min(2, audio.Application);
|
||||||
|
page.Controls.Add(_cboApplication);
|
||||||
|
y += 34;
|
||||||
|
|
||||||
|
AddLabel(page, "Expected packet loss (%):", 12, y, labelWidth);
|
||||||
|
_numExpectedLoss = new NumericUpDown
|
||||||
|
{
|
||||||
|
Location = new Point(inputX, y - 2),
|
||||||
|
Size = new Size(120, 23),
|
||||||
|
Minimum = 0,
|
||||||
|
Maximum = 100,
|
||||||
|
Value = audio.ExpectedPacketLoss,
|
||||||
|
TabIndex = 15,
|
||||||
|
};
|
||||||
|
page.Controls.Add(_numExpectedLoss);
|
||||||
|
y += 34;
|
||||||
|
|
||||||
|
AddLabel(page, "Com&plexity (0–10):", 12, y, labelWidth);
|
||||||
|
_numComplexity = new NumericUpDown
|
||||||
|
{
|
||||||
|
Location = new Point(inputX, y - 2),
|
||||||
|
Size = new Size(120, 23),
|
||||||
|
Minimum = 0,
|
||||||
|
Maximum = 10,
|
||||||
|
Value = audio.Complexity,
|
||||||
|
TabIndex = 16,
|
||||||
|
};
|
||||||
|
page.Controls.Add(_numComplexity);
|
||||||
|
y += 34;
|
||||||
|
|
||||||
|
_chkFec = new CheckBox
|
||||||
|
{
|
||||||
|
Text = "&FEC",
|
||||||
|
Location = new Point(inputX, y),
|
||||||
|
AutoSize = true,
|
||||||
|
Checked = audio.Fec,
|
||||||
|
TabIndex = 17,
|
||||||
|
};
|
||||||
|
page.Controls.Add(_chkFec);
|
||||||
|
y += 28;
|
||||||
|
|
||||||
|
_chkDtx = new CheckBox
|
||||||
|
{
|
||||||
|
Text = "&DTX",
|
||||||
|
Location = new Point(inputX, y),
|
||||||
|
AutoSize = true,
|
||||||
|
Checked = audio.Dtx,
|
||||||
|
TabIndex = 18,
|
||||||
|
};
|
||||||
|
page.Controls.Add(_chkDtx);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddLabel(Control parent, string text, int x, int y, int width)
|
||||||
|
{
|
||||||
|
parent.Controls.Add(new Label
|
||||||
|
{
|
||||||
|
Text = text,
|
||||||
|
Location = new Point(x, y),
|
||||||
|
Size = new Size(width, 17),
|
||||||
|
TextAlign = System.Drawing.ContentAlignment.MiddleLeft,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SelectParent(uint parentId)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < _cboParent.Items.Count; i++)
|
||||||
|
{
|
||||||
|
if (_cboParent.Items[i] is ChannelItem item && item.Id == parentId)
|
||||||
|
{
|
||||||
|
_cboParent.SelectedIndex = i;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_cboParent.SelectedIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BtnOk_Click(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
string name = _txtName.Text.Trim();
|
||||||
|
if (string.IsNullOrEmpty(name))
|
||||||
|
{
|
||||||
|
MessageBox.Show(this, "Channel name is required.", "VoiceCat",
|
||||||
|
MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||||
|
DialogResult = DialogResult.None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint parentId = (_cboParent.SelectedItem as ChannelItem)?.Id ?? 0;
|
||||||
|
var audio = new AudioConfigInfo(
|
||||||
|
Codec: 0,
|
||||||
|
Stereo: _cboMode.SelectedIndex == 1,
|
||||||
|
SampleRate: (uint)_numSampleRate.Value,
|
||||||
|
BitrateBps: (uint)_numBitrate.Value,
|
||||||
|
FrameMs: (uint)_numFrameMs.Value,
|
||||||
|
Application: (uint)_cboApplication.SelectedIndex,
|
||||||
|
Fec: _chkFec.Checked,
|
||||||
|
ExpectedPacketLoss: (uint)_numExpectedLoss.Value,
|
||||||
|
Dtx: _chkDtx.Checked,
|
||||||
|
Complexity: (uint)_numComplexity.Value);
|
||||||
|
|
||||||
|
Result = new ChannelEditInfo(
|
||||||
|
Id: _editingId,
|
||||||
|
ParentId: parentId,
|
||||||
|
Name: name,
|
||||||
|
Topic: _txtTopic.Text.Trim(),
|
||||||
|
PasswordProtected: _chkPassword.Checked,
|
||||||
|
Password: _chkPassword.Checked ? _txtPassword.Text : null,
|
||||||
|
MaxUsers: (uint)_numMaxUsers.Value,
|
||||||
|
SortOrder: (uint)_numSortOrder.Value,
|
||||||
|
Audio: audio);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ChannelItem(string name, uint id)
|
||||||
|
{
|
||||||
|
public uint Id { get; } = id;
|
||||||
|
public override string ToString() => name;
|
||||||
|
}
|
||||||
|
}
|
||||||
83
clients/windows/VoiceCat.App/Forms/ConnectDialog.Designer.cs
generated
Normal file
83
clients/windows/VoiceCat.App/Forms/ConnectDialog.Designer.cs
generated
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
namespace VoiceCat.App.Forms;
|
||||||
|
|
||||||
|
partial class ConnectDialog
|
||||||
|
{
|
||||||
|
private System.ComponentModel.IContainer components = null!;
|
||||||
|
private Label lblServers = null!;
|
||||||
|
private ListBox lstServers = null!;
|
||||||
|
private Button btnConnect = null!;
|
||||||
|
private Button btnAddNew = null!;
|
||||||
|
private Button btnEdit = null!;
|
||||||
|
private Button btnRemove = null!;
|
||||||
|
private Label lblStatus = null!;
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing) components?.Dispose();
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeComponent()
|
||||||
|
{
|
||||||
|
components = new System.ComponentModel.Container();
|
||||||
|
lblServers = new Label();
|
||||||
|
lstServers = new ListBox();
|
||||||
|
btnConnect = new Button();
|
||||||
|
btnAddNew = new Button();
|
||||||
|
btnEdit = new Button();
|
||||||
|
btnRemove = new Button();
|
||||||
|
lblStatus = new Label();
|
||||||
|
|
||||||
|
lblServers.Text = "&Saved servers:";
|
||||||
|
lblServers.AutoSize = true;
|
||||||
|
lblServers.Location = new Point(12, 12);
|
||||||
|
lblServers.TabIndex = 0;
|
||||||
|
|
||||||
|
lstServers.AccessibleName = "Saved servers";
|
||||||
|
lstServers.Location = new Point(12, 32);
|
||||||
|
lstServers.Size = new Size(360, 200);
|
||||||
|
lstServers.TabIndex = 1;
|
||||||
|
lstServers.SelectedIndexChanged += (_, _) => UpdateButtonsEnabled();
|
||||||
|
|
||||||
|
btnConnect.Text = "&Connect";
|
||||||
|
btnConnect.Location = new Point(384, 32);
|
||||||
|
btnConnect.Size = new Size(110, 27);
|
||||||
|
btnConnect.TabIndex = 2;
|
||||||
|
|
||||||
|
btnAddNew.Text = "&Add new...";
|
||||||
|
btnAddNew.Location = new Point(384, 65);
|
||||||
|
btnAddNew.Size = new Size(110, 27);
|
||||||
|
btnAddNew.TabIndex = 3;
|
||||||
|
|
||||||
|
btnEdit.Text = "&Edit...";
|
||||||
|
btnEdit.Location = new Point(384, 98);
|
||||||
|
btnEdit.Size = new Size(110, 27);
|
||||||
|
btnEdit.TabIndex = 4;
|
||||||
|
|
||||||
|
btnRemove.Text = "&Remove";
|
||||||
|
btnRemove.Location = new Point(384, 131);
|
||||||
|
btnRemove.Size = new Size(110, 27);
|
||||||
|
btnRemove.TabIndex = 5;
|
||||||
|
|
||||||
|
lblStatus.AccessibleName = "Connection status";
|
||||||
|
lblStatus.Location = new Point(12, 244);
|
||||||
|
lblStatus.Size = new Size(482, 23);
|
||||||
|
lblStatus.TabIndex = 6;
|
||||||
|
|
||||||
|
AutoScaleMode = AutoScaleMode.Font;
|
||||||
|
ClientSize = new Size(506, 280);
|
||||||
|
Controls.Add(lblServers);
|
||||||
|
Controls.Add(lstServers);
|
||||||
|
Controls.Add(btnConnect);
|
||||||
|
Controls.Add(btnAddNew);
|
||||||
|
Controls.Add(btnEdit);
|
||||||
|
Controls.Add(btnRemove);
|
||||||
|
Controls.Add(lblStatus);
|
||||||
|
AcceptButton = btnConnect;
|
||||||
|
MinimizeBox = false;
|
||||||
|
MaximizeBox = false;
|
||||||
|
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||||
|
StartPosition = FormStartPosition.CenterScreen;
|
||||||
|
Text = "VoiceCat — Connect to a server";
|
||||||
|
}
|
||||||
|
}
|
||||||
279
clients/windows/VoiceCat.App/Forms/ConnectDialog.cs
Normal file
279
clients/windows/VoiceCat.App/Forms/ConnectDialog.cs
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
using VoiceCat.App.Models;
|
||||||
|
using VoiceCat.Interop;
|
||||||
|
|
||||||
|
namespace VoiceCat.App.Forms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saved-server list + connect flow. On success, ConnectedClient/SelfUserId/Nickname are set
|
||||||
|
/// and DialogResult == OK; the caller (Program.cs) takes ownership of ConnectedClient (does
|
||||||
|
/// NOT dispose it here — MainForm owns its lifetime from that point on).
|
||||||
|
/// </summary>
|
||||||
|
public partial class ConnectDialog : Form
|
||||||
|
{
|
||||||
|
private readonly List<SavedServer> _servers;
|
||||||
|
private readonly System.Windows.Forms.Timer _pumpTimer = new() { Interval = 30 };
|
||||||
|
|
||||||
|
private VoiceCatClient? _client;
|
||||||
|
private bool _identityDialogShown;
|
||||||
|
|
||||||
|
public VoiceCatClient? ConnectedClient { get; private set; }
|
||||||
|
public uint SelfUserId { get; private set; }
|
||||||
|
public string Nickname { get; private set; } = "";
|
||||||
|
|
||||||
|
public ConnectDialog()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_servers = ServerListStore.Load();
|
||||||
|
RefreshServerList();
|
||||||
|
|
||||||
|
_pumpTimer.Tick += (_, _) =>
|
||||||
|
{
|
||||||
|
try { _client?.PumpEvents(); }
|
||||||
|
catch (Exception ex) { Console.Error.WriteLine($"[ConnectDialog] EXCEPTION in PumpEvents/event handler: {ex}"); }
|
||||||
|
};
|
||||||
|
|
||||||
|
btnAddNew.Click += BtnAddNew_Click;
|
||||||
|
btnEdit.Click += BtnEdit_Click;
|
||||||
|
btnRemove.Click += BtnRemove_Click;
|
||||||
|
btnConnect.Click += BtnConnect_Click;
|
||||||
|
lstServers.DoubleClick += BtnConnect_Click;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshServerList()
|
||||||
|
{
|
||||||
|
object? previouslySelected = lstServers.SelectedItem;
|
||||||
|
lstServers.Items.Clear();
|
||||||
|
foreach (var s in _servers) lstServers.Items.Add(s);
|
||||||
|
if (previouslySelected is not null && _servers.Contains(previouslySelected))
|
||||||
|
lstServers.SelectedItem = previouslySelected;
|
||||||
|
else if (lstServers.Items.Count > 0)
|
||||||
|
lstServers.SelectedIndex = 0;
|
||||||
|
UpdateButtonsEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateButtonsEnabled()
|
||||||
|
{
|
||||||
|
bool hasSelection = lstServers.SelectedItem is not null;
|
||||||
|
btnConnect.Enabled = hasSelection;
|
||||||
|
btnEdit.Enabled = hasSelection;
|
||||||
|
btnRemove.Enabled = hasSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BtnAddNew_Click(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
using var dlg = new AddServerDialog();
|
||||||
|
if (dlg.ShowDialog(this) != DialogResult.OK || dlg.Result is null) return;
|
||||||
|
_servers.Add(dlg.Result);
|
||||||
|
ServerListStore.Save(_servers);
|
||||||
|
RefreshServerList();
|
||||||
|
lstServers.SelectedItem = dlg.Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BtnEdit_Click(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (lstServers.SelectedItem is not SavedServer existing) return;
|
||||||
|
using var dlg = new AddServerDialog(existing);
|
||||||
|
if (dlg.ShowDialog(this) != DialogResult.OK) return;
|
||||||
|
ServerListStore.Save(_servers);
|
||||||
|
RefreshServerList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BtnRemove_Click(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (lstServers.SelectedItem is not SavedServer existing) return;
|
||||||
|
var confirm = MessageBox.Show(this, $"Remove '{existing}' from the saved-server list?",
|
||||||
|
"VoiceCat", MessageBoxButtons.YesNo, MessageBoxIcon.Question);
|
||||||
|
if (confirm != DialogResult.Yes) return;
|
||||||
|
_servers.Remove(existing);
|
||||||
|
ServerListStore.Save(_servers);
|
||||||
|
RefreshServerList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BtnConnect_Click(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[ConnectDialog] BtnConnect_Click fired");
|
||||||
|
if (lstServers.SelectedItem is not SavedServer server)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[ConnectDialog] no SavedServer selected — ignoring click");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Console.WriteLine($"[ConnectDialog] selected server: Host={server.Host} Port={server.Port} AuthMode={server.AuthMode}");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
StartConnect(server);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[ConnectDialog] EXCEPTION in StartConnect: {ex}");
|
||||||
|
lblStatus.Text = $"Internal error: {ex.Message}";
|
||||||
|
CleanupFailedAttempt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartConnect(SavedServer server)
|
||||||
|
{
|
||||||
|
SetBusy(true);
|
||||||
|
lblStatus.Text = "Connecting...";
|
||||||
|
|
||||||
|
string tofuDir = Path.GetDirectoryName(ServerListStore.TofuStorePath)!;
|
||||||
|
Console.WriteLine($"[ConnectDialog] tofu store dir: {tofuDir}");
|
||||||
|
Directory.CreateDirectory(tofuDir);
|
||||||
|
|
||||||
|
Console.WriteLine("[ConnectDialog] creating VoiceCatClient...");
|
||||||
|
_client = new VoiceCatClient("VoiceCat-Windows", VoiceCatClient.VersionString,
|
||||||
|
VcLogLevel.Info, ServerListStore.TofuStorePath);
|
||||||
|
Console.WriteLine("[ConnectDialog] VoiceCatClient created OK");
|
||||||
|
_client.EventReceived += OnEvent;
|
||||||
|
_identityDialogShown = false;
|
||||||
|
_pumpTimer.Start();
|
||||||
|
Console.WriteLine($"[ConnectDialog] pump timer started, Enabled={_pumpTimer.Enabled}, Interval={_pumpTimer.Interval}");
|
||||||
|
|
||||||
|
Console.WriteLine($"[ConnectDialog] calling Connect({server.Host}, {server.Port})...");
|
||||||
|
var connectResult = _client.Connect(server.Host, server.Port);
|
||||||
|
Console.WriteLine($"[ConnectDialog] Connect() returned {connectResult}");
|
||||||
|
if (connectResult != VcResult.Ok)
|
||||||
|
{
|
||||||
|
lblStatus.Text = $"Connect failed: {connectResult}";
|
||||||
|
CleanupFailedAttempt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.AuthMode == AuthMode.Guest)
|
||||||
|
{
|
||||||
|
Nickname = string.IsNullOrWhiteSpace(server.LastNickname) ? Environment.UserName : server.LastNickname;
|
||||||
|
Console.WriteLine($"[ConnectDialog] calling AuthenticateGuest({Nickname})...");
|
||||||
|
var authResult = _client.AuthenticateGuest(Nickname);
|
||||||
|
Console.WriteLine($"[ConnectDialog] AuthenticateGuest() returned {authResult}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
string password;
|
||||||
|
if (server.ProtectedPasswordBase64 is not null)
|
||||||
|
{
|
||||||
|
password = PasswordProtector.Unprotect(server.ProtectedPasswordBase64);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
using var pwDlg = new PasswordPromptDialog($"Password for {server.SavedUsername}@{server.Host}:");
|
||||||
|
if (pwDlg.ShowDialog(this) != DialogResult.OK)
|
||||||
|
{
|
||||||
|
lblStatus.Text = "Cancelled.";
|
||||||
|
CleanupFailedAttempt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
password = pwDlg.Password;
|
||||||
|
}
|
||||||
|
Nickname = server.SavedUsername ?? "";
|
||||||
|
Console.WriteLine($"[ConnectDialog] calling AuthenticateUser({Nickname})...");
|
||||||
|
var authResult = _client.AuthenticateUser(server.SavedUsername ?? "", password);
|
||||||
|
Console.WriteLine($"[ConnectDialog] AuthenticateUser() returned {authResult}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEvent(VoiceCatEvent ev)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[ConnectDialog] event: {ev}");
|
||||||
|
switch (ev.Type)
|
||||||
|
{
|
||||||
|
case VcEventType.ConnectionState:
|
||||||
|
lblStatus.Text = ev.ConnectionState switch
|
||||||
|
{
|
||||||
|
VcConnectionState.Connecting => "Connecting...",
|
||||||
|
VcConnectionState.TlsHandshake => "TLS handshake...",
|
||||||
|
VcConnectionState.VerifyingIdentity => "Verifying server identity...",
|
||||||
|
VcConnectionState.Authenticating => "Authenticating...",
|
||||||
|
VcConnectionState.Connected => "Connected.",
|
||||||
|
_ => lblStatus.Text,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
case VcEventType.ServerIdentity:
|
||||||
|
HandleServerIdentity((VcTofuStatus)ev.U32a, ev.Text ?? "");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case VcEventType.AuthResult:
|
||||||
|
if (ev.Result == VcResult.Ok)
|
||||||
|
{
|
||||||
|
SelfUserId = ev.UserId;
|
||||||
|
ConnectedClient = _client;
|
||||||
|
_pumpTimer.Stop();
|
||||||
|
_client!.EventReceived -= OnEvent;
|
||||||
|
DialogResult = DialogResult.OK;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
lblStatus.Text = $"Authentication failed: {ev.Text}";
|
||||||
|
CleanupFailedAttempt();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case VcEventType.Disconnected:
|
||||||
|
if (ConnectedClient is null)
|
||||||
|
{
|
||||||
|
lblStatus.Text = string.IsNullOrEmpty(ev.Text) ? "Disconnected." : $"Disconnected: {ev.Text}";
|
||||||
|
CleanupFailedAttempt();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleServerIdentity(VcTofuStatus status, string certFingerprintHex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[ConnectDialog] HandleServerIdentity status={status} fp={certFingerprintHex} alreadyShown={_identityDialogShown}");
|
||||||
|
if (_identityDialogShown) return; // one decision per connect attempt
|
||||||
|
if (status == VcTofuStatus.Matched)
|
||||||
|
{
|
||||||
|
// Silent success path — no dialog. See ServerIdentityDialog's doc comment.
|
||||||
|
Console.WriteLine("[ConnectDialog] status=Matched -> auto-confirming, no dialog");
|
||||||
|
_client!.ConfirmServerIdentity(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_identityDialogShown = true;
|
||||||
|
Console.WriteLine("[ConnectDialog] showing ServerIdentityDialog...");
|
||||||
|
using var dlg = new ServerIdentityDialog(status, certFingerprintHex, _client!.GetServerIdentityDisplay());
|
||||||
|
var dlgResult = dlg.ShowDialog(this);
|
||||||
|
Console.WriteLine($"[ConnectDialog] ServerIdentityDialog closed with {dlgResult}");
|
||||||
|
bool accept = dlgResult == DialogResult.OK;
|
||||||
|
var confirmResult = _client.ConfirmServerIdentity(accept);
|
||||||
|
Console.WriteLine($"[ConnectDialog] ConfirmServerIdentity({accept}) returned {confirmResult}");
|
||||||
|
if (!accept) lblStatus.Text = "Server identity rejected.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CleanupFailedAttempt()
|
||||||
|
{
|
||||||
|
_pumpTimer.Stop();
|
||||||
|
if (_client is not null)
|
||||||
|
{
|
||||||
|
_client.EventReceived -= OnEvent;
|
||||||
|
_client.Dispose();
|
||||||
|
_client = null;
|
||||||
|
}
|
||||||
|
SetBusy(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetBusy(bool busy)
|
||||||
|
{
|
||||||
|
lstServers.Enabled = !busy;
|
||||||
|
btnConnect.Enabled = !busy && lstServers.SelectedItem is not null;
|
||||||
|
btnAddNew.Enabled = !busy;
|
||||||
|
btnEdit.Enabled = !busy && lstServers.SelectedItem is not null;
|
||||||
|
btnRemove.Enabled = !busy && lstServers.SelectedItem is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnFormClosing(FormClosingEventArgs e)
|
||||||
|
{
|
||||||
|
if (DialogResult != DialogResult.OK)
|
||||||
|
{
|
||||||
|
_pumpTimer.Stop();
|
||||||
|
if (_client is not null)
|
||||||
|
{
|
||||||
|
_client.EventReceived -= OnEvent;
|
||||||
|
_client.Dispose();
|
||||||
|
_client = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
base.OnFormClosing(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
clients/windows/VoiceCat.App/Forms/InputDialog.cs
Normal file
60
clients/windows/VoiceCat.App/Forms/InputDialog.cs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
namespace VoiceCat.App.Forms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A small reusable modal that prompts for a single line of text (e.g., kick/ban reason).
|
||||||
|
/// Returns DialogResult.OK with Text set, or DialogResult.Cancel.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InputDialog : Form
|
||||||
|
{
|
||||||
|
private readonly TextBox _txtInput;
|
||||||
|
|
||||||
|
public string TextValue => _txtInput.Text.Trim();
|
||||||
|
|
||||||
|
public InputDialog(string caption, string label, string defaultText = "")
|
||||||
|
{
|
||||||
|
var lbl = new Label
|
||||||
|
{
|
||||||
|
Text = label,
|
||||||
|
AutoSize = true,
|
||||||
|
Location = new Point(12, 12),
|
||||||
|
TabIndex = 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
_txtInput = new TextBox
|
||||||
|
{
|
||||||
|
Text = defaultText,
|
||||||
|
Location = new Point(12, 36),
|
||||||
|
Size = new Size(360, 23),
|
||||||
|
TabIndex = 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
var btnOk = new Button
|
||||||
|
{
|
||||||
|
Text = "&OK",
|
||||||
|
DialogResult = DialogResult.OK,
|
||||||
|
Location = new Point(216, 72),
|
||||||
|
Size = new Size(75, 27),
|
||||||
|
TabIndex = 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
var btnCancel = new Button
|
||||||
|
{
|
||||||
|
Text = "&Cancel",
|
||||||
|
DialogResult = DialogResult.Cancel,
|
||||||
|
Location = new Point(297, 72),
|
||||||
|
Size = new Size(75, 27),
|
||||||
|
TabIndex = 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
AcceptButton = btnOk;
|
||||||
|
CancelButton = btnCancel;
|
||||||
|
AutoScaleMode = AutoScaleMode.Font;
|
||||||
|
ClientSize = new Size(384, 112);
|
||||||
|
Controls.AddRange([lbl, _txtInput, btnOk, btnCancel]);
|
||||||
|
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||||
|
MaximizeBox = false;
|
||||||
|
MinimizeBox = false;
|
||||||
|
StartPosition = FormStartPosition.CenterParent;
|
||||||
|
Text = caption;
|
||||||
|
}
|
||||||
|
}
|
||||||
380
clients/windows/VoiceCat.App/Forms/MainForm.Designer.cs
generated
Normal file
380
clients/windows/VoiceCat.App/Forms/MainForm.Designer.cs
generated
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
namespace VoiceCat.App.Forms;
|
||||||
|
|
||||||
|
partial class MainForm
|
||||||
|
{
|
||||||
|
private System.ComponentModel.IContainer components = null!;
|
||||||
|
|
||||||
|
// Menu bar + toolbar
|
||||||
|
private MenuStrip menuStrip = null!;
|
||||||
|
private ToolStrip toolStrip = null!;
|
||||||
|
private ToolStripButton tsbJoinVoice = null!;
|
||||||
|
private ToolStripButton tsbScreenShare = null!;
|
||||||
|
|
||||||
|
// Status bar
|
||||||
|
private Label lblStatus = null!;
|
||||||
|
|
||||||
|
// Main left/right split
|
||||||
|
private SplitContainer splitMain = null!;
|
||||||
|
|
||||||
|
// Left panel: channel tree on top, user list on bottom
|
||||||
|
private SplitContainer splitLeft = null!;
|
||||||
|
private Label lblChannels = null!;
|
||||||
|
private TreeView tvChannels = null!;
|
||||||
|
private Label lblUsers = null!;
|
||||||
|
private ListBox lstUsers = null!;
|
||||||
|
|
||||||
|
// Right panel: unified log, compose row, output volume
|
||||||
|
private TableLayoutPanel tblRight = null!;
|
||||||
|
private Label lblLog = null!;
|
||||||
|
private RichTextBox rtbLog = null!;
|
||||||
|
private TableLayoutPanel tblCompose = null!;
|
||||||
|
private TextBox txtCompose = null!;
|
||||||
|
private Button btnSend = null!;
|
||||||
|
private Label lblOutputVolume = null!;
|
||||||
|
private TrackBar trkOutputVolume = null!;
|
||||||
|
|
||||||
|
// Voice control panel (docked Bottom)
|
||||||
|
private Panel pnlVoice = null!;
|
||||||
|
private FlowLayoutPanel flpVoiceTop = null!;
|
||||||
|
private FlowLayoutPanel flpVoiceBottom = null!;
|
||||||
|
private CheckBox chkMute = null!;
|
||||||
|
private CheckBox chkDeafen = null!;
|
||||||
|
private RadioButton radioVad = null!;
|
||||||
|
private RadioButton radioPtt = null!;
|
||||||
|
private RadioButton radioAlwaysOn = null!;
|
||||||
|
private Label lblPttKey = null!;
|
||||||
|
private Button btnChangePtt = null!;
|
||||||
|
private Label lblInputDevice = null!;
|
||||||
|
private ComboBox cboInputDevice = null!;
|
||||||
|
private Button btnRefreshDevices = null!;
|
||||||
|
private Label lblLevel = null!;
|
||||||
|
private ProgressBar pbLevel = null!;
|
||||||
|
private Label lblVadThreshold = null!;
|
||||||
|
private TrackBar trkVadThreshold = null!;
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing) components?.Dispose();
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeComponent()
|
||||||
|
{
|
||||||
|
components = new System.ComponentModel.Container();
|
||||||
|
|
||||||
|
lblStatus = new Label();
|
||||||
|
splitMain = new SplitContainer();
|
||||||
|
splitLeft = new SplitContainer();
|
||||||
|
lblChannels = new Label();
|
||||||
|
tvChannels = new TreeView();
|
||||||
|
lblUsers = new Label();
|
||||||
|
lstUsers = new ListBox();
|
||||||
|
tblRight = new TableLayoutPanel();
|
||||||
|
lblLog = new Label();
|
||||||
|
rtbLog = new RichTextBox();
|
||||||
|
tblCompose = new TableLayoutPanel();
|
||||||
|
txtCompose = new TextBox();
|
||||||
|
btnSend = new Button();
|
||||||
|
lblOutputVolume = new Label();
|
||||||
|
trkOutputVolume = new TrackBar();
|
||||||
|
pnlVoice = new Panel();
|
||||||
|
flpVoiceTop = new FlowLayoutPanel();
|
||||||
|
flpVoiceBottom = new FlowLayoutPanel();
|
||||||
|
chkMute = new CheckBox();
|
||||||
|
chkDeafen = new CheckBox();
|
||||||
|
radioVad = new RadioButton();
|
||||||
|
radioPtt = new RadioButton();
|
||||||
|
radioAlwaysOn = new RadioButton();
|
||||||
|
lblPttKey = new Label();
|
||||||
|
btnChangePtt = new Button();
|
||||||
|
lblInputDevice = new Label();
|
||||||
|
cboInputDevice = new ComboBox();
|
||||||
|
btnRefreshDevices = new Button();
|
||||||
|
lblLevel = new Label();
|
||||||
|
pbLevel = new ProgressBar();
|
||||||
|
lblVadThreshold = new Label();
|
||||||
|
trkVadThreshold = new TrackBar();
|
||||||
|
menuStrip = new MenuStrip();
|
||||||
|
toolStrip = new ToolStrip();
|
||||||
|
tsbJoinVoice = new ToolStripButton();
|
||||||
|
tsbScreenShare = new ToolStripButton();
|
||||||
|
|
||||||
|
// ── Status label ──────────────────────────────────────────────────────
|
||||||
|
lblStatus.AccessibleName = "Connection status";
|
||||||
|
lblStatus.Dock = DockStyle.Top;
|
||||||
|
lblStatus.AutoSize = true;
|
||||||
|
lblStatus.Padding = new Padding(6, 4, 6, 4);
|
||||||
|
lblStatus.TabIndex = 0;
|
||||||
|
lblStatus.Text = "Connecting...";
|
||||||
|
|
||||||
|
// ── Channel tree ──────────────────────────────────────────────────────
|
||||||
|
lblChannels.Text = "Channels:";
|
||||||
|
lblChannels.Dock = DockStyle.Top;
|
||||||
|
lblChannels.AutoSize = true;
|
||||||
|
lblChannels.Padding = new Padding(4, 4, 4, 2);
|
||||||
|
|
||||||
|
tvChannels.AccessibleName = "Channel list";
|
||||||
|
tvChannels.AccessibleDescription =
|
||||||
|
"Double-click or press Enter to join a channel. " +
|
||||||
|
"Channels marked [password] require a password.";
|
||||||
|
tvChannels.Dock = DockStyle.Fill;
|
||||||
|
tvChannels.HideSelection = false;
|
||||||
|
tvChannels.TabIndex = 0;
|
||||||
|
|
||||||
|
// ── User list ─────────────────────────────────────────────────────────
|
||||||
|
lblUsers.Text = "Users in channel:";
|
||||||
|
lblUsers.Dock = DockStyle.Top;
|
||||||
|
lblUsers.AutoSize = true;
|
||||||
|
lblUsers.Padding = new Padding(4, 4, 4, 2);
|
||||||
|
|
||||||
|
lstUsers.AccessibleName = "Users in current channel";
|
||||||
|
lstUsers.AccessibleDescription =
|
||||||
|
"People in the same channel. Double-click or press Enter for per-user volume settings. " +
|
||||||
|
"Talking users are marked (talking).";
|
||||||
|
lstUsers.Dock = DockStyle.Fill;
|
||||||
|
lstUsers.TabIndex = 1;
|
||||||
|
|
||||||
|
// ── Left split (channels top, users bottom) ───────────────────────────
|
||||||
|
splitLeft.Orientation = Orientation.Horizontal;
|
||||||
|
splitLeft.Dock = DockStyle.Fill;
|
||||||
|
splitLeft.Panel1MinSize = 100;
|
||||||
|
splitLeft.Panel2MinSize = 80;
|
||||||
|
splitLeft.TabIndex = 0;
|
||||||
|
splitLeft.Panel1.Controls.Add(tvChannels);
|
||||||
|
splitLeft.Panel1.Controls.Add(lblChannels);
|
||||||
|
splitLeft.Panel2.Controls.Add(lstUsers);
|
||||||
|
splitLeft.Panel2.Controls.Add(lblUsers);
|
||||||
|
|
||||||
|
// ── Unified log ───────────────────────────────────────────────────────
|
||||||
|
lblLog.Text = "Chat & Activity:";
|
||||||
|
lblLog.AutoSize = true;
|
||||||
|
lblLog.Padding = new Padding(2, 2, 2, 1);
|
||||||
|
|
||||||
|
rtbLog.AccessibleName = "Chat and activity log";
|
||||||
|
rtbLog.AccessibleDescription = "Combined history of chat messages (normal) and activity events (gray).";
|
||||||
|
rtbLog.Dock = DockStyle.Fill;
|
||||||
|
rtbLog.ReadOnly = true;
|
||||||
|
rtbLog.ScrollBars = RichTextBoxScrollBars.Vertical;
|
||||||
|
rtbLog.BackColor = SystemColors.Window;
|
||||||
|
rtbLog.TabIndex = 0;
|
||||||
|
|
||||||
|
// ── Compose row ───────────────────────────────────────────────────────
|
||||||
|
txtCompose.AccessibleName = "Message text";
|
||||||
|
txtCompose.AccessibleDescription = "Type your channel message. Press Enter or click Send to send.";
|
||||||
|
txtCompose.Dock = DockStyle.Fill;
|
||||||
|
txtCompose.TabIndex = 1;
|
||||||
|
|
||||||
|
btnSend.Text = "&Send";
|
||||||
|
btnSend.Dock = DockStyle.Fill;
|
||||||
|
btnSend.TabIndex = 2;
|
||||||
|
|
||||||
|
tblCompose.ColumnCount = 2;
|
||||||
|
tblCompose.RowCount = 1;
|
||||||
|
tblCompose.Dock = DockStyle.Fill;
|
||||||
|
tblCompose.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F));
|
||||||
|
tblCompose.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 68F));
|
||||||
|
tblCompose.RowStyles.Add(new RowStyle(SizeType.Percent, 100F));
|
||||||
|
tblCompose.Padding = new Padding(0, 3, 0, 0);
|
||||||
|
tblCompose.Controls.Add(txtCompose, 0, 0);
|
||||||
|
tblCompose.Controls.Add(btnSend, 1, 0);
|
||||||
|
|
||||||
|
// ── Output volume row ─────────────────────────────────────────────────
|
||||||
|
lblOutputVolume.Text = "Output volume:";
|
||||||
|
lblOutputVolume.AutoSize = true;
|
||||||
|
lblOutputVolume.Padding = new Padding(2, 6, 4, 0);
|
||||||
|
|
||||||
|
trkOutputVolume.AccessibleName = "Output volume";
|
||||||
|
trkOutputVolume.AccessibleDescription = "Global playback volume for all incoming audio.";
|
||||||
|
trkOutputVolume.Minimum = 0;
|
||||||
|
trkOutputVolume.Maximum = 100;
|
||||||
|
trkOutputVolume.Value = 80;
|
||||||
|
trkOutputVolume.TickFrequency = 10;
|
||||||
|
trkOutputVolume.SmallChange = 1;
|
||||||
|
trkOutputVolume.LargeChange = 10;
|
||||||
|
trkOutputVolume.Dock = DockStyle.Fill;
|
||||||
|
trkOutputVolume.TabIndex = 3;
|
||||||
|
|
||||||
|
// ── Right table layout ────────────────────────────────────────────────
|
||||||
|
tblRight.ColumnCount = 1;
|
||||||
|
tblRight.RowCount = 5;
|
||||||
|
tblRight.Dock = DockStyle.Fill;
|
||||||
|
tblRight.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F));
|
||||||
|
tblRight.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // row 0: lblLog
|
||||||
|
tblRight.RowStyles.Add(new RowStyle(SizeType.Percent, 100F)); // row 1: rtbLog
|
||||||
|
tblRight.RowStyles.Add(new RowStyle(SizeType.Absolute, 34F)); // row 2: compose
|
||||||
|
tblRight.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // row 3: vol label
|
||||||
|
tblRight.RowStyles.Add(new RowStyle(SizeType.Absolute, 45F)); // row 4: vol slider
|
||||||
|
tblRight.Controls.Add(lblLog, 0, 0);
|
||||||
|
tblRight.Controls.Add(rtbLog, 0, 1);
|
||||||
|
tblRight.Controls.Add(tblCompose, 0, 2);
|
||||||
|
tblRight.Controls.Add(lblOutputVolume, 0, 3);
|
||||||
|
tblRight.Controls.Add(trkOutputVolume, 0, 4);
|
||||||
|
|
||||||
|
// ── Main split ────────────────────────────────────────────────────────
|
||||||
|
splitMain.Dock = DockStyle.Fill;
|
||||||
|
splitMain.Panel1MinSize = 150;
|
||||||
|
splitMain.TabIndex = 1;
|
||||||
|
splitMain.Panel1.Controls.Add(splitLeft);
|
||||||
|
splitMain.Panel2.Controls.Add(tblRight);
|
||||||
|
|
||||||
|
// ── Voice control panel ───────────────────────────────────────────────
|
||||||
|
chkMute.Text = "&Mute mic";
|
||||||
|
chkMute.AutoSize = true;
|
||||||
|
chkMute.Enabled = false;
|
||||||
|
chkMute.Margin = new Padding(0, 4, 6, 0);
|
||||||
|
chkMute.TabIndex = 1;
|
||||||
|
|
||||||
|
chkDeafen.Text = "&Deafen";
|
||||||
|
chkDeafen.AutoSize = true;
|
||||||
|
chkDeafen.Enabled = false;
|
||||||
|
chkDeafen.Margin = new Padding(0, 4, 12, 0);
|
||||||
|
chkDeafen.TabIndex = 2;
|
||||||
|
|
||||||
|
var lblMode = new Label { Text = "Mode:", AutoSize = true, Margin = new Padding(0, 5, 4, 0) };
|
||||||
|
|
||||||
|
radioVad.Text = "&Voice activation";
|
||||||
|
radioVad.AutoSize = true;
|
||||||
|
radioVad.Checked = true;
|
||||||
|
radioVad.Enabled = false;
|
||||||
|
radioVad.Margin = new Padding(0, 4, 6, 0);
|
||||||
|
radioVad.TabIndex = 3;
|
||||||
|
|
||||||
|
radioPtt.Text = "&Push to talk";
|
||||||
|
radioPtt.AutoSize = true;
|
||||||
|
radioPtt.Enabled = false;
|
||||||
|
radioPtt.Margin = new Padding(0, 4, 4, 0);
|
||||||
|
radioPtt.TabIndex = 4;
|
||||||
|
|
||||||
|
radioAlwaysOn.Text = "A&lways on";
|
||||||
|
radioAlwaysOn.AutoSize = true;
|
||||||
|
radioAlwaysOn.Enabled = false;
|
||||||
|
radioAlwaysOn.Margin = new Padding(0, 4, 12, 0);
|
||||||
|
radioAlwaysOn.TabIndex = 5;
|
||||||
|
|
||||||
|
lblPttKey.Text = "(F8)";
|
||||||
|
lblPttKey.AutoSize = true;
|
||||||
|
lblPttKey.Margin = new Padding(2, 5, 4, 0);
|
||||||
|
lblPttKey.Visible = false;
|
||||||
|
|
||||||
|
btnChangePtt.Text = "Change key...";
|
||||||
|
btnChangePtt.AutoSize = true;
|
||||||
|
btnChangePtt.Margin = new Padding(0, 2, 0, 0);
|
||||||
|
btnChangePtt.Visible = false;
|
||||||
|
btnChangePtt.TabIndex = 6;
|
||||||
|
|
||||||
|
flpVoiceTop.Dock = DockStyle.Top;
|
||||||
|
flpVoiceTop.Height = 34;
|
||||||
|
flpVoiceTop.AutoSize = false;
|
||||||
|
flpVoiceTop.Padding = new Padding(4, 2, 4, 0);
|
||||||
|
flpVoiceTop.Controls.Add(chkMute);
|
||||||
|
flpVoiceTop.Controls.Add(chkDeafen);
|
||||||
|
flpVoiceTop.Controls.Add(lblMode);
|
||||||
|
flpVoiceTop.Controls.Add(radioVad);
|
||||||
|
flpVoiceTop.Controls.Add(radioPtt);
|
||||||
|
flpVoiceTop.Controls.Add(radioAlwaysOn);
|
||||||
|
flpVoiceTop.Controls.Add(lblPttKey);
|
||||||
|
flpVoiceTop.Controls.Add(btnChangePtt);
|
||||||
|
|
||||||
|
// Bottom row: device picker + level meter
|
||||||
|
lblInputDevice.Text = "Input:";
|
||||||
|
lblInputDevice.AutoSize = true;
|
||||||
|
lblInputDevice.Margin = new Padding(0, 5, 4, 0);
|
||||||
|
|
||||||
|
cboInputDevice.AccessibleName = "Input device";
|
||||||
|
cboInputDevice.AccessibleDescription = "Select which microphone or audio device to use.";
|
||||||
|
cboInputDevice.DropDownStyle = ComboBoxStyle.DropDownList;
|
||||||
|
cboInputDevice.Width = 200;
|
||||||
|
cboInputDevice.Margin = new Padding(0, 2, 4, 0);
|
||||||
|
cboInputDevice.TabIndex = 7;
|
||||||
|
|
||||||
|
btnRefreshDevices.Text = "Re&fresh";
|
||||||
|
btnRefreshDevices.AutoSize = true;
|
||||||
|
btnRefreshDevices.Margin = new Padding(0, 2, 12, 0);
|
||||||
|
btnRefreshDevices.TabIndex = 8;
|
||||||
|
|
||||||
|
lblLevel.Text = "Level:";
|
||||||
|
lblLevel.AutoSize = true;
|
||||||
|
lblLevel.Margin = new Padding(0, 5, 4, 0);
|
||||||
|
|
||||||
|
pbLevel.AccessibleName = "Microphone level";
|
||||||
|
pbLevel.AccessibleDescription = "Current input level from the microphone.";
|
||||||
|
pbLevel.Width = 120;
|
||||||
|
pbLevel.Height = 16;
|
||||||
|
pbLevel.Maximum = 100;
|
||||||
|
pbLevel.Margin = new Padding(0, 6, 0, 0);
|
||||||
|
pbLevel.Style = ProgressBarStyle.Continuous;
|
||||||
|
pbLevel.TabStop = false;
|
||||||
|
|
||||||
|
lblVadThreshold.Text = "Sensitivity:";
|
||||||
|
lblVadThreshold.AutoSize = true;
|
||||||
|
lblVadThreshold.Margin = new Padding(12, 5, 4, 0);
|
||||||
|
lblVadThreshold.Visible = true;
|
||||||
|
|
||||||
|
trkVadThreshold.AccessibleName = "VAD sensitivity";
|
||||||
|
trkVadThreshold.AccessibleDescription =
|
||||||
|
"Voice detection sensitivity. Higher = more sensitive (triggers on quieter sounds). " +
|
||||||
|
"Range 1–100; default 25.";
|
||||||
|
trkVadThreshold.Minimum = 1;
|
||||||
|
trkVadThreshold.Maximum = 100;
|
||||||
|
trkVadThreshold.Value = 76;
|
||||||
|
trkVadThreshold.TickFrequency = 10;
|
||||||
|
trkVadThreshold.SmallChange = 1;
|
||||||
|
trkVadThreshold.LargeChange = 10;
|
||||||
|
trkVadThreshold.Width = 120;
|
||||||
|
trkVadThreshold.Margin = new Padding(0, 2, 0, 0);
|
||||||
|
trkVadThreshold.TabIndex = 9;
|
||||||
|
trkVadThreshold.Visible = true;
|
||||||
|
|
||||||
|
flpVoiceBottom.Dock = DockStyle.Fill;
|
||||||
|
flpVoiceBottom.Padding = new Padding(4, 0, 4, 2);
|
||||||
|
flpVoiceBottom.Controls.Add(lblInputDevice);
|
||||||
|
flpVoiceBottom.Controls.Add(cboInputDevice);
|
||||||
|
flpVoiceBottom.Controls.Add(btnRefreshDevices);
|
||||||
|
flpVoiceBottom.Controls.Add(lblLevel);
|
||||||
|
flpVoiceBottom.Controls.Add(pbLevel);
|
||||||
|
flpVoiceBottom.Controls.Add(lblVadThreshold);
|
||||||
|
flpVoiceBottom.Controls.Add(trkVadThreshold);
|
||||||
|
|
||||||
|
pnlVoice.Dock = DockStyle.Bottom;
|
||||||
|
pnlVoice.Height = 68;
|
||||||
|
pnlVoice.BorderStyle = BorderStyle.FixedSingle;
|
||||||
|
pnlVoice.Padding = new Padding(0);
|
||||||
|
pnlVoice.Controls.Add(flpVoiceBottom); // Fill — added first
|
||||||
|
pnlVoice.Controls.Add(flpVoiceTop); // Top — added last
|
||||||
|
|
||||||
|
// ── Toolbar ───────────────────────────────────────────────────────────
|
||||||
|
tsbJoinVoice.Text = "Join Voice";
|
||||||
|
tsbJoinVoice.DisplayStyle = ToolStripItemDisplayStyle.Text;
|
||||||
|
tsbJoinVoice.CheckOnClick = false;
|
||||||
|
|
||||||
|
tsbScreenShare.Text = "Share Screen Audio";
|
||||||
|
tsbScreenShare.DisplayStyle = ToolStripItemDisplayStyle.Text;
|
||||||
|
tsbScreenShare.CheckOnClick = false;
|
||||||
|
|
||||||
|
toolStrip.Dock = DockStyle.Top;
|
||||||
|
toolStrip.Items.Add(tsbJoinVoice);
|
||||||
|
toolStrip.Items.Add(new ToolStripSeparator());
|
||||||
|
toolStrip.Items.Add(tsbScreenShare);
|
||||||
|
toolStrip.TabIndex = 1;
|
||||||
|
toolStrip.AccessibleName = "Toolbar";
|
||||||
|
|
||||||
|
// ── Menu strip ────────────────────────────────────────────────────────
|
||||||
|
menuStrip.AccessibleName = "Main menu";
|
||||||
|
menuStrip.Dock = DockStyle.Top;
|
||||||
|
menuStrip.TabIndex = 0;
|
||||||
|
MainMenuStrip = menuStrip;
|
||||||
|
|
||||||
|
// ── Form ──────────────────────────────────────────────────────────────
|
||||||
|
AutoScaleMode = AutoScaleMode.Font;
|
||||||
|
ClientSize = new Size(960, 680);
|
||||||
|
MinimumSize = new Size(700, 540);
|
||||||
|
KeyPreview = true;
|
||||||
|
Controls.Add(splitMain); // DockStyle.Fill
|
||||||
|
Controls.Add(pnlVoice); // DockStyle.Bottom
|
||||||
|
Controls.Add(lblStatus); // DockStyle.Top
|
||||||
|
Controls.Add(toolStrip); // DockStyle.Top (above status)
|
||||||
|
Controls.Add(menuStrip); // DockStyle.Top (topmost)
|
||||||
|
StartPosition = FormStartPosition.CenterScreen;
|
||||||
|
Text = "VoiceCat";
|
||||||
|
}
|
||||||
|
}
|
||||||
1036
clients/windows/VoiceCat.App/Forms/MainForm.cs
Normal file
1036
clients/windows/VoiceCat.App/Forms/MainForm.cs
Normal file
File diff suppressed because it is too large
Load Diff
79
clients/windows/VoiceCat.App/Forms/MoveUserDialog.cs
Normal file
79
clients/windows/VoiceCat.App/Forms/MoveUserDialog.cs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
using VoiceCat.Interop;
|
||||||
|
|
||||||
|
namespace VoiceCat.App.Forms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prompt the moderator to pick a destination channel for a user move.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MoveUserDialog : Form
|
||||||
|
{
|
||||||
|
private readonly ComboBox _cboChannel;
|
||||||
|
|
||||||
|
public uint SelectedChannelId { get; private set; }
|
||||||
|
|
||||||
|
public MoveUserDialog(IEnumerable<ChannelInfo> channels, uint currentChannelId)
|
||||||
|
{
|
||||||
|
Text = "Move user to channel";
|
||||||
|
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||||
|
MaximizeBox = false;
|
||||||
|
MinimizeBox = false;
|
||||||
|
StartPosition = FormStartPosition.CenterParent;
|
||||||
|
AutoScaleMode = AutoScaleMode.Font;
|
||||||
|
ClientSize = new Size(384, 120);
|
||||||
|
|
||||||
|
var lbl = new Label
|
||||||
|
{
|
||||||
|
Text = "&Destination channel:",
|
||||||
|
Location = new Point(12, 12),
|
||||||
|
AutoSize = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
_cboChannel = new ComboBox
|
||||||
|
{
|
||||||
|
Location = new Point(12, 36),
|
||||||
|
Size = new Size(360, 23),
|
||||||
|
DropDownStyle = ComboBoxStyle.DropDownList,
|
||||||
|
TabIndex = 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var ch in channels.OrderBy(c => c.Name))
|
||||||
|
{
|
||||||
|
_cboChannel.Items.Add(new ChannelItem(ch.Name, ch.Id));
|
||||||
|
if (ch.Id == currentChannelId) _cboChannel.SelectedIndex = _cboChannel.Items.Count - 1;
|
||||||
|
}
|
||||||
|
if (_cboChannel.SelectedIndex < 0 && _cboChannel.Items.Count > 0)
|
||||||
|
_cboChannel.SelectedIndex = 0;
|
||||||
|
|
||||||
|
var btnOk = new Button
|
||||||
|
{
|
||||||
|
Text = "&OK",
|
||||||
|
DialogResult = DialogResult.OK,
|
||||||
|
Location = new Point(216, 72),
|
||||||
|
Size = new Size(75, 27),
|
||||||
|
TabIndex = 2,
|
||||||
|
};
|
||||||
|
var btnCancel = new Button
|
||||||
|
{
|
||||||
|
Text = "&Cancel",
|
||||||
|
DialogResult = DialogResult.Cancel,
|
||||||
|
Location = new Point(297, 72),
|
||||||
|
Size = new Size(75, 27),
|
||||||
|
TabIndex = 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
AcceptButton = btnOk;
|
||||||
|
CancelButton = btnCancel;
|
||||||
|
Controls.AddRange([lbl, _cboChannel, btnOk, btnCancel]);
|
||||||
|
|
||||||
|
btnOk.Click += (_, _) =>
|
||||||
|
{
|
||||||
|
SelectedChannelId = (_cboChannel.SelectedItem as ChannelItem)?.Id ?? 0;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ChannelItem(string name, uint id)
|
||||||
|
{
|
||||||
|
public uint Id { get; } = id;
|
||||||
|
public override string ToString() => name;
|
||||||
|
}
|
||||||
|
}
|
||||||
61
clients/windows/VoiceCat.App/Forms/PasswordPromptDialog.Designer.cs
generated
Normal file
61
clients/windows/VoiceCat.App/Forms/PasswordPromptDialog.Designer.cs
generated
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
namespace VoiceCat.App.Forms;
|
||||||
|
|
||||||
|
partial class PasswordPromptDialog
|
||||||
|
{
|
||||||
|
private System.ComponentModel.IContainer components = null!;
|
||||||
|
private Label lblPrompt = null!;
|
||||||
|
private TextBox txtPassword = null!;
|
||||||
|
private Button btnOk = null!;
|
||||||
|
private Button btnCancel = null!;
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing) components?.Dispose();
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeComponent()
|
||||||
|
{
|
||||||
|
components = new System.ComponentModel.Container();
|
||||||
|
lblPrompt = new Label();
|
||||||
|
txtPassword = new TextBox();
|
||||||
|
btnOk = new Button();
|
||||||
|
btnCancel = new Button();
|
||||||
|
|
||||||
|
lblPrompt.AutoSize = true;
|
||||||
|
lblPrompt.Location = new Point(12, 12);
|
||||||
|
lblPrompt.MaximumSize = new Size(360, 0);
|
||||||
|
lblPrompt.TabIndex = 0;
|
||||||
|
|
||||||
|
txtPassword.Location = new Point(12, 40);
|
||||||
|
txtPassword.Size = new Size(360, 23);
|
||||||
|
txtPassword.UseSystemPasswordChar = true;
|
||||||
|
txtPassword.TabIndex = 1;
|
||||||
|
|
||||||
|
btnOk.Text = "&OK";
|
||||||
|
btnOk.DialogResult = DialogResult.OK;
|
||||||
|
btnOk.Location = new Point(216, 75);
|
||||||
|
btnOk.Size = new Size(75, 27);
|
||||||
|
btnOk.TabIndex = 2;
|
||||||
|
|
||||||
|
btnCancel.Text = "&Cancel";
|
||||||
|
btnCancel.DialogResult = DialogResult.Cancel;
|
||||||
|
btnCancel.Location = new Point(297, 75);
|
||||||
|
btnCancel.Size = new Size(75, 27);
|
||||||
|
btnCancel.TabIndex = 3;
|
||||||
|
|
||||||
|
AutoScaleMode = AutoScaleMode.Font;
|
||||||
|
ClientSize = new Size(384, 114);
|
||||||
|
Controls.Add(lblPrompt);
|
||||||
|
Controls.Add(txtPassword);
|
||||||
|
Controls.Add(btnOk);
|
||||||
|
Controls.Add(btnCancel);
|
||||||
|
AcceptButton = btnOk;
|
||||||
|
CancelButton = btnCancel;
|
||||||
|
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||||
|
MaximizeBox = false;
|
||||||
|
MinimizeBox = false;
|
||||||
|
StartPosition = FormStartPosition.CenterParent;
|
||||||
|
Text = "Password required";
|
||||||
|
}
|
||||||
|
}
|
||||||
15
clients/windows/VoiceCat.App/Forms/PasswordPromptDialog.cs
Normal file
15
clients/windows/VoiceCat.App/Forms/PasswordPromptDialog.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace VoiceCat.App.Forms;
|
||||||
|
|
||||||
|
/// <summary>Small modal for "type a password right now" — used when a saved server's
|
||||||
|
/// password wasn't remembered, and (Phase E) for password-protected channel joins.</summary>
|
||||||
|
public partial class PasswordPromptDialog : Form
|
||||||
|
{
|
||||||
|
public string Password => txtPassword.Text;
|
||||||
|
|
||||||
|
public PasswordPromptDialog(string promptText)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
lblPrompt.Text = promptText;
|
||||||
|
txtPassword.AccessibleName = "Password";
|
||||||
|
}
|
||||||
|
}
|
||||||
215
clients/windows/VoiceCat.App/Forms/PerUserTuningDialog.cs
Normal file
215
clients/windows/VoiceCat.App/Forms/PerUserTuningDialog.cs
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
using VoiceCat.Interop;
|
||||||
|
|
||||||
|
namespace VoiceCat.App.Forms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-remote-user, per-stream gain/mute/noise-reduction settings (docs/voice.md §1, §10).
|
||||||
|
/// One row is rendered for each stream the remote user is currently publishing. Each row
|
||||||
|
/// controls only that stream — so a listener can turn down one user's desktop audio while
|
||||||
|
/// keeping their mic, and independently noise-reduce a third user. All of these are
|
||||||
|
/// listener-local: no protocol traffic, no effect on other listeners.
|
||||||
|
///
|
||||||
|
/// Changes apply in real time as the user adjusts controls (no OK/Cancel round-trip); the
|
||||||
|
/// Close button just dismisses. State lives in the core and is not persisted across sessions.
|
||||||
|
/// The dialog is a modal snapshot of the streams active when it was opened — close and
|
||||||
|
/// reopen to see streams started/stopped in the meantime.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PerUserTuningDialog : Form
|
||||||
|
{
|
||||||
|
private readonly VoiceCatClient _client;
|
||||||
|
private readonly uint _userId;
|
||||||
|
|
||||||
|
private readonly List<StreamRow> _rows = new();
|
||||||
|
|
||||||
|
public PerUserTuningDialog(VoiceCatClient client, uint userId, string nickname)
|
||||||
|
{
|
||||||
|
_client = client;
|
||||||
|
_userId = userId;
|
||||||
|
|
||||||
|
AutoScaleMode = AutoScaleMode.Font;
|
||||||
|
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||||
|
MaximizeBox = false;
|
||||||
|
MinimizeBox = false;
|
||||||
|
StartPosition = FormStartPosition.CenterParent;
|
||||||
|
Text = $"User settings — {nickname}";
|
||||||
|
|
||||||
|
BuildControls(nickname);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildControls(string nickname)
|
||||||
|
{
|
||||||
|
var lblTitle = new Label
|
||||||
|
{
|
||||||
|
Text = $"Settings for {nickname}",
|
||||||
|
AutoSize = true,
|
||||||
|
Font = new Font(Font, FontStyle.Bold),
|
||||||
|
Location = new Point(12, 12),
|
||||||
|
TabIndex = 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
var streams = _client.ListUserStreams(_userId);
|
||||||
|
|
||||||
|
const int rowH = 70; // label + trackbar + mute/nr
|
||||||
|
const int rowGap = 8;
|
||||||
|
int y = 44;
|
||||||
|
|
||||||
|
if (streams.Count == 0)
|
||||||
|
{
|
||||||
|
var lblEmpty = new Label
|
||||||
|
{
|
||||||
|
Text = "No active streams.",
|
||||||
|
AutoSize = true,
|
||||||
|
Location = new Point(12, y),
|
||||||
|
};
|
||||||
|
Controls.Add(lblTitle);
|
||||||
|
Controls.Add(lblEmpty);
|
||||||
|
ClientSize = new Size(420, 44 + lblEmpty.PreferredHeight + 16);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Controls.Add(lblTitle);
|
||||||
|
|
||||||
|
int tabIndex = 1;
|
||||||
|
foreach (var s in streams)
|
||||||
|
{
|
||||||
|
var row = CreateRow(s, y, ref tabIndex);
|
||||||
|
_rows.Add(row);
|
||||||
|
Controls.AddRange(row.Controls);
|
||||||
|
y += rowH + rowGap;
|
||||||
|
}
|
||||||
|
|
||||||
|
var btnClose = new Button
|
||||||
|
{
|
||||||
|
Text = "&Close",
|
||||||
|
DialogResult = DialogResult.OK,
|
||||||
|
Location = new Point(336, y + 4),
|
||||||
|
Size = new Size(75, 27),
|
||||||
|
TabIndex = tabIndex,
|
||||||
|
};
|
||||||
|
AcceptButton = btnClose;
|
||||||
|
|
||||||
|
Controls.Add(btnClose);
|
||||||
|
|
||||||
|
ClientSize = new Size(420, y + 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
private StreamRow CreateRow(StreamSummary s, int y, ref int tabIndex)
|
||||||
|
{
|
||||||
|
// Read back the listener's current state for this stream (defaults if never set).
|
||||||
|
float gain0 = 1.0f; bool mute0 = false; bool nr0 = false;
|
||||||
|
var (r, state) = _client.GetRemoteStream(_userId, s.StreamId);
|
||||||
|
if (r == VcResult.Ok && state is not null)
|
||||||
|
{
|
||||||
|
gain0 = state.Gain;
|
||||||
|
mute0 = state.Muted;
|
||||||
|
nr0 = state.NoiseReduction;
|
||||||
|
}
|
||||||
|
|
||||||
|
var lblName = new Label
|
||||||
|
{
|
||||||
|
Text = StreamLabel(s),
|
||||||
|
AutoSize = true,
|
||||||
|
Font = new Font(Font, FontStyle.Bold),
|
||||||
|
Location = new Point(12, y),
|
||||||
|
TabIndex = tabIndex++,
|
||||||
|
};
|
||||||
|
|
||||||
|
var trkGain = new TrackBar
|
||||||
|
{
|
||||||
|
AccessibleName = $"Gain for {s.Label}",
|
||||||
|
AccessibleDescription = "Volume level for this stream. 100 is normal (1.0×), 200 is double.",
|
||||||
|
Location = new Point(12, y + 20),
|
||||||
|
Size = new Size(260, 45),
|
||||||
|
Minimum = 0,
|
||||||
|
Maximum = 200,
|
||||||
|
Value = ClampToTrack(gain0),
|
||||||
|
TickFrequency = 25,
|
||||||
|
SmallChange = 5,
|
||||||
|
LargeChange = 25,
|
||||||
|
TabIndex = tabIndex++,
|
||||||
|
};
|
||||||
|
|
||||||
|
var lblGainValue = new Label
|
||||||
|
{
|
||||||
|
AutoSize = true,
|
||||||
|
Location = new Point(280, y + 28),
|
||||||
|
TabIndex = tabIndex++,
|
||||||
|
};
|
||||||
|
void UpdateGainLabel() =>
|
||||||
|
lblGainValue.Text = $"{trkGain.Value}% ({trkGain.Value / 100f:F1}×)";
|
||||||
|
UpdateGainLabel();
|
||||||
|
|
||||||
|
var chkMute = new CheckBox
|
||||||
|
{
|
||||||
|
Text = "&Mute",
|
||||||
|
AutoSize = true,
|
||||||
|
Location = new Point(12, y + 48),
|
||||||
|
Checked = mute0,
|
||||||
|
TabIndex = tabIndex++,
|
||||||
|
};
|
||||||
|
|
||||||
|
var chkNr = new CheckBox
|
||||||
|
{
|
||||||
|
Text = "&Noise reduction (planned — currently passthrough)",
|
||||||
|
AutoSize = true,
|
||||||
|
Location = new Point(96, y + 48),
|
||||||
|
Checked = nr0,
|
||||||
|
TabIndex = tabIndex++,
|
||||||
|
};
|
||||||
|
|
||||||
|
var row = new StreamRow(s.StreamId, trkGain, chkMute, chkNr,
|
||||||
|
new Control[] { lblName, trkGain, lblGainValue, chkMute, chkNr });
|
||||||
|
|
||||||
|
trkGain.Scroll += (_, _) =>
|
||||||
|
{
|
||||||
|
UpdateGainLabel();
|
||||||
|
row.Apply(_client, _userId);
|
||||||
|
};
|
||||||
|
chkMute.CheckedChanged += (_, _) => row.Apply(_client, _userId);
|
||||||
|
chkNr.CheckedChanged += (_, _) => row.Apply(_client, _userId);
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StreamLabel(StreamSummary s)
|
||||||
|
{
|
||||||
|
var kind = s.Kind switch
|
||||||
|
{
|
||||||
|
VcStreamKind.Mic => "Mic",
|
||||||
|
VcStreamKind.ScreenAudio => "Screen audio",
|
||||||
|
VcStreamKind.AuxDevice => "Aux device",
|
||||||
|
_ => "Stream",
|
||||||
|
};
|
||||||
|
return string.IsNullOrWhiteSpace(s.Label) ? kind : $"{kind} — {s.Label}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ClampToTrack(float gain)
|
||||||
|
{
|
||||||
|
int v = (int)Math.Round(gain * 100f);
|
||||||
|
return Math.Max(0, Math.Min(200, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StreamRow
|
||||||
|
{
|
||||||
|
private readonly uint _streamId;
|
||||||
|
private readonly TrackBar _trkGain;
|
||||||
|
private readonly CheckBox _chkMute;
|
||||||
|
private readonly CheckBox _chkNr;
|
||||||
|
public Control[] Controls { get; }
|
||||||
|
|
||||||
|
public StreamRow(uint streamId, TrackBar trkGain, CheckBox chkMute, CheckBox chkNr, Control[] controls)
|
||||||
|
{
|
||||||
|
_streamId = streamId;
|
||||||
|
_trkGain = trkGain;
|
||||||
|
_chkMute = chkMute;
|
||||||
|
_chkNr = chkNr;
|
||||||
|
Controls = controls;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Apply(VoiceCatClient client, uint userId)
|
||||||
|
{
|
||||||
|
float gain = _trkGain.Value / 100f;
|
||||||
|
client.SetRemoteStream(userId, _streamId, gain, _chkMute.Checked, _chkNr.Checked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
110
clients/windows/VoiceCat.App/Forms/PermissionsDialog.cs
Normal file
110
clients/windows/VoiceCat.App/Forms/PermissionsDialog.cs
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
using VoiceCat.Interop;
|
||||||
|
|
||||||
|
namespace VoiceCat.App.Forms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Edit a user's server permissions.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PermissionsDialog : Form
|
||||||
|
{
|
||||||
|
private readonly CheckBox _chkCreateTemp;
|
||||||
|
private readonly CheckBox _chkKick;
|
||||||
|
private readonly CheckBox _chkBan;
|
||||||
|
private readonly CheckBox _chkMove;
|
||||||
|
private readonly CheckBox _chkAdminAccounts;
|
||||||
|
private readonly CheckBox _chkIsAdmin;
|
||||||
|
|
||||||
|
public PermissionsInfo? Result { get; private set; }
|
||||||
|
|
||||||
|
public PermissionsDialog(string nickname, PermissionsInfo current)
|
||||||
|
{
|
||||||
|
Text = $"Permissions — {nickname}";
|
||||||
|
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||||
|
MaximizeBox = false;
|
||||||
|
MinimizeBox = false;
|
||||||
|
StartPosition = FormStartPosition.CenterParent;
|
||||||
|
AutoScaleMode = AutoScaleMode.Font;
|
||||||
|
ClientSize = new Size(320, 260);
|
||||||
|
|
||||||
|
var lbl = new Label
|
||||||
|
{
|
||||||
|
Text = $"Set permissions for {nickname}:",
|
||||||
|
Location = new Point(12, 12),
|
||||||
|
AutoSize = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
_chkCreateTemp = new CheckBox
|
||||||
|
{
|
||||||
|
Text = "Can create &temporary channels",
|
||||||
|
Location = new Point(12, 38),
|
||||||
|
AutoSize = true,
|
||||||
|
Checked = current.CanCreateTempChannel,
|
||||||
|
};
|
||||||
|
_chkKick = new CheckBox
|
||||||
|
{
|
||||||
|
Text = "Can &kick users",
|
||||||
|
Location = new Point(12, 64),
|
||||||
|
AutoSize = true,
|
||||||
|
Checked = current.CanKick,
|
||||||
|
};
|
||||||
|
_chkBan = new CheckBox
|
||||||
|
{
|
||||||
|
Text = "Can &ban users",
|
||||||
|
Location = new Point(12, 90),
|
||||||
|
AutoSize = true,
|
||||||
|
Checked = current.CanBan,
|
||||||
|
};
|
||||||
|
_chkMove = new CheckBox
|
||||||
|
{
|
||||||
|
Text = "Can &move users",
|
||||||
|
Location = new Point(12, 116),
|
||||||
|
AutoSize = true,
|
||||||
|
Checked = current.CanMoveUsers,
|
||||||
|
};
|
||||||
|
_chkAdminAccounts = new CheckBox
|
||||||
|
{
|
||||||
|
Text = "Can manage &accounts",
|
||||||
|
Location = new Point(12, 142),
|
||||||
|
AutoSize = true,
|
||||||
|
Checked = current.CanAdminAccounts,
|
||||||
|
};
|
||||||
|
_chkIsAdmin = new CheckBox
|
||||||
|
{
|
||||||
|
Text = "Is &admin",
|
||||||
|
Location = new Point(12, 168),
|
||||||
|
AutoSize = true,
|
||||||
|
Checked = current.IsAdmin,
|
||||||
|
};
|
||||||
|
|
||||||
|
var btnOk = new Button
|
||||||
|
{
|
||||||
|
Text = "&OK",
|
||||||
|
DialogResult = DialogResult.OK,
|
||||||
|
Location = new Point(152, 210),
|
||||||
|
Size = new Size(75, 27),
|
||||||
|
};
|
||||||
|
var btnCancel = new Button
|
||||||
|
{
|
||||||
|
Text = "&Cancel",
|
||||||
|
DialogResult = DialogResult.Cancel,
|
||||||
|
Location = new Point(233, 210),
|
||||||
|
Size = new Size(75, 27),
|
||||||
|
};
|
||||||
|
|
||||||
|
AcceptButton = btnOk;
|
||||||
|
CancelButton = btnCancel;
|
||||||
|
Controls.AddRange([lbl, _chkCreateTemp, _chkKick, _chkBan, _chkMove,
|
||||||
|
_chkAdminAccounts, _chkIsAdmin, btnOk, btnCancel]);
|
||||||
|
|
||||||
|
btnOk.Click += (_, _) =>
|
||||||
|
{
|
||||||
|
Result = new PermissionsInfo(
|
||||||
|
CanCreateTempChannel: _chkCreateTemp.Checked,
|
||||||
|
CanKick: _chkKick.Checked,
|
||||||
|
CanBan: _chkBan.Checked,
|
||||||
|
CanMoveUsers: _chkMove.Checked,
|
||||||
|
CanAdminAccounts: _chkAdminAccounts.Checked,
|
||||||
|
IsAdmin: _chkIsAdmin.Checked);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
115
clients/windows/VoiceCat.App/Forms/PrivateMessageForm.cs
Normal file
115
clients/windows/VoiceCat.App/Forms/PrivateMessageForm.cs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
using VoiceCat.Interop;
|
||||||
|
|
||||||
|
namespace VoiceCat.App.Forms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Modeless window for a single private message conversation.
|
||||||
|
/// Created by MainForm and shown non-modally; MainForm routes incoming PMs here.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PrivateMessageForm : Form
|
||||||
|
{
|
||||||
|
private readonly VoiceCatClient _client;
|
||||||
|
private readonly uint _userId;
|
||||||
|
private readonly uint _selfUserId;
|
||||||
|
|
||||||
|
private readonly RichTextBox _rtbHistory;
|
||||||
|
private readonly TextBox _txtCompose;
|
||||||
|
private readonly Button _btnSend;
|
||||||
|
|
||||||
|
public PrivateMessageForm(VoiceCatClient client, uint userId, string nickname, uint selfUserId)
|
||||||
|
{
|
||||||
|
_client = client;
|
||||||
|
_userId = userId;
|
||||||
|
_selfUserId = selfUserId;
|
||||||
|
|
||||||
|
Text = $"Private Message — {nickname}";
|
||||||
|
ClientSize = new Size(480, 360);
|
||||||
|
MinimumSize = new Size(320, 240);
|
||||||
|
StartPosition = FormStartPosition.Manual;
|
||||||
|
|
||||||
|
_rtbHistory = new RichTextBox
|
||||||
|
{
|
||||||
|
ReadOnly = true,
|
||||||
|
Dock = DockStyle.Fill,
|
||||||
|
ScrollBars = RichTextBoxScrollBars.Vertical,
|
||||||
|
BackColor = SystemColors.Window,
|
||||||
|
AccessibleName = "Message history",
|
||||||
|
};
|
||||||
|
|
||||||
|
var composePanel = new TableLayoutPanel
|
||||||
|
{
|
||||||
|
Dock = DockStyle.Bottom,
|
||||||
|
Height = 34,
|
||||||
|
ColumnCount = 2,
|
||||||
|
RowCount = 1,
|
||||||
|
Padding = new Padding(0, 3, 0, 0),
|
||||||
|
};
|
||||||
|
composePanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F));
|
||||||
|
composePanel.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 68F));
|
||||||
|
composePanel.RowStyles.Add(new RowStyle(SizeType.Percent, 100F));
|
||||||
|
|
||||||
|
_txtCompose = new TextBox
|
||||||
|
{
|
||||||
|
Dock = DockStyle.Fill,
|
||||||
|
AccessibleName = "Message text",
|
||||||
|
};
|
||||||
|
_btnSend = new Button
|
||||||
|
{
|
||||||
|
Text = "&Send",
|
||||||
|
Dock = DockStyle.Fill,
|
||||||
|
};
|
||||||
|
|
||||||
|
composePanel.Controls.Add(_txtCompose, 0, 0);
|
||||||
|
composePanel.Controls.Add(_btnSend, 1, 0);
|
||||||
|
|
||||||
|
Controls.Add(_rtbHistory);
|
||||||
|
Controls.Add(composePanel);
|
||||||
|
|
||||||
|
_txtCompose.KeyDown += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.KeyCode == Keys.Enter) { Send(); e.Handled = e.SuppressKeyPress = true; }
|
||||||
|
};
|
||||||
|
_btnSend.Click += (_, _) => Send();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called by MainForm when a PM message arrives (or we send one and the server echoes it).
|
||||||
|
public void AppendMessage(string time, bool isSelf, string sender, string text)
|
||||||
|
{
|
||||||
|
if (IsDisposed) return;
|
||||||
|
string line = $"[{time}] {sender}: {text}\n";
|
||||||
|
int selStart = _rtbHistory.TextLength;
|
||||||
|
_rtbHistory.AppendText(line);
|
||||||
|
if (isSelf)
|
||||||
|
{
|
||||||
|
_rtbHistory.Select(selStart, line.Length);
|
||||||
|
_rtbHistory.SelectionColor = Color.Gray;
|
||||||
|
_rtbHistory.Select(_rtbHistory.TextLength, 0);
|
||||||
|
_rtbHistory.SelectionColor = _rtbHistory.ForeColor;
|
||||||
|
}
|
||||||
|
_rtbHistory.ScrollToCaret();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called by MainForm for status events (e.g., the other user disconnected).
|
||||||
|
public void AppendActivity(string text)
|
||||||
|
{
|
||||||
|
if (IsDisposed) return;
|
||||||
|
string line = $"[{DateTime.Now:HH:mm}] {text}\n";
|
||||||
|
int selStart = _rtbHistory.TextLength;
|
||||||
|
_rtbHistory.AppendText(line);
|
||||||
|
_rtbHistory.Select(selStart, line.Length);
|
||||||
|
_rtbHistory.SelectionColor = Color.Gray;
|
||||||
|
_rtbHistory.Select(_rtbHistory.TextLength, 0);
|
||||||
|
_rtbHistory.SelectionColor = _rtbHistory.ForeColor;
|
||||||
|
_rtbHistory.ScrollToCaret();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Send()
|
||||||
|
{
|
||||||
|
string msg = _txtCompose.Text.Trim();
|
||||||
|
if (string.IsNullOrEmpty(msg)) return;
|
||||||
|
_client.SendText(VcTextScope.Private, _userId, msg);
|
||||||
|
_txtCompose.Clear();
|
||||||
|
// No optimistic echo: the server relays our own message back, so AppendMessage is
|
||||||
|
// called from MainForm.HandleTextMessage when the echo arrives.
|
||||||
|
}
|
||||||
|
}
|
||||||
51
clients/windows/VoiceCat.App/Forms/PttKeyCaptureDialog.cs
Normal file
51
clients/windows/VoiceCat.App/Forms/PttKeyCaptureDialog.cs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
namespace VoiceCat.App.Forms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shows "Press any key…" and captures the next KeyDown as the PTT key.
|
||||||
|
/// Press Escape to cancel without changing the key.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PttKeyCaptureDialog : Form
|
||||||
|
{
|
||||||
|
private readonly Label _label;
|
||||||
|
|
||||||
|
public Keys CapturedKey { get; private set; }
|
||||||
|
|
||||||
|
public PttKeyCaptureDialog(Keys current)
|
||||||
|
{
|
||||||
|
CapturedKey = current;
|
||||||
|
|
||||||
|
_label = new Label
|
||||||
|
{
|
||||||
|
Text = $"Current PTT key: {current}\n\nPress any key to set a new PTT key,\nor press Escape to keep the current key.",
|
||||||
|
AutoSize = false,
|
||||||
|
Dock = DockStyle.Fill,
|
||||||
|
TextAlign = ContentAlignment.MiddleCenter,
|
||||||
|
TabIndex = 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
AutoScaleMode = AutoScaleMode.Font;
|
||||||
|
ClientSize = new Size(340, 130);
|
||||||
|
Controls.Add(_label);
|
||||||
|
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||||
|
KeyPreview = true;
|
||||||
|
MaximizeBox = false;
|
||||||
|
MinimizeBox = false;
|
||||||
|
StartPosition = FormStartPosition.CenterParent;
|
||||||
|
Text = "Set PTT key";
|
||||||
|
|
||||||
|
KeyDown += (_, e) =>
|
||||||
|
{
|
||||||
|
e.SuppressKeyPress = true;
|
||||||
|
if (e.KeyCode == Keys.Escape)
|
||||||
|
{
|
||||||
|
DialogResult = DialogResult.Cancel;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
CapturedKey = e.KeyCode;
|
||||||
|
DialogResult = DialogResult.OK;
|
||||||
|
}
|
||||||
|
Close();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
65
clients/windows/VoiceCat.App/Forms/ServerIdentityDialog.Designer.cs
generated
Normal file
65
clients/windows/VoiceCat.App/Forms/ServerIdentityDialog.Designer.cs
generated
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
namespace VoiceCat.App.Forms;
|
||||||
|
|
||||||
|
partial class ServerIdentityDialog
|
||||||
|
{
|
||||||
|
private System.ComponentModel.IContainer components = null!;
|
||||||
|
private Label lblWarning = null!;
|
||||||
|
private Label lblFingerprint = null!;
|
||||||
|
private Button btnAccept = null!;
|
||||||
|
private Button btnCancel = null!;
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing) components?.Dispose();
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeComponent()
|
||||||
|
{
|
||||||
|
components = new System.ComponentModel.Container();
|
||||||
|
lblWarning = new Label();
|
||||||
|
lblFingerprint = new Label();
|
||||||
|
btnAccept = new Button();
|
||||||
|
btnCancel = new Button();
|
||||||
|
|
||||||
|
lblWarning.Location = new Point(12, 12);
|
||||||
|
lblWarning.Size = new Size(460, 90);
|
||||||
|
lblWarning.TabStop = true; // a Label can receive focus when TabStop=true — used
|
||||||
|
// deliberately so OnShown's Focus() call forces a screen
|
||||||
|
// reader to read this text immediately on activation.
|
||||||
|
lblWarning.TabIndex = 0;
|
||||||
|
|
||||||
|
lblFingerprint.Location = new Point(12, 110);
|
||||||
|
lblFingerprint.Size = new Size(460, 90);
|
||||||
|
lblFingerprint.TabStop = true;
|
||||||
|
lblFingerprint.TabIndex = 1;
|
||||||
|
lblFingerprint.Font = new Font(FontFamily.GenericMonospace, 9f);
|
||||||
|
|
||||||
|
btnAccept.Location = new Point(150, 210);
|
||||||
|
btnAccept.Size = new Size(220, 27);
|
||||||
|
btnAccept.TabIndex = 2;
|
||||||
|
btnAccept.Click += (_, _) => { DialogResult = DialogResult.OK; Close(); };
|
||||||
|
|
||||||
|
btnCancel.Text = "&Cancel";
|
||||||
|
btnCancel.Location = new Point(397, 210);
|
||||||
|
btnCancel.Size = new Size(75, 27);
|
||||||
|
btnCancel.TabIndex = 3;
|
||||||
|
btnCancel.Click += (_, _) => { DialogResult = DialogResult.Cancel; Close(); };
|
||||||
|
|
||||||
|
AutoScaleMode = AutoScaleMode.Font;
|
||||||
|
ClientSize = new Size(484, 250);
|
||||||
|
Controls.Add(lblWarning);
|
||||||
|
Controls.Add(lblFingerprint);
|
||||||
|
Controls.Add(btnAccept);
|
||||||
|
Controls.Add(btnCancel);
|
||||||
|
// Cancel is the default/Esc-bound button — the SAFE default for an unverified or
|
||||||
|
// changed identity is to NOT silently trust it.
|
||||||
|
AcceptButton = btnCancel;
|
||||||
|
CancelButton = btnCancel;
|
||||||
|
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||||
|
MaximizeBox = false;
|
||||||
|
MinimizeBox = false;
|
||||||
|
StartPosition = FormStartPosition.CenterParent;
|
||||||
|
Text = "Server identity";
|
||||||
|
}
|
||||||
|
}
|
||||||
66
clients/windows/VoiceCat.App/Forms/ServerIdentityDialog.cs
Normal file
66
clients/windows/VoiceCat.App/Forms/ServerIdentityDialog.cs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
using VoiceCat.Interop;
|
||||||
|
|
||||||
|
namespace VoiceCat.App.Forms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// TOFU server-identity confirmation (M4). Shown only for VcTofuStatus.FirstConnect/Mismatch
|
||||||
|
/// — never Matched (that's the silent-success "subsequent connects verify the pin" path
|
||||||
|
/// docs/security.md describes; showing a dialog on every routine reconnect would be exactly
|
||||||
|
/// the "overly chatty" experience this project avoids elsewhere too).
|
||||||
|
/// DialogResult.OK = accept (and the caller should call ConfirmServerIdentity(true) and, for
|
||||||
|
/// FirstConnect/Mismatch, the core persists the new pin); DialogResult.Cancel = reject.
|
||||||
|
/// </summary>
|
||||||
|
public partial class ServerIdentityDialog : Form
|
||||||
|
{
|
||||||
|
public ServerIdentityDialog(VcTofuStatus status, string certFingerprintHex, string identityDisplayHex)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
string formattedCertFp = FormatFingerprint(certFingerprintHex);
|
||||||
|
string formattedIdentityFp = string.IsNullOrEmpty(identityDisplayHex)
|
||||||
|
? "(not yet available)"
|
||||||
|
: FormatFingerprint(identityDisplayHex);
|
||||||
|
|
||||||
|
if (status == VcTofuStatus.Mismatch)
|
||||||
|
{
|
||||||
|
Text = "WARNING: Server identity changed";
|
||||||
|
lblWarning.Text =
|
||||||
|
"WARNING: This server's identity has CHANGED since you last connected.\r\n\r\n" +
|
||||||
|
"This could mean the server was reinstalled, OR that someone is intercepting " +
|
||||||
|
"your connection. If you did not expect this server's identity to change, " +
|
||||||
|
"choose Cancel.";
|
||||||
|
lblFingerprint.Text = $"New certificate fingerprint:\r\n{formattedCertFp}\r\n\r\n" +
|
||||||
|
$"Server also identifies as:\r\n{formattedIdentityFp}";
|
||||||
|
btnAccept.Text = "&Trust the new identity anyway";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Text = "New server — verify identity";
|
||||||
|
lblWarning.Text =
|
||||||
|
"You have not connected to this server before. If you have verified its " +
|
||||||
|
"fingerprint with the server operator through another channel, choose Trust " +
|
||||||
|
"and connect. Otherwise, choose Cancel.";
|
||||||
|
lblFingerprint.Text = $"Certificate fingerprint:\r\n{formattedCertFp}\r\n\r\n" +
|
||||||
|
$"Server also identifies as:\r\n{formattedIdentityFp}";
|
||||||
|
btnAccept.Text = "&Trust and connect";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnShown(EventArgs e)
|
||||||
|
{
|
||||||
|
base.OnShown(e);
|
||||||
|
// Force a screen reader to read the warning text immediately on dialog activation,
|
||||||
|
// rather than landing focus straight on a button and silently skipping it.
|
||||||
|
lblWarning.Focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatFingerprint(string hex)
|
||||||
|
{
|
||||||
|
// Groups of 4 hex chars, like a product key — easier to read/dictate/compare than one
|
||||||
|
// unbroken 64-character string.
|
||||||
|
var groups = new List<string>();
|
||||||
|
for (int i = 0; i < hex.Length; i += 4)
|
||||||
|
groups.Add(hex.Substring(i, Math.Min(4, hex.Length - i)));
|
||||||
|
return string.Join(' ', groups);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user