Compare commits
60 Commits
feat/m3-mu
...
ios-voice-
| Author | SHA1 | Date | |
|---|---|---|---|
| fb73b694d0 | |||
| 6c17881cc0 | |||
| e806b698ec | |||
| 4f89d2d32d | |||
| 6071c8e238 | |||
| 5be6d8430d | |||
| 0c6b1a36cf | |||
| c1e6f4f7ff | |||
| 6b7f06a282 | |||
| 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 |
24
.dockerignore
Normal file
24
.dockerignore
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Git history — large and never needed inside the build context
|
||||||
|
.git/
|
||||||
|
|
||||||
|
# Previous build outputs
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Native GUI client code (Swift/Xcode, C#/WinForms) — server build doesn't need these
|
||||||
|
clients/
|
||||||
|
|
||||||
|
# Documentation and prose — not compiled
|
||||||
|
docs/
|
||||||
|
*.md
|
||||||
|
AGENTS.md
|
||||||
|
PROGRESS.md
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
|
# Editor / tooling config
|
||||||
|
.clang-format
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# OS noise
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
87
.github/workflows/build-linux.yml
vendored
Normal file
87
.github/workflows/build-linux.yml
vendored
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
name: Build Linux Binaries
|
||||||
|
|
||||||
|
# Builds stripped voicecat-server + voicecat-admin for linux/amd64 and linux/arm64.
|
||||||
|
# Run manually from the Actions tab, or on any push to main.
|
||||||
|
# Artifacts are downloadable from the workflow run for ~90 days.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch: # manual trigger from the Actions tab
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'core/**'
|
||||||
|
- 'server/**'
|
||||||
|
- 'tools/**'
|
||||||
|
- 'cmake/**'
|
||||||
|
- 'CMakeLists.txt'
|
||||||
|
- 'CMakePresets.json'
|
||||||
|
- 'vcpkg.json'
|
||||||
|
- 'Dockerfile'
|
||||||
|
- '.github/workflows/build-linux.yml'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- arch: amd64
|
||||||
|
runner: ubuntu-24.04
|
||||||
|
vcpkg_triplet: x64-linux
|
||||||
|
- arch: arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
vcpkg_triplet: arm64-linux
|
||||||
|
|
||||||
|
name: linux/${{ matrix.arch }}
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Cache vcpkg packages
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/vcpkg
|
||||||
|
/usr/local/share/vcpkg/buildtrees
|
||||||
|
key: vcpkg-${{ matrix.vcpkg_triplet }}-${{ hashFiles('vcpkg.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
vcpkg-${{ matrix.vcpkg_triplet }}-
|
||||||
|
|
||||||
|
- name: Install build dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends \
|
||||||
|
build-essential cmake ninja-build git curl zip unzip tar \
|
||||||
|
pkg-config autoconf autoconf-archive automake libtool nasm python3
|
||||||
|
|
||||||
|
- name: Set up vcpkg
|
||||||
|
run: |
|
||||||
|
VCPKG_COMMIT=$(jq -r '."builtin-baseline"' vcpkg.json)
|
||||||
|
git init /tmp/vcpkg
|
||||||
|
git -C /tmp/vcpkg remote add origin https://github.com/microsoft/vcpkg.git
|
||||||
|
git -C /tmp/vcpkg fetch --depth=1 origin "$VCPKG_COMMIT"
|
||||||
|
git -C /tmp/vcpkg checkout FETCH_HEAD
|
||||||
|
/tmp/vcpkg/bootstrap-vcpkg.sh -disableMetrics
|
||||||
|
echo "VCPKG_ROOT=/tmp/vcpkg" >> "$GITHUB_ENV"
|
||||||
|
echo "VCPKG_DISABLE_METRICS=1" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Build (server-release preset)
|
||||||
|
run: |
|
||||||
|
cmake --preset server-release
|
||||||
|
cmake --build --preset server-release
|
||||||
|
|
||||||
|
- name: Collect binaries
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
cp build/server-release/bin/voicecat-server dist/
|
||||||
|
cp build/server-release/bin/voicecat-admin dist/
|
||||||
|
file dist/voicecat-server dist/voicecat-admin
|
||||||
|
ls -lh dist/
|
||||||
|
|
||||||
|
- name: Upload binaries
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: voicecat-linux-${{ matrix.arch }}
|
||||||
|
path: dist/
|
||||||
|
retention-days: 90
|
||||||
16
.gitignore
vendored
16
.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,18 @@ 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
|
||||||
|
|
||||||
|
# Python bytecode cache (e.g. scripts/asc_api.py)
|
||||||
|
__pycache__/
|
||||||
|
|||||||
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 — 24/24 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": "skeleton", "configurePreset": "skeleton", "output": { "outputOnFailure": true } },
|
||||||
{ "name": "dev", "configurePreset": "dev", "output": { "outputOnFailure": true } },
|
{ "name": "dev", "configurePreset": "dev", "output": { "outputOnFailure": true } },
|
||||||
{ "name": "m1-dev", "configurePreset": "m1-dev", "output": { "outputOnFailure": true } },
|
{ "name": "release", "configurePreset": "release", "output": { "outputOnFailure": true } }
|
||||||
{ "name": "m2-dev", "configurePreset": "m2-dev", "output": { "outputOnFailure": true } }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
98
Dockerfile
Normal file
98
Dockerfile
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Stage 1 — Build
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
FROM ubuntu:24.04 AS builder
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
cmake \
|
||||||
|
ninja-build \
|
||||||
|
git \
|
||||||
|
curl \
|
||||||
|
zip \
|
||||||
|
unzip \
|
||||||
|
tar \
|
||||||
|
pkg-config \
|
||||||
|
ca-certificates \
|
||||||
|
autoconf \
|
||||||
|
autoconf-archive \
|
||||||
|
automake \
|
||||||
|
libtool \
|
||||||
|
nasm \
|
||||||
|
python3 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Fetch vcpkg at the exact commit pinned in vcpkg.json builtin-baseline.
|
||||||
|
# vcpkg resolves baselines via `git show <sha>:versions/baseline.json`, so it
|
||||||
|
# needs a real .git repo — not a tarball. A single shallow fetch is fast (~30 MB)
|
||||||
|
# and gives vcpkg exactly what it needs.
|
||||||
|
ARG VCPKG_COMMIT=d46283cf33cf5de7bd88e12156ce03882be1f179
|
||||||
|
RUN git init /vcpkg \
|
||||||
|
&& git -C /vcpkg remote add origin https://github.com/microsoft/vcpkg.git \
|
||||||
|
&& git -C /vcpkg fetch --depth=1 origin "${VCPKG_COMMIT}" \
|
||||||
|
&& git -C /vcpkg checkout FETCH_HEAD \
|
||||||
|
&& /vcpkg/bootstrap-vcpkg.sh -disableMetrics
|
||||||
|
ENV VCPKG_ROOT=/vcpkg
|
||||||
|
ENV VCPKG_DISABLE_METRICS=1
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ARG TARGETARCH
|
||||||
|
# Three cache mounts:
|
||||||
|
# downloads — source tarballs (~200 MB); safe to share across arches
|
||||||
|
# vcpkg-cache — vcpkg binary cache (pre-built .zip archives per package ABI);
|
||||||
|
# restores packages in seconds on subsequent builds instead of
|
||||||
|
# recompiling. Scoped by arch so amd64/arm64 don't collide.
|
||||||
|
# buildtrees — NOT cached; deleted at end of layer so neither the Docker
|
||||||
|
# image nor the BuildKit cache accumulates several GB of
|
||||||
|
# intermediate build artifacts.
|
||||||
|
ENV VCPKG_BINARY_SOURCES="clear;files,/vcpkg-cache,readwrite"
|
||||||
|
RUN --mount=type=cache,target=/vcpkg/downloads \
|
||||||
|
--mount=type=cache,target=/vcpkg-cache,id=vc-bin-${TARGETARCH} \
|
||||||
|
cmake --preset server-release \
|
||||||
|
&& cmake --build --preset server-release \
|
||||||
|
&& rm -rf /vcpkg/buildtrees
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Stage 2 — Export (binary-only, used by scripts/build-linux-binaries.sh)
|
||||||
|
# docker buildx build --target export --output type=local,dest=./dist/linux-amd64 .
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
FROM scratch AS export
|
||||||
|
COPY --from=builder /src/build/server-release/bin/voicecat-server /voicecat-server
|
||||||
|
COPY --from=builder /src/build/server-release/bin/voicecat-admin /voicecat-admin
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Stage 3 — Runtime (default stage — must be last)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
FROM ubuntu:24.04 AS runtime
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# ca-certificates is useful if the server ever makes outbound TLS calls; also
|
||||||
|
# satisfies any mbedTLS system-CA lookup at runtime.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN groupadd -r voicecat && useradd -r -g voicecat -s /sbin/nologin voicecat
|
||||||
|
|
||||||
|
COPY --from=builder /src/build/server-release/bin/voicecat-server /usr/local/bin/voicecat-server
|
||||||
|
COPY --from=builder /src/build/server-release/bin/voicecat-admin /usr/local/bin/voicecat-admin
|
||||||
|
|
||||||
|
RUN mkdir -p /data && chown voicecat:voicecat /data
|
||||||
|
|
||||||
|
USER voicecat
|
||||||
|
|
||||||
|
# Persistent state: Ed25519 identity key, self-signed TLS cert, SQLite database.
|
||||||
|
VOLUME ["/data"]
|
||||||
|
|
||||||
|
# Control (TLS 1.3) and media (ChaCha20-Poly1305) share one port number on TCP+UDP.
|
||||||
|
EXPOSE 8384/tcp
|
||||||
|
EXPOSE 8384/udp
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/voicecat-server"]
|
||||||
|
CMD ["--data-dir", "/data"]
|
||||||
806
PROGRESS.md
806
PROGRESS.md
@@ -10,20 +10,453 @@ 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
|
- **Awaiting on-device verification (2026-06-22):** **iOS real echo cancellation / noise suppression
|
||||||
section below for the full file-by-file change list. `ctest --test-dir build/m1-dev` —
|
via native VPIO.** Root cause of "voice chat doesn't sound like a call" (echo + no NR): real iOS
|
||||||
**11/11 tests** green (3 consecutive full-suite runs), including the new
|
AEC/NS/AGC come only from Apple's Voice-Processing I/O unit (VPIO), but the core uses miniaudio's
|
||||||
`test_m3_multistream` (real `vc_client`s, not raw sockets — same lesson as M2: ABI-level
|
plain RemoteIO units — so `.voiceChat` mode alone never engaged AEC. Fix moves both mic capture and
|
||||||
coverage is what proves the client library, not just the wire protocol).
|
playback to a native Swift `AVAudioEngine` (`setVoiceProcessingEnabled`) on the AEC presets, with the
|
||||||
**Two items intentionally still open** (carried forward, not silently dropped):
|
core in external mode.
|
||||||
- `vc_set_input_device`/`vc_set_input_mode`/`vc_set_push_to_talk`/`vc_list_devices`
|
- **Core (done, builds + tests green):** new ABI `vc_set_mixed_output_sink` + `vc_set_external_playback`
|
||||||
(device enumeration + VAD/PTT input gate) remain `VC_ERR_NOT_IMPLEMENTED` — explicitly
|
(voicecat.h PATCH→2). `AudioEngine` gains a mixer-timer thread that drives `on_playback` (decode+mix)
|
||||||
scoped out of this M3 pass; revisit in a future milestone.
|
on a ~20 ms cadence with NO hardware playback device and ships the final mix to the mixed-output
|
||||||
- Stereo Opus is now wire-correct end-to-end (a channel configured `MODE_STEREO` really
|
sink; `start()` also skips the hardware capture device when the MIC stream is `external_feed`
|
||||||
encodes/decodes 2-channel Opus packets), but `AudioEngine`'s playback mixer/output device
|
(`AudioParams.external_capture`). New white-box test `test_external_playback` (23/24;
|
||||||
stays mono internally — stereo streams are downmixed (avg L/R) immediately after decode,
|
pre-existing `external_pcm` teardown crash on Darwin 25.5 is UNRELATED — original tree crashes too).
|
||||||
before mixing. True stereo *playback output* is a follow-up, not part of M3.
|
- **Swift (done, builds):** `VoiceCatCore` wrappers (`externalFeed` on `StreamDescriptor`,
|
||||||
- **Next:** **M4 — native clients** (Windows C#, macOS/iOS Swift). See `docs/roadmap.md §M4`.
|
`setMixedOutputSink`, `setExternalPlayback`); new `IOSVoiceProcessingEngine.swift` (VPIO
|
||||||
|
`AVAudioEngine`: mic tap→`feedPcm`, mixed-sink lock-free ring→`AVAudioSourceNode`);
|
||||||
|
`IOSAudioRouter.currentConfigUsesVoiceProcessing` gates the path per preset; `SessionState`
|
||||||
|
join/leave + `reconcileVoicePath()` switch between VPIO and the miniaudio path; Voice Chat defaults
|
||||||
|
to speaker; SettingsView shows AEC/NS state. **Rebuild the xcframework** before building the app:
|
||||||
|
`clients/apple/scripts/build-xcframework.sh --all` (new ABI symbols). `xcodebuild` iOS sim Debug
|
||||||
|
BUILD SUCCEEDED.
|
||||||
|
- **Post-verification fixes (2026-06-22, Swift-only — no core/ABI change):** two on-device bugs fixed.
|
||||||
|
- **Voice Chat (VPIO) silent playback:** `SessionState.doStartMicStream()` called `audioRestart()`
|
||||||
|
BEFORE `startStream`, so when the engine was already running (a remote stream had started it) it
|
||||||
|
reopened with `external_capture=false` and opened a hardware miniaudio capture device; the
|
||||||
|
announce-result restart then early-returned (engine already running) so that device was never
|
||||||
|
dropped and fought the `AVAudioEngine` VPIO unit, silencing playback. Fix: set
|
||||||
|
`setExternalPlayback` first, then `startStream` (which stores `external_feed` synchronously), THEN
|
||||||
|
`audioRestart()` — the core reopens in full external mode (no hardware devices). Added VPIO
|
||||||
|
diagnostics (graph/route formats at start; ring written/read totals at teardown).
|
||||||
|
- **Stereo Mic / Studio quiet earpiece:** the `.builtInMicBtA2dp` presets omit `.defaultToSpeaker`
|
||||||
|
(it breaks A2DP) and skip `forceSpeaker`, so with no Bluetooth connected output pinned to the quiet
|
||||||
|
receiver. New `IOSAudioRouter.applyA2dpSpeakerFallback()` overrides to the built-in speaker when no
|
||||||
|
external (A2DP/wired/AirPlay) output is present, clears the override when one is — called after
|
||||||
|
activation and on device-change route changes (`AudioSessionManager`).
|
||||||
|
- **Next (user, on device):** two iPhones on speaker, Voice Chat preset → confirm (a) no echo, (b)
|
||||||
|
background noise suppressed, (c) speaker output by default AND remote audio is now audible; then
|
||||||
|
Stereo Mic / Studio with no BT → confirm loud speaker (not earpiece), and A2DP takes over when a BT
|
||||||
|
headset connects. Tune the mixer-timer/ring sizing if there's under/overrun.
|
||||||
|
|
||||||
|
- **Done (2026-06-21):** **Docker + Linux deployment + GitHub Actions cross-build.** Added the complete Linux server
|
||||||
|
deployment story (the only missing platform — Windows and macOS already have native
|
||||||
|
binaries):
|
||||||
|
- `Dockerfile` — multi-stage (builder: `ubuntu:24.04` + vcpkg + `cmake --preset
|
||||||
|
server-release`; runtime: `ubuntu:24.04`, non-root `voicecat` user, `/data` volume,
|
||||||
|
TCP+UDP 8384). vcpkg is fetched via the GitHub archive tarball at the exact
|
||||||
|
`builtin-baseline` commit (`d46283cf…`), avoiding a full git-history clone. BuildKit
|
||||||
|
cache mounts on `/vcpkg/downloads`, `/vcpkg/buildtrees`, `/vcpkg/packages` (scoped by
|
||||||
|
`TARGETARCH`) keep rebuilds fast. Both `voicecat-server` and `voicecat-admin` are
|
||||||
|
copied into the runtime image.
|
||||||
|
- `docker-compose.yml` — single-service compose file with `restart: unless-stopped`,
|
||||||
|
named volume `voicecat-data`, and port mappings for TCP+UDP 8384. `command:` shows
|
||||||
|
how to set `--name`.
|
||||||
|
- `.dockerignore` — excludes `.git/`, `build/`, `clients/` (Swift/C# code), `docs/`,
|
||||||
|
markdown, editor config; build context is just `core/`, `server/`, `tools/`, `cmake/`,
|
||||||
|
and the three root CMake/vcpkg files.
|
||||||
|
- `deploy/linux/voicecat.service` — hardened systemd unit (non-root, `ProtectSystem`,
|
||||||
|
`NoNewPrivileges`, `AmbientCapabilities=CAP_NET_BIND_SERVICE`) for bare-metal deploys.
|
||||||
|
- Multi-arch: `docker buildx build --platform linux/amd64,linux/arm64 .` works without
|
||||||
|
any triplet override — `cmake/voicecat-toolchain.cmake` auto-detects from the host
|
||||||
|
arch cmake sees inside the buildx container.
|
||||||
|
- Quick start: `docker compose up -d` (or `docker run -d -p 8384:8384/tcp -p
|
||||||
|
8384:8384/udp -v voicecat-data:/data voicecat`). First run auto-generates identity
|
||||||
|
+ cert + DB; check logs for fingerprint + admin password.
|
||||||
|
- **GitHub Actions** (`.github/workflows/build-linux.yml`): primary cross-platform
|
||||||
|
binary build path — amd64 uses `ubuntu-24.04`, arm64 uses `ubuntu-24.04-arm`
|
||||||
|
(native, not QEMU). Triggers on push to main (when C++/cmake files change) and
|
||||||
|
manually via `workflow_dispatch`. Downloads land as 90-day artifacts.
|
||||||
|
`scripts/build-linux-binaries.sh` is the local Docker fallback (needs ~10–15 GB
|
||||||
|
free disk; suits Linux dev machines, not Windows Docker Desktop).
|
||||||
|
|
||||||
|
- **Done (2026-06-21):** **Fix permanent voice-loss bug + harden the UDP media path (protocol v2).**
|
||||||
|
Field report: two iOS users lost all audio mid-call after a bad-network blip and could not
|
||||||
|
recover even by restarting the apps. Root causes found in the UDP media path:
|
||||||
|
1. **Anti-replay window poisoned by unauthenticated packets (the trigger).**
|
||||||
|
`SodiumMediaCrypto::open()` advanced `recv_highest_` from the plaintext header `seq`
|
||||||
|
*before* verifying the AEAD tag and never rolled it back on failure. One corrupted/forged
|
||||||
|
frame (a bit-flip on flaky wifi) shoved the high-water mark far ahead, after which every
|
||||||
|
legitimate frame was rejected as "too old" — permanently. Fixed by reordering to
|
||||||
|
replay-check → authenticate → update (RFC 3711 §3.3): the window is now touched only after
|
||||||
|
a successful tag check. Regression test in `test_media_aead.cpp`
|
||||||
|
(`test_corrupted_seq_does_not_poison_window`) — fails on the old code, passes now.
|
||||||
|
2. **16-bit seq wrap with no rollover counter.** The wire header carried only the low 16 bits
|
||||||
|
of the nonce counter (zero-extended on receive); after 65,536 frames the reconstructed
|
||||||
|
nonce diverged and all frames failed auth. **Wire format widened to a full u64 seq**
|
||||||
|
(`voice_frame.h`: header 14 → 20 bytes, `seq` u16 → u64; `crypto.cpp`, `client.cpp`,
|
||||||
|
`media_relay.cpp` updated; `JitterBuffer::Frame::seq` widened). This is a **versioned wire
|
||||||
|
change → `VOICECAT_PROTOCOL_VERSION` 1 → 2**; the `Hello` handshake rejects on mismatch
|
||||||
|
(`conn_session.cpp`). The voice frame is parsed only in `core/`+`server/`+`tests/`, so the
|
||||||
|
Swift/C# clients need only a rebuild — no parser changes.
|
||||||
|
3. **Server leaked UDP state on disconnect.** `SessionRegistry::unregister_session()` now also
|
||||||
|
frees `udp_endpoints_`/`udp_tokens_`/`ssrc_to_session_` (scan-and-erase by session id).
|
||||||
|
4. **Diagnostics.** `MediaRelay` now emits rate-limited dropped-frame counters
|
||||||
|
(unmapped-endpoint / no-recv-crypto / open-failed) so a wedged media path is observable.
|
||||||
|
- **Verified:** `cmake --build --preset dev` clean; `ctest --preset dev -E external_pcm`
|
||||||
|
**22/22 pass** (incl. `m2_voice` e2e relay + the two new AEAD regressions). `external_pcm`
|
||||||
|
still aborts on the **pre-existing** CoreAudio shutdown mutex race (confirmed identical on a
|
||||||
|
clean baseline checkout under the same harness — unrelated to these changes). Docs updated:
|
||||||
|
`voice.md` §2 (header), `protocol.md` (v2 + negotiation), `security.md` (authenticate-then-advance).
|
||||||
|
|
||||||
|
- **Done (2026-06-21):** **Expose all channel codec params + guest nickname in every client.**
|
||||||
|
- **DRED everywhere + ABI fix.** `dred` (Opus 1.6 Deep REDundancy) existed in the C ABI
|
||||||
|
(`vc_audio_config.dred`) and proto but was absent from *both* client marshaling layers — a
|
||||||
|
latent ABI mismatch: Swift `AudioConfig` and the C# `VcAudioConfigNative` blittable struct
|
||||||
|
were each one `int` short of the native struct passed to `vc_create_channel`/`vc_edit_channel`.
|
||||||
|
Added `dred` through Swift (`Models.swift`, `Marshaling.swift`, `VoiceCatClient.toNative`) and
|
||||||
|
C# (`Structs.cs`, `Models.cs`, `Marshaling.cs`, `VoiceCatClient.cs`).
|
||||||
|
- **Windows:** added the one missing DRED checkbox to `ChannelEditDialog` (all other params
|
||||||
|
were already present).
|
||||||
|
- **macOS:** `ChannelEditSheet` now exposes the previously-hidden params — application profile,
|
||||||
|
sample rate, expected packet loss, complexity, and DRED (was only stereo/bitrate/frame/FEC/DTX).
|
||||||
|
- **iOS:** `ChannelEditView` was name+topic only; rebuilt into a full create **and edit** form
|
||||||
|
(General: name/topic/parent/password/max-users/sort-order; Audio: stereo/bitrate/sample-rate/
|
||||||
|
frame/application/packet-loss/complexity/FEC/DTX/DRED). Added `SessionState.editChannel` and an
|
||||||
|
"Edit" swipe action (admins) in `ChannelTreeView` + `ChannelBrowserView` (iOS previously had no
|
||||||
|
edit-channel UI at all). Note: the channel list doesn't carry the current audio config, so on
|
||||||
|
edit the audio fields start from codec defaults — same limitation as macOS/Windows.
|
||||||
|
- **Guest nickname.** Guests could not set a display name on iOS *or* macOS (the field was
|
||||||
|
absent/disabled; only Windows had it). Added a dedicated `nickname` to `SavedServer` on both
|
||||||
|
(backward-compatible Codable), a Nickname field shown in Guest mode (`AddServerView` /
|
||||||
|
`AddServerSheet`), and wired the guest auth path to use it (`AppState`, `ConnectWindowController`).
|
||||||
|
- **Verified:** `xcodebuild` Debug — macOS BUILD SUCCEEDED; iOS (sim, `ARCHS=arm64`) BUILD
|
||||||
|
SUCCEEDED. Core `ctest --preset dev` 22/23 (only `external_pcm` aborts on a pre-existing
|
||||||
|
shutdown mutex race; no C++ was changed). Windows C# not buildable on macOS — changes reviewed.
|
||||||
|
|
||||||
|
- **Done (2026-06-21):** **iOS iPhone-layout UX fixes.** (1) Channels are now a **drill-down**
|
||||||
|
on iPhone — new `ChannelBrowserView` (root list of top-level channels) → `ChannelDetailView`
|
||||||
|
(people in the channel + sub-channels + an explicit "Join Channel" button with password
|
||||||
|
prompt). The iPad 3-column `NavigationSplitView` is unchanged. (2) Extracted a self-contained
|
||||||
|
`UserRow` (context menu + sheets) from `UserListView` so admin actions are reused in the
|
||||||
|
drill-down. (3) Fixed the **off-screen chat compose box**: `MainView` now places
|
||||||
|
`VoiceControlsView` via `.safeAreaInset(edge: .bottom)` instead of a floating `.overlay`, so
|
||||||
|
it reserves layout space above the tab bar and cooperates with keyboard avoidance. (4)
|
||||||
|
**Collapsed Activity into Chat** like macOS/Windows: `ChatView` renders a merged, time-sorted
|
||||||
|
timeline of `messages` + `activityLog` (activity rows in gray); the separate Activity tab and
|
||||||
|
`ActivityLogView.swift` are removed. `xcodebuild` Debug for `generic/platform=iOS` BUILD
|
||||||
|
SUCCEEDED (sim slice still arm64-only → simulator run N/A). Next: on-device check of the
|
||||||
|
drill-down + compose box + unified timeline.
|
||||||
|
|
||||||
|
- **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-21):** **macOS per-app screen-audio selection.** Before sharing, a new
|
||||||
|
`ScreenSharePickerSheet` lets the user choose scope — share Everything / Only selected apps /
|
||||||
|
All except selected apps — plus a first-class **"Exclude screen reader (VoiceOver) audio"**
|
||||||
|
toggle. `ScreenAudioCapture` now takes a `ScreenAudioSelection` and builds the matching
|
||||||
|
`SCContentFilter` (`including:` / `excludingApplications:`); app list comes from
|
||||||
|
`SCShareableContent`. macOS `xcodebuild` Debug BUILD SUCCEEDED. iOS deliberately untouched —
|
||||||
|
ReplayKit only delivers the mixed system stream, so per-app/VoiceOver filtering is impossible
|
||||||
|
there (documented in voice.md §9). **Still to verify on-device:** which process actually
|
||||||
|
carries VoiceOver speech (VoiceOver app vs. `com.apple.speech.speechsynthesisd`) — the exclude
|
||||||
|
set covers both candidates in `ScreenAudioCapture.screenReaderBundleIDs`; confirm exclusion
|
||||||
|
actually silences it in a real share.
|
||||||
|
|
||||||
|
- **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 +466,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,156 @@
|
|||||||
# 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.
|
||||||
|
|
||||||
|
## Ad-hoc distribution (iOS, pre-TestFlight)
|
||||||
|
|
||||||
|
To hand the iOS app to a handful of friends before TestFlight, use
|
||||||
|
[`scripts/dist-ios-adhoc.sh`](../../scripts/dist-ios-adhoc.sh). It registers each device's
|
||||||
|
UDID, builds an ad-hoc-signed `VoiceCatiOS.ipa`, and generates the `manifest.plist` +
|
||||||
|
`index.html` for an over-the-air (`itms-services://`) web install. Ad-hoc builds only run on
|
||||||
|
devices whose UDID is registered *before* signing, and stock iOS won't install a bare `.ipa`
|
||||||
|
without a sideloading tool — so the web-install page is the friend-friendly path.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# One-time: create an App Store Connect API "Team Key" (.p8, Admin/App Manager access) at
|
||||||
|
# App Store Connect → Users and Access → Integrations → App Store Connect API
|
||||||
|
export ASC_KEY_ID=ABC123 ASC_ISSUER_ID=1111-... ASC_KEY_PATH=~/.appstoreconnect/AuthKey_ABC123.p8
|
||||||
|
|
||||||
|
# Register a device + build + stage everything into dist/ios-adhoc/
|
||||||
|
scripts/dist-ios-adhoc.sh --udid <UDID> --name "Friend iPhone" \
|
||||||
|
--base-url https://example.com/voicecat
|
||||||
|
```
|
||||||
|
|
||||||
|
Then upload the three staged files (`VoiceCatiOS.ipa`, `manifest.plist`, `index.html`) to
|
||||||
|
that **HTTPS** folder and open `index.html` in Safari on a registered iPhone (iOS 18+).
|
||||||
|
Device UDID registration is automated via [`scripts/asc_api.py`](../../scripts/asc_api.py)
|
||||||
|
(`asc_api.py list` shows the registered devices against the 100-iOS-devices/year cap).
|
||||||
|
Requires a paid Apple Developer Program membership. Run `scripts/dist-ios-adhoc.sh --help`
|
||||||
|
for all flags.
|
||||||
|
|||||||
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, dred: c.dred != 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func permissions(_ p: vc_permissions) -> Permissions {
|
||||||
|
Permissions(canCreateTempChannel: p.can_create_temp_channel != 0,
|
||||||
|
canKick: p.can_kick != 0, canBan: p.can_ban != 0,
|
||||||
|
canMoveUsers: p.can_move_users != 0,
|
||||||
|
canAdminAccounts: p.can_admin_accounts != 0,
|
||||||
|
isAdmin: p.is_admin != 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
242
clients/apple/Sources/VoiceCatCore/Models.swift
Normal file
242
clients/apple/Sources/VoiceCatCore/Models.swift
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
// 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 let dred: Bool // Deep REDundancy (Opus 1.6), off by default
|
||||||
|
|
||||||
|
public init(codec: UInt32 = 0, stereo: Bool = false, sampleRate: UInt32 = 48000,
|
||||||
|
bitrateBps: UInt32 = 64000, frameMs: UInt32 = 20, application: UInt32 = 0,
|
||||||
|
fec: Bool = true, expectedPacketLoss: UInt32 = 5, dtx: Bool = false,
|
||||||
|
complexity: UInt32 = 10, dred: Bool = false) {
|
||||||
|
self.codec = codec; self.stereo = stereo; self.sampleRate = sampleRate
|
||||||
|
self.bitrateBps = bitrateBps; self.frameMs = frameMs; self.application = application
|
||||||
|
self.fec = fec; self.expectedPacketLoss = expectedPacketLoss; self.dtx = dtx
|
||||||
|
self.complexity = complexity; self.dred = dred
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream descriptor — mirrors `vc_stream_desc`. Used by `startStream(kind:deviceId:label:)`.
|
||||||
|
public struct StreamDescriptor: Sendable, Equatable {
|
||||||
|
public let kind: VoiceCatStreamKind
|
||||||
|
/// nil = default device for this kind.
|
||||||
|
public let deviceId: String?
|
||||||
|
public let label: String
|
||||||
|
/// When true the caller feeds PCM via `feedPcm` (e.g. the iOS VPIO mic path) and the core
|
||||||
|
/// skips opening a hardware capture device for this stream. Mirrors `vc_stream_desc.external_feed`.
|
||||||
|
public let externalFeed: Bool
|
||||||
|
|
||||||
|
public init(kind: VoiceCatStreamKind, deviceId: String? = nil, label: String,
|
||||||
|
externalFeed: Bool = false) {
|
||||||
|
self.kind = kind; self.deviceId = deviceId; self.label = label
|
||||||
|
self.externalFeed = externalFeed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server identity info — parsed from a `.serverIdentity` event + `getServerIdentityDisplay()`.
|
||||||
|
/// The `tlsCertFingerprint` (SHA-256 hex of the TLS leaf cert) is the value the TOFU gate
|
||||||
|
/// actually pins on; `ed25519Fingerprint` is display-only (docs/security.md §1.1).
|
||||||
|
public struct ServerIdentity: Sendable, Equatable {
|
||||||
|
public let tofuStatus: VoiceCatTofuStatus
|
||||||
|
/// SHA-256 hex of the TLS leaf certificate — the pinned value. No separators (64 chars).
|
||||||
|
public let tlsCertFingerprint: String
|
||||||
|
/// Ed25519 identity fingerprint from ServerHello, hex-formatted — display only.
|
||||||
|
/// Empty if not yet available.
|
||||||
|
public let ed25519Fingerprint: String
|
||||||
|
|
||||||
|
public init(tofuStatus: VoiceCatTofuStatus, tlsCertFingerprint: String,
|
||||||
|
ed25519Fingerprint: String) {
|
||||||
|
self.tofuStatus = tofuStatus; self.tlsCertFingerprint = tlsCertFingerprint
|
||||||
|
self.ed25519Fingerprint = ed25519Fingerprint
|
||||||
|
}
|
||||||
|
}
|
||||||
617
clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift
Normal file
617
clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift
Normal file
@@ -0,0 +1,617 @@
|
|||||||
|
// 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
|
||||||
|
|
||||||
|
/// Swift-idiomatic alias for the C `vc_mixed_output_cb` function-pointer type from `voicecat.h`
|
||||||
|
/// — the external mixed-output sink used by the iOS VPIO path (see `setMixedOutputSink`).
|
||||||
|
public typealias VoiceCatMixedOutputCallback = vc_mixed_output_cb
|
||||||
|
|
||||||
|
/// The Swift wrapper around `vc_client*`. Owns the native handle for its entire lifetime;
|
||||||
|
/// `deinit` destroys it. Events and level meters are delivered on the main queue via the
|
||||||
|
/// `onEvent` / `onLevel` closures.
|
||||||
|
///
|
||||||
|
/// Thread-safety: the public methods are not thread-safe — call them from the main thread
|
||||||
|
/// (the standard AppKit/SwiftUI pattern). The internal event/level buffers are thread-safe
|
||||||
|
/// (lock-protected) because they're written from the core's event thread.
|
||||||
|
public final class VoiceCatClient {
|
||||||
|
|
||||||
|
// MARK: - Stored properties
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
desc.external_feed = descriptor.externalFeed ? 1 : 0
|
||||||
|
|
||||||
|
let r = vc_stream_start(handle, &desc, &streamId)
|
||||||
|
return (VoiceCatResult(r), streamId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func stopStream(_ streamId: UInt32) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_stream_stop(handle, streamId))
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func setInputDevice(streamId: UInt32, deviceId: String?) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_set_input_device(handle, streamId, deviceId))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the capture channel count for a local MIC stream (1 = mono, 2 = stereo interleaved).
|
||||||
|
/// Takes effect on the next AudioEngine restart (immediately if already running). Used by
|
||||||
|
/// the iOS `IOSAudioRouter` when the user picks stereo built-in mic capture.
|
||||||
|
@discardableResult
|
||||||
|
public func setCaptureChannels(streamId: UInt32, channels: UInt32) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_set_capture_channels(handle, streamId, channels))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - External PCM feed / tap
|
||||||
|
|
||||||
|
/// External PCM feed — drives a local stream's encode pipeline with caller-supplied PCM
|
||||||
|
/// instead of (or in addition to) a hardware capture device. Intended for ReplayKit
|
||||||
|
/// Broadcast Extension (iOS), ScreenCaptureKit (macOS), bots, and soundboard use cases.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - streamId: The stream returned by `startStream`. Must be active.
|
||||||
|
/// - pcm: Raw int16 PCM pointer. Caller must keep the buffer alive for the duration of the call.
|
||||||
|
/// - samplesPerChannel: Samples per channel (e.g. 960 for 20 ms @ 48 kHz).
|
||||||
|
/// - channels: 1 (mono) or 2 (stereo interleaved L/R).
|
||||||
|
@discardableResult
|
||||||
|
public func feedPcm(streamId: UInt32, pcm: UnsafePointer<Int16>,
|
||||||
|
samplesPerChannel: Int, channels: UInt32) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_stream_feed_pcm(handle, streamId, pcm,
|
||||||
|
samplesPerChannel, channels))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience overload for feeding from a Swift `[Int16]` array.
|
||||||
|
@discardableResult
|
||||||
|
public func feedPcm(streamId: UInt32, pcm: [Int16],
|
||||||
|
samplesPerChannel: Int, channels: UInt32) -> VoiceCatResult {
|
||||||
|
pcm.withUnsafeBufferPointer {
|
||||||
|
feedPcm(streamId: streamId, pcm: $0.baseAddress!,
|
||||||
|
samplesPerChannel: samplesPerChannel, channels: channels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// External PCM tap — receive decoded per-stream audio as raw int16 PCM before it
|
||||||
|
/// reaches the hardware mix. Fires once per decoded Opus frame per remote stream.
|
||||||
|
///
|
||||||
|
/// The callback is a C function pointer (`@convention(c)`) receiving:
|
||||||
|
/// `(user, userId, streamId, pcm, samplesPerChannel, channels, sampleRate)`
|
||||||
|
///
|
||||||
|
/// Pass `nil` to disable (default). The callback MUST NOT block or allocate.
|
||||||
|
@discardableResult
|
||||||
|
public func setPcmSink(_ cb: VoiceCatPcmSinkCallback?, user: UnsafeMutableRawPointer?) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_set_pcm_sink(handle, cb, user))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// External mixed-output sink (iOS VPIO) — receives the FINAL mixed remote audio as int16
|
||||||
|
/// PCM on the core's mixer-timer thread when external playback is enabled. The Swift VPIO
|
||||||
|
/// renderer copies this into its ring and plays it through the voice-processing output so
|
||||||
|
/// echo cancellation has its reference signal. Pass `nil` to disable. Mirrors
|
||||||
|
/// `vc_set_mixed_output_sink`. The callback MUST NOT block or allocate.
|
||||||
|
@discardableResult
|
||||||
|
public func setMixedOutputSink(_ cb: VoiceCatMixedOutputCallback?,
|
||||||
|
user: UnsafeMutableRawPointer?) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_set_mixed_output_sink(handle, cb, user))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable/disable external-playback mode (iOS VPIO). When enabled, the core opens NO hardware
|
||||||
|
/// playback device; it drives decode+mix on a timer and delivers the final mix via
|
||||||
|
/// `setMixedOutputSink`. Apply before the engine starts, or follow with `audioRestart()` to
|
||||||
|
/// apply to a running engine. Mirrors `vc_set_external_playback`.
|
||||||
|
@discardableResult
|
||||||
|
public func setExternalPlayback(_ enabled: Bool) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_set_external_playback(handle, enabled ? 1 : 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func setInputMode(_ mode: VoiceCatInputMode) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_set_input_mode(handle, mode.cValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// VAD threshold: normalized RMS 0.0–1.0 (default ~0.025). Takes effect immediately.
|
||||||
|
@discardableResult
|
||||||
|
public func setVadThreshold(_ threshold: Float) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_set_vad_threshold(handle, threshold))
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func setPushToTalk(_ active: Bool) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_set_push_to_talk(handle, active ? 1 : 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func setSelfMute(micMuted: Bool, deafened: Bool) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_set_self_mute(handle, micMuted ? 1 : 0, deafened ? 1 : 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Global playback volume applied after mixing all remote streams. gain 0.0 = silent,
|
||||||
|
/// 1.0 = unity (default), >1.0 amplifies. Always LOCAL — no protocol traffic. Mirrors the
|
||||||
|
/// Windows client's `SetOutputVolume` and the C ABI `vc_set_output_volume` 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
|
||||||
|
n.dred = dred ? 1 : 0
|
||||||
|
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>
|
||||||
612
clients/apple/iOS/VoiceCatiOS.xcodeproj/project.pbxproj
Normal file
612
clients/apple/iOS/VoiceCatiOS.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,612 @@
|
|||||||
|
// !$*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 */; };
|
||||||
|
BBBB00000000000000000F02 /* IOSVoiceProcessingEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000F01 /* IOSVoiceProcessingEngine.swift */; };
|
||||||
|
BBBB00000000000000000048 /* VoiceCatCore in Frameworks */ = {isa = PBXBuildFile; productRef = BBBB0000000000000000004A /* VoiceCatCore */; };
|
||||||
|
CCCC00000000000000000010 /* BroadcastAudioPump.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCCC00000000000000000002 /* BroadcastAudioPump.swift */; };
|
||||||
|
CCCC00000000000000000011 /* BroadcastAudioRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCCC00000000000000000001 /* BroadcastAudioRing.swift */; };
|
||||||
|
CCCC00000000000000000012 /* SampleHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCCC00000000000000000003 /* SampleHandler.swift */; };
|
||||||
|
CCCC00000000000000000013 /* BroadcastAudioRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCCC00000000000000000001 /* BroadcastAudioRing.swift */; };
|
||||||
|
CCCC00000000000000000014 /* VoiceCatBroadcast.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = CCCC00000000000000000006 /* VoiceCatBroadcast.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
CCCC00000000000000000036 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = BBBB00000000000000000001 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = CCCC00000000000000000030 /* VoiceCatBroadcast */;
|
||||||
|
remoteInfo = VoiceCatBroadcast;
|
||||||
|
};
|
||||||
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
CCCC00000000000000000035 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = CCCC00000000000000000030 /* VoiceCatBroadcast */;
|
||||||
|
targetProxy = CCCC00000000000000000036 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
BBBB00000000000000000012 /* VoiceCatiOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VoiceCatiOS.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
BBBB00000000000000000015 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000016 /* VoiceCatiOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = VoiceCatiOS.entitlements; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000017 /* VoiceCatiOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceCatiOSApp.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000018 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000019 /* SessionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionState.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB0000000000000000001A /* AudioSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionManager.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB0000000000000000001B /* ServerListStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListStore.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB0000000000000000001C /* SavedServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedServer.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB0000000000000000001E /* ServerListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB0000000000000000001F /* AddServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddServerView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000020 /* ServerIdentityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerIdentityView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000021 /* PasswordPromptView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordPromptView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000022 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000023 /* ChannelTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelTreeView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000024 /* UserListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000025 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000060 /* ChannelBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelBrowserView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000061 /* ChannelDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelDetailView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000062 /* UserRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRow.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000027 /* VoiceControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceControlsView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000028 /* PerUserTuningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerUserTuningView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000029 /* ChannelEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelEditView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB0000000000000000002A /* BanUserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BanUserView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB0000000000000000002B /* MoveUserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveUserView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB0000000000000000002C /* PermissionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB0000000000000000002D /* AccountsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB0000000000000000002E /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB0000000000000000002F /* IOSAudioRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSAudioRouter.swift; sourceTree = "<group>"; };
|
||||||
|
BBBB00000000000000000F01 /* IOSVoiceProcessingEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSVoiceProcessingEngine.swift; sourceTree = "<group>"; };
|
||||||
|
CCCC00000000000000000001 /* BroadcastAudioRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastAudioRing.swift; sourceTree = "<group>"; };
|
||||||
|
CCCC00000000000000000002 /* BroadcastAudioPump.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastAudioPump.swift; sourceTree = "<group>"; };
|
||||||
|
CCCC00000000000000000003 /* SampleHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleHandler.swift; sourceTree = "<group>"; };
|
||||||
|
CCCC00000000000000000004 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
CCCC00000000000000000005 /* VoiceCatBroadcast.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = VoiceCatBroadcast.entitlements; sourceTree = "<group>"; };
|
||||||
|
CCCC00000000000000000006 /* VoiceCatBroadcast.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = VoiceCatBroadcast.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
|
CCCC00000000000000000037 /* Embed Foundation Extensions */ = {
|
||||||
|
isa = PBXCopyFilesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
dstPath = "";
|
||||||
|
dstSubfolderSpec = 13;
|
||||||
|
files = (
|
||||||
|
CCCC00000000000000000014 /* VoiceCatBroadcast.appex in Embed Foundation Extensions */,
|
||||||
|
);
|
||||||
|
name = "Embed Foundation Extensions";
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
BBBB00000000000000000011 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
BBBB00000000000000000048 /* VoiceCatCore in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
BBBB00000000000000000002 = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
BBBB00000000000000000003 /* VoiceCatiOS */,
|
||||||
|
CCCC00000000000000000021 /* VoiceCatBroadcast */,
|
||||||
|
CCCC00000000000000000020 /* Shared */,
|
||||||
|
BBBB00000000000000000007 /* Products */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
BBBB00000000000000000003 /* VoiceCatiOS */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
BBBB00000000000000000015 /* Info.plist */,
|
||||||
|
BBBB00000000000000000016 /* VoiceCatiOS.entitlements */,
|
||||||
|
BBBB00000000000000000017 /* VoiceCatiOSApp.swift */,
|
||||||
|
BBBB00000000000000000018 /* AppState.swift */,
|
||||||
|
BBBB00000000000000000019 /* SessionState.swift */,
|
||||||
|
BBBB0000000000000000001A /* AudioSessionManager.swift */,
|
||||||
|
BBBB0000000000000000002F /* IOSAudioRouter.swift */,
|
||||||
|
BBBB00000000000000000F01 /* IOSVoiceProcessingEngine.swift */,
|
||||||
|
BBBB0000000000000000001B /* ServerListStore.swift */,
|
||||||
|
BBBB0000000000000000001C /* SavedServer.swift */,
|
||||||
|
CCCC00000000000000000002 /* BroadcastAudioPump.swift */,
|
||||||
|
BBBB00000000000000000006 /* Views */,
|
||||||
|
);
|
||||||
|
path = VoiceCatiOS;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
CCCC00000000000000000020 /* Shared */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
CCCC00000000000000000001 /* BroadcastAudioRing.swift */,
|
||||||
|
);
|
||||||
|
path = Shared;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
CCCC00000000000000000021 /* VoiceCatBroadcast */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
CCCC00000000000000000003 /* SampleHandler.swift */,
|
||||||
|
CCCC00000000000000000004 /* Info.plist */,
|
||||||
|
CCCC00000000000000000005 /* VoiceCatBroadcast.entitlements */,
|
||||||
|
);
|
||||||
|
path = VoiceCatBroadcast;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
BBBB00000000000000000006 /* Views */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
BBBB0000000000000000001E /* ServerListView.swift */,
|
||||||
|
BBBB0000000000000000001F /* AddServerView.swift */,
|
||||||
|
BBBB00000000000000000020 /* ServerIdentityView.swift */,
|
||||||
|
BBBB00000000000000000021 /* PasswordPromptView.swift */,
|
||||||
|
BBBB00000000000000000022 /* MainView.swift */,
|
||||||
|
BBBB00000000000000000023 /* ChannelTreeView.swift */,
|
||||||
|
BBBB00000000000000000024 /* UserListView.swift */,
|
||||||
|
BBBB00000000000000000025 /* ChatView.swift */,
|
||||||
|
BBBB00000000000000000060 /* ChannelBrowserView.swift */,
|
||||||
|
BBBB00000000000000000061 /* ChannelDetailView.swift */,
|
||||||
|
BBBB00000000000000000062 /* UserRow.swift */,
|
||||||
|
BBBB00000000000000000027 /* VoiceControlsView.swift */,
|
||||||
|
BBBB00000000000000000028 /* PerUserTuningView.swift */,
|
||||||
|
BBBB00000000000000000029 /* ChannelEditView.swift */,
|
||||||
|
BBBB0000000000000000002A /* BanUserView.swift */,
|
||||||
|
BBBB0000000000000000002B /* MoveUserView.swift */,
|
||||||
|
BBBB0000000000000000002C /* PermissionsView.swift */,
|
||||||
|
BBBB0000000000000000002D /* AccountsView.swift */,
|
||||||
|
BBBB0000000000000000002E /* SettingsView.swift */,
|
||||||
|
);
|
||||||
|
path = Views;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
BBBB00000000000000000007 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
BBBB00000000000000000012 /* VoiceCatiOS.app */,
|
||||||
|
CCCC00000000000000000006 /* VoiceCatBroadcast.appex */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
BBBB00000000000000000008 /* VoiceCatiOS */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = BBBB0000000000000000000A /* Build configuration list for PBXNativeTarget "VoiceCatiOS" */;
|
||||||
|
buildPhases = (
|
||||||
|
BBBB0000000000000000000F /* Sources */,
|
||||||
|
BBBB00000000000000000010 /* Resources */,
|
||||||
|
BBBB00000000000000000011 /* Frameworks */,
|
||||||
|
CCCC00000000000000000037 /* Embed Foundation Extensions */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
CCCC00000000000000000035 /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
name = VoiceCatiOS;
|
||||||
|
packageProductDependencies = (
|
||||||
|
BBBB0000000000000000004A /* VoiceCatCore */,
|
||||||
|
);
|
||||||
|
productName = VoiceCatiOS;
|
||||||
|
productReference = BBBB00000000000000000012 /* VoiceCatiOS.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
CCCC00000000000000000030 /* VoiceCatBroadcast */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = CCCC00000000000000000032 /* Build configuration list for PBXNativeTarget "VoiceCatBroadcast" */;
|
||||||
|
buildPhases = (
|
||||||
|
CCCC00000000000000000031 /* Sources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = VoiceCatBroadcast;
|
||||||
|
productName = VoiceCatBroadcast;
|
||||||
|
productReference = CCCC00000000000000000006 /* VoiceCatBroadcast.appex */;
|
||||||
|
productType = "com.apple.product-type.app-extension";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
BBBB00000000000000000001 /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = 1;
|
||||||
|
LastSwiftUpdateCheck = 1500;
|
||||||
|
LastUpgradeCheck = 1500;
|
||||||
|
};
|
||||||
|
buildConfigurationList = BBBB00000000000000000009 /* Build configuration list for PBXProject "VoiceCatiOS" */;
|
||||||
|
compatibilityVersion = "Xcode 14.0";
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
en,
|
||||||
|
Base,
|
||||||
|
);
|
||||||
|
mainGroup = BBBB00000000000000000002;
|
||||||
|
packageReferences = (
|
||||||
|
BBBB00000000000000000049 /* XCLocalSwiftPackageReference "../" */,
|
||||||
|
);
|
||||||
|
productRefGroup = BBBB00000000000000000007 /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
BBBB00000000000000000008 /* VoiceCatiOS */,
|
||||||
|
CCCC00000000000000000030 /* VoiceCatBroadcast */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
BBBB00000000000000000010 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
BBBB0000000000000000000F /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
BBBB00000000000000000030 /* VoiceCatiOSApp.swift in Sources */,
|
||||||
|
BBBB00000000000000000031 /* AppState.swift in Sources */,
|
||||||
|
BBBB00000000000000000032 /* SessionState.swift in Sources */,
|
||||||
|
BBBB00000000000000000033 /* AudioSessionManager.swift in Sources */,
|
||||||
|
BBBB0000000000000000004B /* IOSAudioRouter.swift in Sources */,
|
||||||
|
BBBB00000000000000000F02 /* IOSVoiceProcessingEngine.swift in Sources */,
|
||||||
|
BBBB00000000000000000034 /* ServerListStore.swift in Sources */,
|
||||||
|
BBBB00000000000000000035 /* SavedServer.swift in Sources */,
|
||||||
|
CCCC00000000000000000010 /* BroadcastAudioPump.swift in Sources */,
|
||||||
|
CCCC00000000000000000011 /* BroadcastAudioRing.swift in Sources */,
|
||||||
|
BBBB00000000000000000037 /* ServerListView.swift in Sources */,
|
||||||
|
BBBB00000000000000000038 /* AddServerView.swift in Sources */,
|
||||||
|
BBBB00000000000000000039 /* ServerIdentityView.swift in Sources */,
|
||||||
|
BBBB0000000000000000003A /* PasswordPromptView.swift in Sources */,
|
||||||
|
BBBB0000000000000000003B /* MainView.swift in Sources */,
|
||||||
|
BBBB0000000000000000003C /* ChannelTreeView.swift in Sources */,
|
||||||
|
BBBB0000000000000000003D /* UserListView.swift in Sources */,
|
||||||
|
BBBB0000000000000000003E /* ChatView.swift in Sources */,
|
||||||
|
BBBB00000000000000000070 /* ChannelBrowserView.swift in Sources */,
|
||||||
|
BBBB00000000000000000071 /* ChannelDetailView.swift in Sources */,
|
||||||
|
BBBB00000000000000000072 /* UserRow.swift in Sources */,
|
||||||
|
BBBB00000000000000000040 /* VoiceControlsView.swift in Sources */,
|
||||||
|
BBBB00000000000000000041 /* PerUserTuningView.swift in Sources */,
|
||||||
|
BBBB00000000000000000042 /* ChannelEditView.swift in Sources */,
|
||||||
|
BBBB00000000000000000043 /* BanUserView.swift in Sources */,
|
||||||
|
BBBB00000000000000000044 /* MoveUserView.swift in Sources */,
|
||||||
|
BBBB00000000000000000045 /* PermissionsView.swift in Sources */,
|
||||||
|
BBBB00000000000000000046 /* AccountsView.swift in Sources */,
|
||||||
|
BBBB00000000000000000047 /* SettingsView.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
CCCC00000000000000000031 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
CCCC00000000000000000012 /* SampleHandler.swift in Sources */,
|
||||||
|
CCCC00000000000000000013 /* BroadcastAudioRing.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
BBBB0000000000000000000B /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"DEBUG=1",
|
||||||
|
"$(inherited)",
|
||||||
|
);
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
BBBB0000000000000000000C /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
BBBB0000000000000000000D /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = VoiceCatiOS/VoiceCatiOS.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = FJV8L966W4;
|
||||||
|
INFOPLIST_FILE = VoiceCatiOS/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = VoiceCat;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 0.0.1;
|
||||||
|
OTHER_LDFLAGS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"-lc++",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = 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.nickname?.isEmpty == false) ? server.nickname! : "iOS User"
|
||||||
|
client.authenticateGuest(nick)
|
||||||
|
case .password:
|
||||||
|
let savedPw = ServerListStore.shared.loadPassword(tag: server.keychainTag)
|
||||||
|
if let pw = savedPw, !pw.isEmpty {
|
||||||
|
client.authenticateUser(server.savedUsername, password: pw)
|
||||||
|
} else {
|
||||||
|
showPasswordPrompt = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func disconnect() {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
189
clients/apple/iOS/VoiceCatiOS/AudioSessionManager.swift
Normal file
189
clients/apple/iOS/VoiceCatiOS/AudioSessionManager.swift
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
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?
|
||||||
|
|
||||||
|
/// Set by `SessionState`. Invoked by `IOSAudioRouter` after an audio-config change so the
|
||||||
|
/// voice path (native VPIO vs the core's miniaudio path) can be restarted to match the new
|
||||||
|
/// preset/route when the mic is active. No-op when not in voice. See
|
||||||
|
/// `SessionState.reconcileVoicePath()` and `IOSVoiceProcessingEngine`.
|
||||||
|
var reconcileVoicePath: (() -> Void)?
|
||||||
|
|
||||||
|
/// 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, pick the right output once the session is live: defer to
|
||||||
|
// a connected A2DP/wired/AirPlay route, but fall back to the loud built-in speaker (not
|
||||||
|
// the quiet earpiece) when nothing external is connected. See applyA2dpSpeakerFallback().
|
||||||
|
IOSAudioRouter.shared.applyA2dpSpeakerFallback()
|
||||||
|
let route = AVAudioSession.sharedInstance().currentRoute
|
||||||
|
let outputNames = route.outputs.map { $0.portName }.joined(separator: ", ")
|
||||||
|
let inputNames = route.inputs.map { $0.portName }.joined(separator: ", ")
|
||||||
|
logger.info("session activated — outputs: [\(outputNames)], inputs: [\(inputNames)]")
|
||||||
|
logSessionState("after activate")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deactivate the AVAudioSession. Call ONLY when disconnecting from the server — not
|
||||||
|
/// when leaving voice (the user may still want to hear remote audio).
|
||||||
|
func deactivateSession() {
|
||||||
|
guard isSessionActive else {
|
||||||
|
logger.debug("deactivateSession — not active, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try? AVAudioSession.sharedInstance().setActive(false,
|
||||||
|
options: .notifyOthersOnDeactivation)
|
||||||
|
isSessionActive = false
|
||||||
|
logger.info("session deactivated")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log the full AVAudioSession state — category, mode, options, and active route.
|
||||||
|
/// Useful for diagnosing routing issues, e.g. confirming the session stays
|
||||||
|
/// `PlayAndRecord` with `allowBluetoothA2DP` and keeps the A2DP output route even
|
||||||
|
/// after the mic engine starts.
|
||||||
|
func logSessionState(_ when: String) {
|
||||||
|
let s = AVAudioSession.sharedInstance()
|
||||||
|
var opts: [String] = []
|
||||||
|
let o = s.categoryOptions
|
||||||
|
if o.contains(.mixWithOthers) { opts.append("mixWithOthers") }
|
||||||
|
if o.contains(.duckOthers) { opts.append("duckOthers") }
|
||||||
|
if o.contains(.allowBluetoothHFP) { opts.append("allowBluetoothHFP") }
|
||||||
|
if o.contains(.allowBluetoothA2DP) { opts.append("allowBluetoothA2DP") }
|
||||||
|
if o.contains(.allowAirPlay) { opts.append("allowAirPlay") }
|
||||||
|
if o.contains(.defaultToSpeaker) { opts.append("defaultToSpeaker") }
|
||||||
|
let outs = s.currentRoute.outputs.map { "\($0.portName)[\($0.portType.rawValue)]" }
|
||||||
|
.joined(separator: ", ")
|
||||||
|
let ins = s.currentRoute.inputs.map { "\($0.portName)[\($0.portType.rawValue)]" }
|
||||||
|
.joined(separator: ", ")
|
||||||
|
logger.info("""
|
||||||
|
[SESSION @ \(when, privacy: .public)] category=\(s.category.rawValue, privacy: .public) \
|
||||||
|
mode=\(s.mode.rawValue, privacy: .public) options=[\(opts.joined(separator: ","), privacy: .public)] \
|
||||||
|
inputs=[\(ins, privacy: .public)] outputs=[\(outs, privacy: .public)] \
|
||||||
|
inputCh=\(s.inputNumberOfChannels) outputCh=\(s.outputNumberOfChannels)
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleInterruption(_ notification: Notification) {
|
||||||
|
guard let info = notification.userInfo,
|
||||||
|
let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||||
|
let type = AVAudioSession.InterruptionType(rawValue: typeValue)
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
switch type {
|
||||||
|
case .began:
|
||||||
|
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()
|
||||||
|
// Re-evaluate the A2DP-mode speaker fallback: a Bluetooth unplug should drop us onto
|
||||||
|
// the loud speaker (not the earpiece), and a replug should hand output back to A2DP.
|
||||||
|
if isSessionActive {
|
||||||
|
IOSAudioRouter.shared.applyA2dpSpeakerFallback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() } }
|
||||||
|
}
|
||||||
736
clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift
Normal file
736
clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift
Normal file
@@ -0,0 +1,736 @@
|
|||||||
|
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
|
||||||
|
/// User-requested speaker fallback: when on, route to the built-in speaker instead of the
|
||||||
|
/// earpiece (receiver) when no headphones/Bluetooth are connected. Orthogonal to the
|
||||||
|
/// bluetooth mode and presets. Default off — current behavior is unchanged for existing users.
|
||||||
|
@Published var forceSpeaker: Bool = false
|
||||||
|
@Published var micMode: MicMode = .standard
|
||||||
|
@Published var captureChannels: CaptureChannels = .mono
|
||||||
|
@Published var selectedInputPortId: String?
|
||||||
|
@Published var selectedDataSourceId: String?
|
||||||
|
@Published var selectedPolarPattern: String?
|
||||||
|
@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"
|
||||||
|
private let kForceSpeaker = "cat.voice.audio.forceSpeaker"
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the current configuration should use the native iOS Voice-Processing path (VPIO:
|
||||||
|
/// real AEC/NS/AGC via `IOSVoiceProcessingEngine`). True exactly when `applyConfiguration`
|
||||||
|
/// selects the `.voiceChat` AVAudioSession mode — mono + standard processing + not A2DP
|
||||||
|
/// (A2DP / stereo / raw modes can't use VPIO, so they keep the core's miniaudio path).
|
||||||
|
var currentConfigUsesVoiceProcessing: Bool {
|
||||||
|
captureChannels == .mono && micMode == .standard && bluetoothMode != .builtInMicBtA2dp
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 for the speaker preset and, when the user enables the
|
||||||
|
// `forceSpeaker` toggle, for the HFP preset too — it forces output to the built-in
|
||||||
|
// speaker instead of the receiver while still yielding to connected BT/wired output.
|
||||||
|
// It also actively breaks A2DP routing in .playAndRecord, so it must NEVER be set for
|
||||||
|
// the A2DP preset (forceSpeaker is intentionally ignored there).
|
||||||
|
// .allowAirPlay is added to the Bluetooth presets so AirPlay output also works.
|
||||||
|
var options: AVAudioSession.CategoryOptions = [.mixWithOthers]
|
||||||
|
switch bluetoothMode {
|
||||||
|
case .btHfpVoice:
|
||||||
|
// Voice Chat: allow BOTH HFP and A2DP, let iOS pick the right profile for the
|
||||||
|
// connected device. HFP and A2DP must NOT be made mutually exclusive (HFP-only)
|
||||||
|
// — that blocks A2DP headphones from receiving audio. HFP is preferred (the system
|
||||||
|
// uses it when a two-way mic path is needed); A2DP stays available for output-only.
|
||||||
|
options.insert(.allowBluetoothHFP)
|
||||||
|
options.insert(.allowBluetoothA2DP)
|
||||||
|
options.insert(.allowAirPlay)
|
||||||
|
case .builtInMicBtA2dp:
|
||||||
|
// A2DP output only (no HFP). With HFP disabled the Bluetooth device can only be
|
||||||
|
// an OUTPUT (A2DP), so the system routes the mic to the built-in mic — exactly
|
||||||
|
// what we want for "built-in mic + A2DP output", in either mono OR stereo.
|
||||||
|
options.insert(.allowBluetoothA2DP)
|
||||||
|
options.insert(.allowAirPlay)
|
||||||
|
case .builtInMicSpeaker:
|
||||||
|
// Built-in mic + speaker/wired output only. Prefer speaker over the receiver.
|
||||||
|
options.insert(.defaultToSpeaker)
|
||||||
|
}
|
||||||
|
|
||||||
|
// User-requested speaker fallback: route to the built-in speaker instead of the
|
||||||
|
// receiver when no headphones/BT are connected. Skipped for the A2DP mode because
|
||||||
|
// .defaultToSpeaker breaks A2DP routing (see note above). Redundant for
|
||||||
|
// builtInMicSpeaker, which already sets it.
|
||||||
|
if forceSpeaker && bluetoothMode != .builtInMicBtA2dp {
|
||||||
|
options.insert(.defaultToSpeaker)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Set category + mode, chosen per scenario:
|
||||||
|
// - Stereo capture: .default — .voiceChat (the AEC/VPIO path) forces MONO, so stereo
|
||||||
|
// is only possible in a non-VPIO mode. .default supports multi-capsule stereo AND
|
||||||
|
// keeps the A2DP output route alive.
|
||||||
|
// - Mono raw/studio: .measurement — all system processing off.
|
||||||
|
// - Mono + A2DP output: .videoRecording — keeps A2DP output without VPIO (no AEC).
|
||||||
|
// - Mono standard (HFP or speaker): .voiceChat — hardware AEC/AGC/HPF.
|
||||||
|
let mode: AVAudioSession.Mode
|
||||||
|
if captureChannels == .stereo {
|
||||||
|
mode = .default
|
||||||
|
} else if micMode == .raw {
|
||||||
|
mode = .measurement
|
||||||
|
} else if bluetoothMode == .builtInMicBtA2dp {
|
||||||
|
mode = .videoRecording
|
||||||
|
} else {
|
||||||
|
mode = .voiceChat
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try session.setCategory(.playAndRecord, mode: mode, options: options)
|
||||||
|
logger.info("setCategory ok — mode=\(self.modeLabel(mode)), bt=\(self.bluetoothMode.rawValue), ch=\(self.captureChannels.rawValue), options=\(self.optionsLabel(options))")
|
||||||
|
} catch {
|
||||||
|
logger.error("setCategory failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Input & mic-capsule configuration.
|
||||||
|
if captureChannels == .stereo {
|
||||||
|
// 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)
|
||||||
|
forceSpeaker = UserDefaults.standard.bool(forKey: kForceSpeaker)
|
||||||
|
}
|
||||||
|
/// Persist current selections to UserDefaults.
|
||||||
|
func savePreferences() {
|
||||||
|
UserDefaults.standard.set(bluetoothMode.rawValue, forKey: kBluetoothMode)
|
||||||
|
UserDefaults.standard.set(micMode.rawValue, forKey: kMicMode)
|
||||||
|
UserDefaults.standard.set(captureChannels.rawValue, forKey: kCaptureChannels)
|
||||||
|
UserDefaults.standard.set(selectedInputPortId, forKey: kInputPortId)
|
||||||
|
UserDefaults.standard.set(selectedDataSourceId, forKey: kDataSourceId)
|
||||||
|
UserDefaults.standard.set(selectedPolarPattern, forKey: kPolarPattern)
|
||||||
|
UserDefaults.standard.set(forceSpeaker, forKey: kForceSpeaker)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
// VPIO class or route may have changed — restart the voice path if mic is active.
|
||||||
|
AudioSessionManager.shared.reconcileVoicePath?()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setForceSpeaker(_ on: Bool) {
|
||||||
|
forceSpeaker = on
|
||||||
|
savePreferences()
|
||||||
|
applyConfiguration()
|
||||||
|
refreshRoutes()
|
||||||
|
// Route changed under a possibly-running VPIO engine — reconcile if mic is active.
|
||||||
|
AudioSessionManager.shared.reconcileVoicePath?()
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectMicMode(_ mode: MicMode) {
|
||||||
|
micMode = mode
|
||||||
|
savePreferences()
|
||||||
|
applyConfiguration()
|
||||||
|
updateWarnings()
|
||||||
|
// Standard↔Raw flips the VPIO class — reconcile if mic is active.
|
||||||
|
AudioSessionManager.shared.reconcileVoicePath?()
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
// Mono↔stereo flips the VPIO class (stereo can't use VPIO) — reconcile if mic is active.
|
||||||
|
AudioSessionManager.shared.reconcileVoicePath?()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// Voice Chat is a phone-call experience — default to the loud speaker so output doesn't
|
||||||
|
// land on the quiet earpiece (receiver). Still yields to connected BT/wired output.
|
||||||
|
if preset == .voiceChat { forceSpeaker = true }
|
||||||
|
|
||||||
|
if preset.usesBuiltInMic {
|
||||||
|
// 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()
|
||||||
|
// The preset may have flipped the VPIO class (and/or the route) — restart the voice path
|
||||||
|
// if the mic is active so AEC/NS engage (or disengage) to match the new preset.
|
||||||
|
AudioSessionManager.shared.reconcileVoicePath?()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Route fallback for the A2DP-output presets (Stereo Mic / Studio / BT Headphones + Mono
|
||||||
|
/// Mic, all `.builtInMicBtA2dp`). These presets deliberately omit `.defaultToSpeaker` (it
|
||||||
|
/// breaks A2DP routing) and skip the `forceSpeaker` override, so when NO external output
|
||||||
|
/// (Bluetooth A2DP / wired / AirPlay) is connected `.playAndRecord` pins output to the quiet
|
||||||
|
/// built-in receiver (earpiece). This routes to the loud built-in speaker instead via a
|
||||||
|
/// post-activation `overrideOutputAudioPort(.speaker)` — the documented "A2DP if connected,
|
||||||
|
/// else speaker" behavior. When an external output IS present we clear the override so A2DP /
|
||||||
|
/// headphones / AirPlay are honored. No-op outside `.builtInMicBtA2dp` mode (other modes pick
|
||||||
|
/// their route via category options). Must be called AFTER the session is active.
|
||||||
|
func applyA2dpSpeakerFallback() {
|
||||||
|
guard bluetoothMode == .builtInMicBtA2dp else { return }
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
// Treat the built-in receiver and speaker as "internal"; anything else (A2DP, headphones,
|
||||||
|
// USB, AirPlay) is an external output we should defer to.
|
||||||
|
let hasExternalOutput = session.currentRoute.outputs.contains {
|
||||||
|
$0.portType != .builtInReceiver && $0.portType != .builtInSpeaker
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
if hasExternalOutput {
|
||||||
|
try session.overrideOutputAudioPort(.none)
|
||||||
|
logger.info("A2DP mode — external output present, clearing speaker override")
|
||||||
|
} else {
|
||||||
|
try session.overrideOutputAudioPort(.speaker)
|
||||||
|
logger.info("A2DP mode — no external output, routing to built-in speaker")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logger.error("A2DP speaker fallback failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
269
clients/apple/iOS/VoiceCatiOS/IOSVoiceProcessingEngine.swift
Normal file
269
clients/apple/iOS/VoiceCatiOS/IOSVoiceProcessingEngine.swift
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import Darwin
|
||||||
|
import os
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "IOSVoiceProcessingEngine")
|
||||||
|
|
||||||
|
/// In-process single-producer/single-consumer int16 PCM ring for the VPIO playback path.
|
||||||
|
///
|
||||||
|
/// producer = the core's mixer-timer thread (the `vc_set_mixed_output_sink` callback)
|
||||||
|
/// consumer = the `AVAudioSourceNode` render thread
|
||||||
|
///
|
||||||
|
/// Heap-backed (not shared memory like `BroadcastAudioRing`), but the same discipline: aligned
|
||||||
|
/// 64-bit monotonic indices with `OSMemoryBarrier` for acquire/release ordering. Both the C
|
||||||
|
/// callback and the render block are real-time — they only do index math + a memcpy here, never
|
||||||
|
/// lock or allocate.
|
||||||
|
final class PCMRing {
|
||||||
|
private let data: UnsafeMutablePointer<Int16>
|
||||||
|
private let capacity: Int
|
||||||
|
private var writeIdx: UInt64 = 0
|
||||||
|
private var readIdx: UInt64 = 0
|
||||||
|
|
||||||
|
init(capacitySamples: Int) {
|
||||||
|
capacity = capacitySamples
|
||||||
|
data = UnsafeMutablePointer<Int16>.allocate(capacity: capacitySamples)
|
||||||
|
data.initialize(repeating: 0, count: capacitySamples)
|
||||||
|
}
|
||||||
|
deinit { data.deallocate() }
|
||||||
|
|
||||||
|
/// Producer: append `count` interleaved int16 samples. Drops the chunk if it doesn't fit
|
||||||
|
/// (better to skip than tear). Single producer only (the core mixer-timer thread).
|
||||||
|
func write(_ src: UnsafePointer<Int16>, count: Int) {
|
||||||
|
guard count > 0, count <= capacity else { return }
|
||||||
|
let w = writeIdx
|
||||||
|
OSMemoryBarrier()
|
||||||
|
let r = readIdx
|
||||||
|
if capacity - Int(w &- r) < count { return } // full: drop
|
||||||
|
var idx = Int(w % UInt64(capacity))
|
||||||
|
var off = 0
|
||||||
|
var rem = count
|
||||||
|
while rem > 0 {
|
||||||
|
let chunk = min(rem, capacity - idx)
|
||||||
|
(data + idx).update(from: src + off, count: chunk)
|
||||||
|
idx = (idx + chunk) % capacity
|
||||||
|
off += chunk
|
||||||
|
rem -= chunk
|
||||||
|
}
|
||||||
|
OSMemoryBarrier()
|
||||||
|
writeIdx = w &+ UInt64(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consumer: read up to `count` interleaved int16 samples into `dst`; returns the number
|
||||||
|
/// read (the rest is the caller's to silence-fill). Single consumer only (render thread).
|
||||||
|
func read(into dst: UnsafeMutablePointer<Int16>, count: Int) -> Int {
|
||||||
|
let r = readIdx
|
||||||
|
OSMemoryBarrier()
|
||||||
|
let w = writeIdx
|
||||||
|
let available = Int(w &- r)
|
||||||
|
if available <= 0 { return 0 }
|
||||||
|
let n = min(available, count)
|
||||||
|
var idx = Int(r % UInt64(capacity))
|
||||||
|
var off = 0
|
||||||
|
var rem = n
|
||||||
|
while rem > 0 {
|
||||||
|
let chunk = min(rem, capacity - idx)
|
||||||
|
(dst + off).update(from: data + idx, count: chunk)
|
||||||
|
idx = (idx + chunk) % capacity
|
||||||
|
off += chunk
|
||||||
|
rem -= chunk
|
||||||
|
}
|
||||||
|
OSMemoryBarrier()
|
||||||
|
readIdx = r &+ UInt64(n)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discard everything buffered — call before (re)starting so stale pre-roll isn't played.
|
||||||
|
func reset() { OSMemoryBarrier(); readIdx = writeIdx }
|
||||||
|
|
||||||
|
/// Diagnostics: monotonic total samples written / read since the ring was created. The
|
||||||
|
/// indices are already cumulative, so these are free. Only read them when both threads are
|
||||||
|
/// quiesced (e.g. at teardown after the engine + mixer sink are stopped) — they are not
|
||||||
|
/// synchronized for live cross-thread reads. Lets us tell "core never delivered PCM" (Bug 1
|
||||||
|
/// core path) apart from "PCM arrived but produced no sound" (AVAudioEngine output graph).
|
||||||
|
var debugTotalWritten: UInt64 { writeIdx }
|
||||||
|
var debugTotalRead: UInt64 { readIdx }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Native iOS voice-processing audio path (docs/voice.md §8 "iOS voice processing").
|
||||||
|
///
|
||||||
|
/// Real iOS echo cancellation / noise suppression / AGC come ONLY from Apple's Voice-Processing
|
||||||
|
/// I/O unit (VPIO), which `AVAudioEngine.setVoiceProcessingEnabled(true)` enables. For VPIO to
|
||||||
|
/// cancel echo it must own BOTH the mic capture and the remote-audio playback (it subtracts the
|
||||||
|
/// played-back signal from the mic), so on the AEC presets this engine drives both directions and
|
||||||
|
/// the core runs in external mode (no hardware devices):
|
||||||
|
/// - **Mic → core:** a tap on the VPIO input node → 48 kHz int16 → `client.feedPcm(micStreamId)`.
|
||||||
|
/// - **core → speaker:** the core's mixed-output sink fills `ring`; an `AVAudioSourceNode` pulls
|
||||||
|
/// from it and renders through the VPIO output, giving AEC its reference signal.
|
||||||
|
///
|
||||||
|
/// Lifecycle is driven by `SessionState` join/leave. The Stereo Mic / Studio / A2DP presets keep
|
||||||
|
/// the core's miniaudio path instead (they want raw / stereo / no-AEC routing VPIO can't provide).
|
||||||
|
@MainActor
|
||||||
|
final class IOSVoiceProcessingEngine {
|
||||||
|
static let shared = IOSVoiceProcessingEngine()
|
||||||
|
|
||||||
|
private(set) var isRunning = false
|
||||||
|
|
||||||
|
private let engine = AVAudioEngine()
|
||||||
|
private var sourceNode: AVAudioSourceNode?
|
||||||
|
private weak var client: VoiceCatClient?
|
||||||
|
private var micStreamId: UInt32 = 0
|
||||||
|
|
||||||
|
// 48 kHz stereo Float32 (deinterleaved) — the format the source node renders and the engine
|
||||||
|
// processes in. The core delivers 48 kHz stereo int16 via the mixed-output sink.
|
||||||
|
private let outFormat = AVAudioFormat(
|
||||||
|
commonFormat: .pcmFormatFloat32, sampleRate: 48000, channels: 2, interleaved: false)!
|
||||||
|
|
||||||
|
// Playback ring (mixed remote audio): ~0.5 s of 48 kHz stereo int16. Filled by the core's
|
||||||
|
// mixer-timer thread, drained by the source-node render thread.
|
||||||
|
private let ring = PCMRing(capacitySamples: 48000 * 2 / 2)
|
||||||
|
// Render-thread scratch for deinterleaving — pre-allocated so the render block never allocates.
|
||||||
|
private let renderScratchFrames = 8192
|
||||||
|
private let renderScratch: UnsafeMutablePointer<Int16>
|
||||||
|
|
||||||
|
// Mic-feed converter (input-node format → 48 kHz int16) and its target buffer. Owned here so
|
||||||
|
// the (background) tap block reuses them instead of allocating per callback.
|
||||||
|
private var micConverter: AVAudioConverter?
|
||||||
|
private var micTargetFormat: AVAudioFormat?
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
renderScratch = UnsafeMutablePointer<Int16>.allocate(capacity: renderScratchFrames * 2)
|
||||||
|
renderScratch.initialize(repeating: 0, count: renderScratchFrames * 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the VPIO engine for an active mic stream. The caller must have already enabled
|
||||||
|
/// external playback on the core (`client.setExternalPlayback(true)` + `audioRestart()`) and
|
||||||
|
/// started the MIC stream with `externalFeed: true`.
|
||||||
|
func start(client: VoiceCatClient, micStreamId: UInt32, captureChannels: UInt32) {
|
||||||
|
guard !isRunning else { return }
|
||||||
|
self.client = client
|
||||||
|
self.micStreamId = micStreamId
|
||||||
|
ring.reset()
|
||||||
|
|
||||||
|
// Enable the voice-processing I/O unit (AEC/NS/AGC) on the shared input+output unit.
|
||||||
|
do {
|
||||||
|
try engine.inputNode.setVoiceProcessingEnabled(true)
|
||||||
|
} catch {
|
||||||
|
logger.error("setVoiceProcessingEnabled failed: \(error.localizedDescription) — AEC unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Playback: source node pulls mixed PCM from the ring through the VPIO output. ──
|
||||||
|
let ring = self.ring
|
||||||
|
let scratch = self.renderScratch
|
||||||
|
let scratchFrames = self.renderScratchFrames
|
||||||
|
let src = AVAudioSourceNode(format: outFormat) { _, _, frameCount, ablPtr in
|
||||||
|
let frames = Int(frameCount)
|
||||||
|
let abl = UnsafeMutableAudioBufferListPointer(ablPtr)
|
||||||
|
let n = min(frames, scratchFrames)
|
||||||
|
let got = ring.read(into: scratch, count: n * 2) / 2 // interleaved stereo → frames
|
||||||
|
// Deinterleave int16 → Float32 per channel; silence-fill any underrun tail.
|
||||||
|
let scale: Float = 1.0 / 32768.0
|
||||||
|
for ch in 0..<abl.count {
|
||||||
|
guard let base = abl[ch].mData?.assumingMemoryBound(to: Float.self) else { continue }
|
||||||
|
for i in 0..<frames {
|
||||||
|
if i < got {
|
||||||
|
let s = scratch[i * 2 + min(ch, 1)]
|
||||||
|
base[i] = Float(s) * scale
|
||||||
|
} else {
|
||||||
|
base[i] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return noErr
|
||||||
|
}
|
||||||
|
sourceNode = src
|
||||||
|
engine.attach(src)
|
||||||
|
engine.connect(src, to: engine.mainMixerNode, format: outFormat)
|
||||||
|
|
||||||
|
// ── Mic: tap the VPIO input node, convert to 48 kHz int16, feed the core. ──
|
||||||
|
let inFormat = engine.inputNode.outputFormat(forBus: 0)
|
||||||
|
let targetCh = max(1, min(2, captureChannels))
|
||||||
|
let target = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: 48000,
|
||||||
|
channels: AVAudioChannelCount(targetCh), interleaved: true)
|
||||||
|
micTargetFormat = target
|
||||||
|
micConverter = (target != nil && inFormat.sampleRate > 0)
|
||||||
|
? AVAudioConverter(from: inFormat, to: target!) : nil
|
||||||
|
if micConverter == nil {
|
||||||
|
logger.error("mic converter unavailable (in=\(inFormat)) — mic will not transmit")
|
||||||
|
}
|
||||||
|
|
||||||
|
let c = client
|
||||||
|
let sid = micStreamId
|
||||||
|
let converter = micConverter
|
||||||
|
let tgt = micTargetFormat
|
||||||
|
engine.inputNode.installTap(onBus: 0, bufferSize: 960, format: inFormat) { buffer, _ in
|
||||||
|
guard let converter, let tgt else { return }
|
||||||
|
// Convert this tap buffer to 48 kHz int16. Output capacity scaled for any upsample.
|
||||||
|
let ratio = tgt.sampleRate / buffer.format.sampleRate
|
||||||
|
let outCap = AVAudioFrameCount(Double(buffer.frameLength) * ratio + 16)
|
||||||
|
guard let outBuf = AVAudioPCMBuffer(pcmFormat: tgt, frameCapacity: outCap) else { return }
|
||||||
|
var fed = false
|
||||||
|
let status = converter.convert(to: outBuf, error: nil) { _, outStatus in
|
||||||
|
if fed { outStatus.pointee = .noDataNow; return nil }
|
||||||
|
fed = true
|
||||||
|
outStatus.pointee = .haveData
|
||||||
|
return buffer
|
||||||
|
}
|
||||||
|
guard status != .error, outBuf.frameLength > 0,
|
||||||
|
let chData = outBuf.int16ChannelData else { return }
|
||||||
|
let spc = Int(outBuf.frameLength)
|
||||||
|
// int16 interleaved → channelData[0] is the interleaved buffer for interleaved formats.
|
||||||
|
c.feedPcm(streamId: sid, pcm: chData[0], samplesPerChannel: spc, channels: targetCh)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wire the core's mixed-output sink into the ring (C function pointer, no captures). ──
|
||||||
|
let ringPtr = Unmanaged.passUnretained(self.ring).toOpaque()
|
||||||
|
client.setMixedOutputSink({ user, pcm, spc, ch, _ in
|
||||||
|
guard let user, let pcm else { return }
|
||||||
|
let ring = Unmanaged<PCMRing>.fromOpaque(user).takeUnretainedValue()
|
||||||
|
ring.write(pcm, count: spc * Int(ch))
|
||||||
|
}, user: ringPtr)
|
||||||
|
|
||||||
|
engine.prepare()
|
||||||
|
do {
|
||||||
|
try engine.start()
|
||||||
|
isRunning = true
|
||||||
|
// Diagnostics: capture the negotiated graph formats and the live output route so a
|
||||||
|
// silent-playback report can be triaged (format/rate mismatch vs. routing vs. the
|
||||||
|
// core not delivering PCM — see the ring stats logged in teardown()).
|
||||||
|
let outFmt = engine.outputNode.outputFormat(forBus: 0)
|
||||||
|
let mixFmt = engine.mainMixerNode.outputFormat(forBus: 0)
|
||||||
|
let route = AVAudioSession.sharedInstance().currentRoute.outputs
|
||||||
|
.map { "\($0.portName)[\($0.portType.rawValue)]" }.joined(separator: ", ")
|
||||||
|
logger.info("""
|
||||||
|
VPIO engine started — inFormat=\(inFormat), captureCh=\(targetCh), \
|
||||||
|
outputNode=\(outFmt), mainMixer=\(mixFmt), outputRoute=[\(route)]
|
||||||
|
""")
|
||||||
|
} catch {
|
||||||
|
logger.error("VPIO engine start failed: \(error.localizedDescription)")
|
||||||
|
teardown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the VPIO engine. The caller is responsible for restoring the core's hardware playback
|
||||||
|
/// afterwards (`client.setExternalPlayback(false)` + `audioRestart()`).
|
||||||
|
func stop() {
|
||||||
|
guard isRunning else { return }
|
||||||
|
teardown()
|
||||||
|
logger.info("VPIO engine stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func teardown() {
|
||||||
|
client?.setMixedOutputSink(nil, user: nil)
|
||||||
|
engine.inputNode.removeTap(onBus: 0)
|
||||||
|
if engine.isRunning { engine.stop() }
|
||||||
|
// Diagnostics (threads now quiesced): how much mixed PCM the core delivered into the ring
|
||||||
|
// vs. how much the render thread consumed. written==0 ⇒ the core never delivered (Bug 1
|
||||||
|
// core/lifecycle path); written>0 with no audible output ⇒ the AVAudioEngine output graph.
|
||||||
|
logger.info("VPIO ring stats — written=\(self.ring.debugTotalWritten) read=\(self.ring.debugTotalRead) samples")
|
||||||
|
try? engine.inputNode.setVoiceProcessingEnabled(false)
|
||||||
|
if let src = sourceNode {
|
||||||
|
engine.detach(src)
|
||||||
|
sourceNode = nil
|
||||||
|
}
|
||||||
|
micConverter = nil
|
||||||
|
micTargetFormat = nil
|
||||||
|
ring.reset()
|
||||||
|
isRunning = false
|
||||||
|
}
|
||||||
|
}
|
||||||
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>
|
||||||
29
clients/apple/iOS/VoiceCatiOS/SavedServer.swift
Normal file
29
clients/apple/iOS/VoiceCatiOS/SavedServer.swift
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct SavedServer: Codable, Identifiable, Equatable {
|
||||||
|
let id: UUID
|
||||||
|
var host: String
|
||||||
|
var port: UInt16
|
||||||
|
var authMode: AuthMode
|
||||||
|
var savedUsername: String
|
||||||
|
/// Free-form display name used when connecting as a guest. Distinct from the account
|
||||||
|
/// `savedUsername`. Empty falls back to a default. Optional for backward-compatible decoding.
|
||||||
|
var nickname: String?
|
||||||
|
var keychainTag: String
|
||||||
|
|
||||||
|
enum AuthMode: String, Codable { case guest, password }
|
||||||
|
|
||||||
|
init(id: UUID = UUID(), host: String, port: UInt16,
|
||||||
|
authMode: AuthMode = .guest, savedUsername: String = "",
|
||||||
|
nickname: String? = nil, keychainTag: String = "") {
|
||||||
|
self.id = id
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.authMode = authMode
|
||||||
|
self.savedUsername = savedUsername
|
||||||
|
self.nickname = nickname
|
||||||
|
self.keychainTag = keychainTag.isEmpty ? id.uuidString : keychainTag
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayString: String { "\(host):\(port)" }
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
429
clients/apple/iOS/VoiceCatiOS/SessionState.swift
Normal file
429
clients/apple/iOS/VoiceCatiOS/SessionState.swift
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
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()
|
||||||
|
// When IOSAudioRouter changes the audio config, restart the voice path if needed so the
|
||||||
|
// native VPIO engine (AEC/NS/AGC) engages or disengages to match the new preset/route.
|
||||||
|
AudioSessionManager.shared.reconcileVoicePath = { [weak self] in self?.reconcileVoicePath() }
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// VPIO path: on the AEC presets, the native AVAudioEngine does AEC/NS/AGC and the core
|
||||||
|
// runs in external mode (no hardware mic/playback). The mic stream is started with
|
||||||
|
// externalFeed so the core skips the hardware capture device; setExternalPlayback makes
|
||||||
|
// it skip the hardware playback device and deliver the mix to IOSVoiceProcessingEngine.
|
||||||
|
//
|
||||||
|
// ORDER MATTERS: set the external-playback flag now, but defer audioRestart() until
|
||||||
|
// AFTER startStream (below) so the MIC LocalStream — which carries external_feed=true —
|
||||||
|
// already exists when ensure_audio_running() derives external_capture. Restarting before
|
||||||
|
// the stream exists makes the core reopen a hardware capture device that is never dropped
|
||||||
|
// (the announce-result restart early-returns because the engine is already running); that
|
||||||
|
// lingering miniaudio capture unit then fights the AVAudioEngine VPIO unit on the same
|
||||||
|
// .voiceChat session and silences VPIO playback.
|
||||||
|
let useVPIO = IOSAudioRouter.shared.currentConfigUsesVoiceProcessing
|
||||||
|
client.setExternalPlayback(useVPIO)
|
||||||
|
|
||||||
|
let desc = StreamDescriptor(kind: .mic, deviceId: voiceState.currentDeviceId, label: "Mic",
|
||||||
|
externalFeed: useVPIO)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if useVPIO {
|
||||||
|
// The external-feed MIC stream now exists, so restart the core into full
|
||||||
|
// external mode (no hardware capture/playback, mixer-timer only) — mic and
|
||||||
|
// speaker are owned entirely by the VPIO engine, which we start right after.
|
||||||
|
client.audioRestart()
|
||||||
|
IOSVoiceProcessingEngine.shared.start(
|
||||||
|
client: client, micStreamId: streamId, captureChannels: channels)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
addActivity("Failed to start mic: \(result.description)")
|
||||||
|
if useVPIO { // revert external-playback mode so remote audio still plays
|
||||||
|
client.setExternalPlayback(false)
|
||||||
|
client.audioRestart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopMicStream() {
|
||||||
|
// Tear down the VPIO engine first (removes the mic tap + unregisters the mixed sink),
|
||||||
|
// then stop the mic stream, then restore the core's hardware playback for any remaining
|
||||||
|
// remote audio. Order matters: the mic stream must be gone before audioRestart so the
|
||||||
|
// core opens a normal playback device (and no capture device — there's no mic stream).
|
||||||
|
let wasVPIO = IOSVoiceProcessingEngine.shared.isRunning
|
||||||
|
if wasVPIO {
|
||||||
|
IOSVoiceProcessingEngine.shared.stop()
|
||||||
|
}
|
||||||
|
if voiceState.localStreamId != 0 {
|
||||||
|
client.stopStream(voiceState.localStreamId)
|
||||||
|
voiceState.localStreamId = 0
|
||||||
|
AudioSessionManager.shared.activeMicStreamId = nil
|
||||||
|
}
|
||||||
|
if wasVPIO {
|
||||||
|
client.setExternalPlayback(false)
|
||||||
|
client.audioRestart() // reopen hardware playback (no mic stream → no hw capture)
|
||||||
|
}
|
||||||
|
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).
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restart the voice path when the audio config changes mid-call (driven by IOSAudioRouter).
|
||||||
|
/// If VPIO is involved on either the current or desired side, restart the mic so the native
|
||||||
|
/// voice-processing engine engages/disengages and re-binds to the new route. Pure miniaudio
|
||||||
|
/// config tweaks need no restart — the core's own audioRestart (already issued) handles them.
|
||||||
|
private func reconcileVoicePath() {
|
||||||
|
guard voiceState.micActive else { return }
|
||||||
|
let want = IOSAudioRouter.shared.currentConfigUsesVoiceProcessing
|
||||||
|
let have = IOSVoiceProcessingEngine.shared.isRunning
|
||||||
|
guard want || have else { return }
|
||||||
|
stopMicStream()
|
||||||
|
doStartMicStream()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(_ info: ChannelEdit) {
|
||||||
|
client.createChannel(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
func editChannel(_ info: ChannelEdit) {
|
||||||
|
client.editChannel(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteChannel(_ channelId: UInt32) {
|
||||||
|
client.deleteChannel(channelId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchAccountList() {
|
||||||
|
client.requestAccountList()
|
||||||
|
}
|
||||||
|
|
||||||
|
func createAccount(username: String, password: String) {
|
||||||
|
client.createAccount(username, password: password)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteAccount(username: String) {
|
||||||
|
client.deleteAccount(username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetPassword(username: String, newPassword: String) {
|
||||||
|
client.resetPassword(username, newPassword: newPassword)
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
107
clients/apple/iOS/VoiceCatiOS/Views/AddServerView.swift
Normal file
107
clients/apple/iOS/VoiceCatiOS/Views/AddServerView.swift
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AddServerView: View {
|
||||||
|
@Environment(AppState.self) private var appState
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
let editing: SavedServer?
|
||||||
|
|
||||||
|
@State private var host = ""
|
||||||
|
@State private var port = "7878"
|
||||||
|
@State private var authMode = SavedServer.AuthMode.guest
|
||||||
|
@State private var nickname = ""
|
||||||
|
@State private var username = ""
|
||||||
|
@State private var password = ""
|
||||||
|
@State private var savePassword = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("Server") {
|
||||||
|
TextField("Hostname or IP", text: $host)
|
||||||
|
.textContentType(.URL)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.accessibilityLabel("Server hostname or IP address")
|
||||||
|
TextField("Port", text: $port)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
.accessibilityLabel("Port number")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Authentication") {
|
||||||
|
Picker("Mode", selection: $authMode) {
|
||||||
|
Text("Guest").tag(SavedServer.AuthMode.guest)
|
||||||
|
Text("Account").tag(SavedServer.AuthMode.password)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.accessibilityLabel("Authentication mode")
|
||||||
|
|
||||||
|
if authMode == .guest {
|
||||||
|
TextField("Nickname (optional)", text: $nickname)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.accessibilityLabel("Guest nickname, optional display name")
|
||||||
|
}
|
||||||
|
|
||||||
|
if authMode == .password {
|
||||||
|
TextField("Username", text: $username)
|
||||||
|
.textContentType(.username)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.accessibilityLabel("Username")
|
||||||
|
SecureField("Password (optional)", text: $password)
|
||||||
|
.textContentType(.password)
|
||||||
|
.accessibilityLabel("Password, optional, leave blank to enter at connect time")
|
||||||
|
Toggle("Save password in Keychain", isOn: $savePassword)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(editing == nil ? "Add Server" : "Edit Server")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Save") { save() }
|
||||||
|
.disabled(host.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
|| UInt16(port) == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
if let s = editing {
|
||||||
|
host = s.host
|
||||||
|
port = "\(s.port)"
|
||||||
|
authMode = s.authMode
|
||||||
|
username = s.savedUsername
|
||||||
|
nickname = s.nickname ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
let trimmedHost = host.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !trimmedHost.isEmpty, let portNum = UInt16(port) else { return }
|
||||||
|
|
||||||
|
let pw = (savePassword && authMode == .password && !password.isEmpty) ? password : nil
|
||||||
|
let trimmedNick = nickname.trimmingCharacters(in: .whitespaces)
|
||||||
|
let nick: String? = (authMode == .guest && !trimmedNick.isEmpty) ? trimmedNick : nil
|
||||||
|
|
||||||
|
if var s = editing {
|
||||||
|
s.host = trimmedHost
|
||||||
|
s.port = portNum
|
||||||
|
s.authMode = authMode
|
||||||
|
s.savedUsername = authMode == .password ? username : ""
|
||||||
|
s.nickname = nick
|
||||||
|
appState.updateServer(s, password: pw)
|
||||||
|
} else {
|
||||||
|
let s = SavedServer(host: trimmedHost, port: portNum,
|
||||||
|
authMode: authMode,
|
||||||
|
savedUsername: authMode == .password ? username : "",
|
||||||
|
nickname: nick)
|
||||||
|
appState.addServer(s, password: pw)
|
||||||
|
}
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
105
clients/apple/iOS/VoiceCatiOS/Views/ChannelBrowserView.swift
Normal file
105
clients/apple/iOS/VoiceCatiOS/Views/ChannelBrowserView.swift
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
/// iPhone channels tab: a drill-down browser. The root list shows only top-level channels;
|
||||||
|
/// tapping (or VoiceOver-activating) a channel pushes `ChannelDetailView`, which shows the
|
||||||
|
/// people in it and any sub-channels. Joining is an explicit action inside the detail view.
|
||||||
|
struct ChannelBrowserView: View {
|
||||||
|
@Bindable var session: SessionState
|
||||||
|
@State private var showCreateChannel = false
|
||||||
|
@State private var editChannel: Channel?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
ForEach(rootChannels) { ch in
|
||||||
|
NavigationLink {
|
||||||
|
ChannelDetailView(channel: ch, session: session)
|
||||||
|
} label: {
|
||||||
|
ChannelRow(channel: ch, session: session)
|
||||||
|
}
|
||||||
|
.swipeActions(edge: .trailing) {
|
||||||
|
if session.permissions.isAdmin {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
session.deleteChannel(ch.id)
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.swipeActions(edge: .leading) {
|
||||||
|
if session.permissions.isAdmin {
|
||||||
|
Button {
|
||||||
|
editChannel = ch
|
||||||
|
} label: {
|
||||||
|
Label("Edit", systemImage: "pencil")
|
||||||
|
}
|
||||||
|
.tint(.blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Channels")
|
||||||
|
.toolbar {
|
||||||
|
if session.permissions.canCreateTempChannel || session.permissions.isAdmin {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button {
|
||||||
|
showCreateChannel = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Create channel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showCreateChannel) {
|
||||||
|
ChannelEditView(channelId: nil, session: session)
|
||||||
|
}
|
||||||
|
.sheet(item: $editChannel) { ch in
|
||||||
|
ChannelEditView(channelId: ch.id, session: session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var rootChannels: [Channel] {
|
||||||
|
session.channels
|
||||||
|
.filter { $0.parentId == 0 }
|
||||||
|
.sorted { $0.name < $1.name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared channel row used by the iPhone browser and detail views. Mirrors the look of the
|
||||||
|
/// iPad `ChannelTreeView` row but keyed on a `Channel` rather than a tree `ChannelNode`.
|
||||||
|
struct ChannelRow: View {
|
||||||
|
let channel: Channel
|
||||||
|
let session: SessionState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let isCurrent = session.currentChannelId == channel.id
|
||||||
|
let usersHere = session.users.filter { $0.channelId == channel.id }
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: channel.passwordProtected ? "lock.fill" : "number")
|
||||||
|
.foregroundStyle(isCurrent ? .blue : .secondary)
|
||||||
|
.imageScale(.small)
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(channel.name)
|
||||||
|
.fontWeight(isCurrent ? .semibold : .regular)
|
||||||
|
if !channel.topic.isEmpty {
|
||||||
|
Text(channel.topic)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if !usersHere.isEmpty {
|
||||||
|
Text("\(usersHere.count)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityLabel("\(channel.name)\(isCurrent ? ", current" : "")\(channel.passwordProtected ? ", password protected" : "")\(!usersHere.isEmpty ? ", \(usersHere.count) users" : "")")
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
163
clients/apple/iOS/VoiceCatiOS/Views/ChannelEditView.swift
Normal file
163
clients/apple/iOS/VoiceCatiOS/Views/ChannelEditView.swift
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
struct ChannelEditView: View {
|
||||||
|
let channelId: UInt32?
|
||||||
|
@Bindable var session: SessionState
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
// General
|
||||||
|
@State private var name = ""
|
||||||
|
@State private var topic = ""
|
||||||
|
@State private var parentId: UInt32 = 0
|
||||||
|
@State private var passwordProtected = false
|
||||||
|
@State private var password = ""
|
||||||
|
@State private var maxUsers = "0"
|
||||||
|
@State private var sortOrder = "0"
|
||||||
|
|
||||||
|
// Audio (Opus). Note: the channel list does not carry the current audio config, so when
|
||||||
|
// editing an existing channel these start from the codec defaults (same as macOS/Windows).
|
||||||
|
@State private var stereo = false
|
||||||
|
@State private var bitrate = "64000"
|
||||||
|
@State private var sampleRate = "48000"
|
||||||
|
@State private var frameMs: UInt32 = 20
|
||||||
|
@State private var application: UInt32 = 0
|
||||||
|
@State private var packetLoss = "5"
|
||||||
|
@State private var complexity = 10
|
||||||
|
@State private var fec = true
|
||||||
|
@State private var dtx = false
|
||||||
|
@State private var dred = false
|
||||||
|
|
||||||
|
private var isEditing: Bool { channelId != nil }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("Channel Info") {
|
||||||
|
TextField("Name", text: $name)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.accessibilityLabel("Channel name")
|
||||||
|
TextField("Topic (optional)", text: $topic)
|
||||||
|
.accessibilityLabel("Channel topic, optional")
|
||||||
|
Picker("Parent", selection: $parentId) {
|
||||||
|
Text("(root)").tag(UInt32(0))
|
||||||
|
ForEach(parentOptions) { ch in
|
||||||
|
Text(ch.name).tag(ch.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Parent channel")
|
||||||
|
Toggle("Password protected", isOn: $passwordProtected)
|
||||||
|
if passwordProtected {
|
||||||
|
SecureField("Password (blank keeps existing)", text: $password)
|
||||||
|
.accessibilityLabel("Channel password")
|
||||||
|
}
|
||||||
|
TextField("Max users (0 = unlimited)", text: $maxUsers)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
.accessibilityLabel("Maximum users, zero means unlimited")
|
||||||
|
TextField("Sort order", text: $sortOrder)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
.accessibilityLabel("Sort order")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Audio (Opus)") {
|
||||||
|
Toggle("Stereo", isOn: $stereo)
|
||||||
|
TextField("Bitrate (bps)", text: $bitrate)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
.accessibilityLabel("Bitrate in bits per second")
|
||||||
|
TextField("Sample rate (Hz)", text: $sampleRate)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
.accessibilityLabel("Sample rate in Hz")
|
||||||
|
Picker("Frame", selection: $frameMs) {
|
||||||
|
ForEach([UInt32(10), 20, 40, 60], id: \.self) { ms in
|
||||||
|
Text("\(ms) ms").tag(ms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Opus frame duration")
|
||||||
|
Picker("Application", selection: $application) {
|
||||||
|
Text("VoIP").tag(UInt32(0))
|
||||||
|
Text("Audio").tag(UInt32(1))
|
||||||
|
Text("Low delay").tag(UInt32(2))
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Opus application profile")
|
||||||
|
TextField("Expected packet loss %", text: $packetLoss)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
.accessibilityLabel("Expected packet loss percent, 0 to 100")
|
||||||
|
Stepper("Complexity: \(complexity)", value: $complexity, in: 0...10)
|
||||||
|
.accessibilityLabel("Opus complexity, 0 to 10")
|
||||||
|
Toggle("FEC (forward error correction)", isOn: $fec)
|
||||||
|
Toggle("DTX (discontinuous transmission)", isOn: $dtx)
|
||||||
|
Toggle("DRED (deep redundancy)", isOn: $dred)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(isEditing ? "Edit Channel" : "New Channel")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Save") {
|
||||||
|
save()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear(perform: loadIfEditing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Channels offered as a parent. Excludes the channel being edited so it can't parent itself.
|
||||||
|
private var parentOptions: [Channel] {
|
||||||
|
session.channels
|
||||||
|
.filter { $0.id != channelId }
|
||||||
|
.sorted { $0.name < $1.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadIfEditing() {
|
||||||
|
guard let id = channelId,
|
||||||
|
let ch = session.channels.first(where: { $0.id == id }) else { return }
|
||||||
|
name = ch.name
|
||||||
|
topic = ch.topic
|
||||||
|
parentId = ch.parentId
|
||||||
|
passwordProtected = ch.passwordProtected
|
||||||
|
maxUsers = "\(ch.maxUsers)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
let trimmedName = name.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !trimmedName.isEmpty else { return }
|
||||||
|
|
||||||
|
let audio = AudioConfig(
|
||||||
|
stereo: stereo,
|
||||||
|
sampleRate: UInt32(sampleRate) ?? 48000,
|
||||||
|
bitrateBps: UInt32(bitrate) ?? 64000,
|
||||||
|
frameMs: frameMs,
|
||||||
|
application: application,
|
||||||
|
fec: fec,
|
||||||
|
expectedPacketLoss: min(UInt32(packetLoss) ?? 5, 100),
|
||||||
|
dtx: dtx,
|
||||||
|
complexity: UInt32(complexity),
|
||||||
|
dred: dred
|
||||||
|
)
|
||||||
|
|
||||||
|
let pw: String? = passwordProtected ? (password.isEmpty ? nil : password) : nil
|
||||||
|
let info = ChannelEdit(
|
||||||
|
id: channelId ?? 0,
|
||||||
|
parentId: parentId,
|
||||||
|
name: trimmedName,
|
||||||
|
topic: topic,
|
||||||
|
passwordProtected: passwordProtected,
|
||||||
|
password: pw,
|
||||||
|
maxUsers: UInt32(maxUsers) ?? 0,
|
||||||
|
sortOrder: UInt32(sortOrder) ?? 0,
|
||||||
|
audio: audio
|
||||||
|
)
|
||||||
|
|
||||||
|
if isEditing {
|
||||||
|
session.editChannel(info)
|
||||||
|
} else {
|
||||||
|
session.createChannel(info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
145
clients/apple/iOS/VoiceCatiOS/Views/ChannelTreeView.swift
Normal file
145
clients/apple/iOS/VoiceCatiOS/Views/ChannelTreeView.swift
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
// ChannelNode wraps Channel for OutlineGroup; childrenOrNil must be nil (not empty [])
|
||||||
|
// for leaf channels so OutlineGroup doesn't render expand buttons.
|
||||||
|
struct ChannelNode: Identifiable {
|
||||||
|
let channel: Channel
|
||||||
|
let children: [ChannelNode]?
|
||||||
|
var id: UInt32 { channel.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChannelTreeView: View {
|
||||||
|
@Bindable var session: SessionState
|
||||||
|
@State private var showCreateChannel = false
|
||||||
|
@State private var editChannel: Channel?
|
||||||
|
@State private var channelPassword = ""
|
||||||
|
@State private var passwordChannelId: UInt32?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List(channelTree, children: \.children) { node in
|
||||||
|
ChannelRowView(node: node, session: session)
|
||||||
|
.onTapGesture {
|
||||||
|
if node.channel.passwordProtected {
|
||||||
|
passwordChannelId = node.channel.id
|
||||||
|
} else {
|
||||||
|
session.joinChannel(node.channel.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.swipeActions(edge: .trailing) {
|
||||||
|
if session.permissions.isAdmin {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
session.deleteChannel(node.channel.id)
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.swipeActions(edge: .leading) {
|
||||||
|
if session.permissions.isAdmin {
|
||||||
|
Button {
|
||||||
|
editChannel = node.channel
|
||||||
|
} label: {
|
||||||
|
Label("Edit", systemImage: "pencil")
|
||||||
|
}
|
||||||
|
.tint(.blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.sidebar)
|
||||||
|
.toolbar {
|
||||||
|
if session.permissions.canCreateTempChannel || session.permissions.isAdmin {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button {
|
||||||
|
showCreateChannel = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Create channel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if session.currentChannelId != 0 {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button("Leave", systemImage: "arrow.left.circle") {
|
||||||
|
session.leaveChannel()
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Leave current channel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showCreateChannel) {
|
||||||
|
ChannelEditView(channelId: nil, session: session)
|
||||||
|
}
|
||||||
|
.sheet(item: $editChannel) { ch in
|
||||||
|
ChannelEditView(channelId: ch.id, session: session)
|
||||||
|
}
|
||||||
|
.alert("Channel Password", isPresented: Binding(
|
||||||
|
get: { passwordChannelId != nil },
|
||||||
|
set: { if !$0 { passwordChannelId = nil; channelPassword = "" } }
|
||||||
|
)) {
|
||||||
|
SecureField("Password", text: $channelPassword)
|
||||||
|
.accessibilityLabel("Channel password")
|
||||||
|
Button("Join") {
|
||||||
|
if let cid = passwordChannelId {
|
||||||
|
session.joinChannel(cid, password: channelPassword)
|
||||||
|
}
|
||||||
|
passwordChannelId = nil
|
||||||
|
channelPassword = ""
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {
|
||||||
|
passwordChannelId = nil
|
||||||
|
channelPassword = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var channelTree: [ChannelNode] {
|
||||||
|
buildTree(parentId: 0, channels: session.channels)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildTree(parentId: UInt32, channels: [Channel]) -> [ChannelNode] {
|
||||||
|
channels
|
||||||
|
.filter { $0.parentId == parentId }
|
||||||
|
.map { ch in
|
||||||
|
let kids = buildTree(parentId: ch.id, channels: channels)
|
||||||
|
return ChannelNode(channel: ch, children: kids.isEmpty ? nil : kids)
|
||||||
|
}
|
||||||
|
.sorted { $0.channel.name < $1.channel.name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ChannelRowView: View {
|
||||||
|
let node: ChannelNode
|
||||||
|
let session: SessionState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let ch = node.channel
|
||||||
|
let isCurrent = session.currentChannelId == ch.id
|
||||||
|
let usersHere = session.users.filter { $0.channelId == ch.id }
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: ch.passwordProtected ? "lock.fill" : "number")
|
||||||
|
.foregroundStyle(isCurrent ? .blue : .secondary)
|
||||||
|
.imageScale(.small)
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(ch.name)
|
||||||
|
.fontWeight(isCurrent ? .semibold : .regular)
|
||||||
|
if !ch.topic.isEmpty {
|
||||||
|
Text(ch.topic)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if !usersHere.isEmpty {
|
||||||
|
Text("\(usersHere.count)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityLabel("\(usersHere.count) users")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityLabel("\(ch.name)\(isCurrent ? ", current" : "")\(ch.passwordProtected ? ", password protected" : "")\(!usersHere.isEmpty ? ", \(usersHere.count) users" : "")")
|
||||||
|
}
|
||||||
|
}
|
||||||
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
277
clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift
Normal file
277
clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
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")
|
||||||
|
|
||||||
|
Toggle("Speaker output", isOn: Binding(
|
||||||
|
get: { router.forceSpeaker },
|
||||||
|
set: { router.setForceSpeaker($0) }
|
||||||
|
))
|
||||||
|
.accessibilityLabel("Speaker output")
|
||||||
|
.accessibilityHint("Routes audio to the speaker instead of the earpiece when no headphones are connected.")
|
||||||
|
|
||||||
|
// Surface the voice-processing state. On the AEC presets the native iOS
|
||||||
|
// Voice-Processing unit (VPIO) does echo cancellation, noise suppression and
|
||||||
|
// automatic gain control; the other presets (stereo/studio/A2DP) can't use it.
|
||||||
|
if router.currentConfigUsesVoiceProcessing {
|
||||||
|
Label("Echo cancellation & noise suppression on (iOS voice processing)",
|
||||||
|
systemImage: "waveform.badge.mic")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityLabel("Echo cancellation and noise suppression are on")
|
||||||
|
} else {
|
||||||
|
Label("No echo cancellation in this preset (stereo / studio / A2DP)",
|
||||||
|
systemImage: "waveform.slash")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityLabel("Echo cancellation is off in this 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" : "")")
|
||||||
|
}
|
||||||
|
}
|
||||||
182
clients/apple/iOS/VoiceCatiOS/Views/VoiceControlsView.swift
Normal file
182
clients/apple/iOS/VoiceCatiOS/Views/VoiceControlsView.swift
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import VoiceCatCore
|
||||||
|
import ReplayKit
|
||||||
|
|
||||||
|
struct VoiceControlsView: View {
|
||||||
|
@Bindable var session: SessionState
|
||||||
|
@StateObject private var router = IOSAudioRouter.shared
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
// Speaker output toggle — force the built-in speaker instead of the earpiece when
|
||||||
|
// no headphones/BT are connected. Mirrors the persisted Settings ▸ Audio toggle.
|
||||||
|
Button {
|
||||||
|
router.setForceSpeaker(!router.forceSpeaker)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: router.forceSpeaker ? "speaker.wave.2.fill" : "speaker.fill")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(router.forceSpeaker ? Color.accentColor : .primary)
|
||||||
|
}
|
||||||
|
.accessibilityLabel(router.forceSpeaker
|
||||||
|
? "Speaker on — turn off to use the earpiece"
|
||||||
|
: "Speaker off — turn on for speakerphone")
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
460
clients/apple/macOS/VoiceCatMac.xcodeproj/project.pbxproj
Normal file
460
clients/apple/macOS/VoiceCatMac.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,460 @@
|
|||||||
|
// !$*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 */; };
|
||||||
|
AAAA0000000000000000004E /* ScreenSharePickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA0000000000000000004F /* ScreenSharePickerSheet.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>"; };
|
||||||
|
AAAA0000000000000000004F /* ScreenSharePickerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenSharePickerSheet.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 */,
|
||||||
|
AAAA0000000000000000004F /* ScreenSharePickerSheet.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 */,
|
||||||
|
AAAA0000000000000000004E /* ScreenSharePickerSheet.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
|
||||||
|
}
|
||||||
|
}
|
||||||
222
clients/apple/macOS/VoiceCatMac/Audio/ScreenAudioCapture.swift
Normal file
222
clients/apple/macOS/VoiceCatMac/Audio/ScreenAudioCapture.swift
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import ScreenCaptureKit
|
||||||
|
|
||||||
|
// Which apps' audio the SCREEN_AUDIO stream captures. ScreenCaptureKit filters audio at the
|
||||||
|
// *application* level (not per-window), so the selection is expressed as bundle IDs. The
|
||||||
|
// picker UI (ScreenSharePickerSheet) produces a `ScreenAudioSelection`; `start()` turns it
|
||||||
|
// into the matching `SCContentFilter`.
|
||||||
|
enum ScreenAudioScope: Equatable {
|
||||||
|
case entireDesktop // whole display — the original behaviour
|
||||||
|
case onlyApps([String]) // capture only these bundle IDs
|
||||||
|
case allExcept([String]) // capture everything except these bundle IDs
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ScreenAudioSelection: Equatable {
|
||||||
|
var scope: ScreenAudioScope = .entireDesktop
|
||||||
|
/// Drop the macOS screen-reader (VoiceOver) speech from the shared mix. Meaningful for
|
||||||
|
/// `.entireDesktop`/`.allExcept`; for `.onlyApps` the screen reader is already excluded.
|
||||||
|
var excludeScreenReader: Bool = false
|
||||||
|
|
||||||
|
static let `default` = ScreenAudioSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 selection: ScreenAudioSelection
|
||||||
|
private let sampleQueue = DispatchQueue(label: "cat.voice.screenaudio.samples")
|
||||||
|
private var stream: SCStream?
|
||||||
|
|
||||||
|
/// Bundle IDs whose audio carries the macOS screen-reader speech. VoiceOver itself plus the
|
||||||
|
/// speech-synthesis daemon that actually renders the spoken audio — the speech is usually
|
||||||
|
/// emitted by the daemon, not the VoiceOver app, so we exclude whichever are running.
|
||||||
|
static let screenReaderBundleIDs: Set<String> = [
|
||||||
|
"com.apple.VoiceOver",
|
||||||
|
"com.apple.VoiceOver4",
|
||||||
|
"com.apple.speech.speechsynthesisd",
|
||||||
|
]
|
||||||
|
|
||||||
|
/// 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, selection: ScreenAudioSelection, onPcm: @escaping PcmHandler) {
|
||||||
|
self.channels = max(1, min(2, Int(channels)))
|
||||||
|
self.selection = selection
|
||||||
|
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 = Self.makeFilter(selection: selection, display: display,
|
||||||
|
apps: content.applications)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn a `ScreenAudioSelection` into an `SCContentFilter` against the running apps.
|
||||||
|
/// ScreenCaptureKit filters audio per application, so we map bundle IDs → SCRunningApplication.
|
||||||
|
private static func makeFilter(selection: ScreenAudioSelection, display: SCDisplay,
|
||||||
|
apps: [SCRunningApplication]) -> SCContentFilter {
|
||||||
|
func appsMatching(_ ids: Set<String>) -> [SCRunningApplication] {
|
||||||
|
apps.filter { ids.contains($0.bundleIdentifier) }
|
||||||
|
}
|
||||||
|
|
||||||
|
switch selection.scope {
|
||||||
|
case .onlyApps(let bundleIDs):
|
||||||
|
// Include-only already excludes everything else (the screen reader included), so the
|
||||||
|
// excludeScreenReader flag is moot in this mode.
|
||||||
|
return SCContentFilter(display: display,
|
||||||
|
including: appsMatching(Set(bundleIDs)),
|
||||||
|
exceptingWindows: [])
|
||||||
|
|
||||||
|
case .allExcept(let bundleIDs):
|
||||||
|
var ids = Set(bundleIDs)
|
||||||
|
if selection.excludeScreenReader { ids.formUnion(screenReaderBundleIDs) }
|
||||||
|
return SCContentFilter(display: display,
|
||||||
|
excludingApplications: appsMatching(ids),
|
||||||
|
exceptingWindows: [])
|
||||||
|
|
||||||
|
case .entireDesktop:
|
||||||
|
if selection.excludeScreenReader {
|
||||||
|
return SCContentFilter(display: display,
|
||||||
|
excludingApplications: appsMatching(screenReaderBundleIDs),
|
||||||
|
exceptingWindows: [])
|
||||||
|
}
|
||||||
|
return SCContentFilter(display: display, excludingWindows: [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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>
|
||||||
27
clients/apple/macOS/VoiceCatMac/Models/SavedServer.swift
Normal file
27
clients/apple/macOS/VoiceCatMac/Models/SavedServer.swift
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
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?
|
||||||
|
/// Free-form display name used when connecting as a guest. Distinct from the account
|
||||||
|
/// `savedUsername`. Empty/nil falls back to the system full name.
|
||||||
|
var nickname: 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
|
||||||
|
}
|
||||||
|
}
|
||||||
164
clients/apple/macOS/VoiceCatMac/Sheets/AddServerSheet.swift
Normal file
164
clients/apple/macOS/VoiceCatMac/Sheets/AddServerSheet.swift
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
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 nicknameField = NSTextField()
|
||||||
|
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")
|
||||||
|
nicknameField.stringValue = s.nickname ?? ""
|
||||||
|
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 nickLabel = NSTextField(labelWithString: "Nickname:")
|
||||||
|
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")
|
||||||
|
|
||||||
|
nicknameField.placeholderString = "leave blank to use your system name"
|
||||||
|
nicknameField.setAccessibilityLabel("Guest nickname (display name)")
|
||||||
|
|
||||||
|
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],
|
||||||
|
[nickLabel, nicknameField],
|
||||||
|
[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"
|
||||||
|
nicknameField.isEnabled = !isAccount
|
||||||
|
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)
|
||||||
|
let nick = nicknameField.stringValue.trimmingCharacters(in: .whitespaces)
|
||||||
|
server.nickname = nick.isEmpty ? nil : nick
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
241
clients/apple/macOS/VoiceCatMac/Sheets/ChannelEditSheet.swift
Normal file
241
clients/apple/macOS/VoiceCatMac/Sheets/ChannelEditSheet.swift
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
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 dredCheckbox = NSButton(checkboxWithTitle: "DRED", target: nil, action: nil)
|
||||||
|
private let frameMsPicker = NSPopUpButton()
|
||||||
|
private let applicationPicker = NSPopUpButton()
|
||||||
|
private let sampleRateField: NSTextField = {
|
||||||
|
let f = NSTextField(); f.stringValue = "48000"; return f
|
||||||
|
}()
|
||||||
|
private let packetLossField: NSTextField = {
|
||||||
|
let f = NSTextField(); f.stringValue = "5"; return f
|
||||||
|
}()
|
||||||
|
private let complexityField: NSTextField = {
|
||||||
|
let f = NSTextField(); f.stringValue = "10"; return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
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 ["10", "20", "40", "60"] { frameMsPicker.addItem(withTitle: "\(ms) ms") }
|
||||||
|
frameMsPicker.selectItem(withTitle: "20 ms")
|
||||||
|
frameMsPicker.setAccessibilityLabel("Opus frame duration")
|
||||||
|
|
||||||
|
applicationPicker.addItem(withTitle: "VoIP")
|
||||||
|
applicationPicker.addItem(withTitle: "Audio")
|
||||||
|
applicationPicker.addItem(withTitle: "Low delay")
|
||||||
|
applicationPicker.setAccessibilityLabel("Opus application profile")
|
||||||
|
|
||||||
|
fecCheckbox.state = .on
|
||||||
|
stereoCheckbox.setAccessibilityLabel("Stereo audio")
|
||||||
|
bitrateField.setAccessibilityLabel("Bitrate in bits per second")
|
||||||
|
sampleRateField.setAccessibilityLabel("Sample rate in Hz")
|
||||||
|
packetLossField.setAccessibilityLabel("Expected packet loss percent (0 to 100)")
|
||||||
|
complexityField.setAccessibilityLabel("Opus complexity (0 to 10)")
|
||||||
|
fecCheckbox.setAccessibilityLabel("Forward error correction")
|
||||||
|
dtxCheckbox.setAccessibilityLabel("Discontinuous transmission")
|
||||||
|
dredCheckbox.setAccessibilityLabel("Deep redundancy (DRED)")
|
||||||
|
|
||||||
|
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: "Sample rate:"), sampleRateField],
|
||||||
|
[NSTextField(labelWithString: "Frame:"), frameMsPicker],
|
||||||
|
[NSTextField(labelWithString: "Application:"), applicationPicker],
|
||||||
|
[NSTextField(labelWithString: "Packet loss %:"), packetLossField],
|
||||||
|
[NSTextField(labelWithString: "Complexity:"), complexityField],
|
||||||
|
[stereoCheckbox, fecCheckbox],
|
||||||
|
[dtxCheckbox, dredCheckbox],
|
||||||
|
])
|
||||||
|
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)"
|
||||||
|
sampleRateField.stringValue = "\(e.audio.sampleRate)"
|
||||||
|
packetLossField.stringValue = "\(e.audio.expectedPacketLoss)"
|
||||||
|
complexityField.stringValue = "\(e.audio.complexity)"
|
||||||
|
fecCheckbox.state = e.audio.fec ? .on : .off
|
||||||
|
dtxCheckbox.state = e.audio.dtx ? .on : .off
|
||||||
|
dredCheckbox.state = e.audio.dred ? .on : .off
|
||||||
|
applicationPicker.selectItem(at: Int(min(e.audio.application, 2)))
|
||||||
|
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 sampleRate = UInt32(sampleRateField.stringValue) ?? 48000
|
||||||
|
let packetLoss = min(UInt32(packetLossField.stringValue) ?? 5, 100)
|
||||||
|
let complexity = min(UInt32(complexityField.stringValue) ?? 10, 10)
|
||||||
|
let frameMsStr = frameMsPicker.titleOfSelectedItem?.replacingOccurrences(of: " ms", with: "") ?? "20"
|
||||||
|
let frameMs = UInt32(frameMsStr) ?? 20
|
||||||
|
let application = UInt32(max(applicationPicker.indexOfSelectedItem, 0))
|
||||||
|
|
||||||
|
let audio = AudioConfig(
|
||||||
|
stereo: stereoCheckbox.state == .on,
|
||||||
|
sampleRate: sampleRate,
|
||||||
|
bitrateBps: bitrate,
|
||||||
|
frameMs: frameMs,
|
||||||
|
application: application,
|
||||||
|
fec: fecCheckbox.state == .on,
|
||||||
|
expectedPacketLoss: packetLoss,
|
||||||
|
dtx: dtxCheckbox.state == .on,
|
||||||
|
complexity: complexity,
|
||||||
|
dred: dredCheckbox.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,246 @@
|
|||||||
|
import AppKit
|
||||||
|
import ScreenCaptureKit
|
||||||
|
|
||||||
|
// ScreenSharePickerSheet — chooses *what* the SCREEN_AUDIO stream captures before sharing
|
||||||
|
// starts. ScreenCaptureKit filters audio per application (not per window), so the user picks
|
||||||
|
// a mode (everything / only-these / all-except-these) plus a set of apps, and a dedicated
|
||||||
|
// toggle to drop their own screen-reader (VoiceOver) speech from the mix.
|
||||||
|
//
|
||||||
|
// Mirrors the modal-sheet pattern used by the rest of the macOS client (UserPickerSheet,
|
||||||
|
// MoveUserSheet, …): an NSViewController presented via MainWindowController.presentSheet, with
|
||||||
|
// an `onComplete` callback that returns the chosen `ScreenAudioSelection` (or nil on cancel).
|
||||||
|
//
|
||||||
|
// The app list comes from `SCShareableContent.current`, fetched asynchronously — that first
|
||||||
|
// access is also what surfaces the Screen Recording (TCC) prompt, which is why the picker is
|
||||||
|
// the natural place for it to appear, before any capture begins.
|
||||||
|
final class ScreenSharePickerSheet: NSViewController, NSTableViewDataSource, NSTableViewDelegate {
|
||||||
|
|
||||||
|
/// Called with the chosen selection, or `nil` if the user cancelled.
|
||||||
|
var onComplete: ((ScreenAudioSelection?) -> Void)?
|
||||||
|
|
||||||
|
private enum Mode: Int { case everything = 0, only = 1, except = 2 }
|
||||||
|
|
||||||
|
private struct AppEntry { let name: String; let bundleID: String; let icon: NSImage? }
|
||||||
|
|
||||||
|
private let initialSelection: ScreenAudioSelection
|
||||||
|
private var mode: Mode
|
||||||
|
private var excludeScreenReader: Bool
|
||||||
|
private var checked: Set<String> // bundle IDs ticked in the app table
|
||||||
|
|
||||||
|
private var apps: [AppEntry] = []
|
||||||
|
private let tableView = NSTableView()
|
||||||
|
private var modeControl: NSSegmentedControl?
|
||||||
|
private var screenReaderCheckbox: NSButton?
|
||||||
|
private var statusLabel: NSTextField?
|
||||||
|
|
||||||
|
init(selection: ScreenAudioSelection) {
|
||||||
|
self.initialSelection = selection
|
||||||
|
switch selection.scope {
|
||||||
|
case .entireDesktop: mode = .everything; checked = []
|
||||||
|
case .onlyApps(let ids): mode = .only; checked = Set(ids)
|
||||||
|
case .allExcept(let ids): mode = .except; checked = Set(ids)
|
||||||
|
}
|
||||||
|
self.excludeScreenReader = selection.excludeScreenReader
|
||||||
|
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: 420))
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
buildUI()
|
||||||
|
loadApps()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UI
|
||||||
|
|
||||||
|
private func buildUI() {
|
||||||
|
let titleLabel = NSTextField(labelWithString: "Choose what to share:")
|
||||||
|
titleLabel.font = .boldSystemFont(ofSize: 13)
|
||||||
|
titleLabel.setAccessibilityLabel("Choose what to share")
|
||||||
|
|
||||||
|
let modeControl = NSSegmentedControl(
|
||||||
|
labels: ["Everything", "Only selected", "All except selected"],
|
||||||
|
trackingMode: .selectOne, target: self, action: #selector(modeChanged))
|
||||||
|
modeControl.selectedSegment = mode.rawValue
|
||||||
|
modeControl.segmentDistribution = .fillEqually
|
||||||
|
modeControl.setAccessibilityLabel("Share mode")
|
||||||
|
self.modeControl = modeControl
|
||||||
|
|
||||||
|
let col = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("app"))
|
||||||
|
tableView.addTableColumn(col)
|
||||||
|
tableView.headerView = nil
|
||||||
|
tableView.dataSource = self
|
||||||
|
tableView.delegate = self
|
||||||
|
tableView.rowHeight = 22
|
||||||
|
tableView.setAccessibilityLabel("Application list")
|
||||||
|
|
||||||
|
let scroll = NSScrollView()
|
||||||
|
scroll.documentView = tableView
|
||||||
|
scroll.hasVerticalScroller = true
|
||||||
|
scroll.borderType = .bezelBorder
|
||||||
|
scroll.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
scroll.setContentHuggingPriority(.defaultLow, for: .vertical)
|
||||||
|
|
||||||
|
let statusLabel = NSTextField(labelWithString: "Loading apps…")
|
||||||
|
statusLabel.textColor = .secondaryLabelColor
|
||||||
|
statusLabel.font = .systemFont(ofSize: 11)
|
||||||
|
self.statusLabel = statusLabel
|
||||||
|
|
||||||
|
let screenReaderCheckbox = NSButton(checkboxWithTitle: "Exclude screen reader (VoiceOver) audio",
|
||||||
|
target: self, action: #selector(screenReaderToggled))
|
||||||
|
screenReaderCheckbox.state = excludeScreenReader ? .on : .off
|
||||||
|
screenReaderCheckbox.toolTip = "Keep your VoiceOver speech out of the shared audio."
|
||||||
|
self.screenReaderCheckbox = screenReaderCheckbox
|
||||||
|
|
||||||
|
let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(cancelClicked))
|
||||||
|
cancelButton.bezelStyle = .rounded
|
||||||
|
cancelButton.keyEquivalent = "\u{1b}" // Esc
|
||||||
|
|
||||||
|
let shareButton = NSButton(title: "Share", target: self, action: #selector(shareClicked))
|
||||||
|
shareButton.bezelStyle = .rounded
|
||||||
|
shareButton.keyEquivalent = "\r"
|
||||||
|
|
||||||
|
let buttonRow = NSStackView(views: [NSView(), cancelButton, shareButton])
|
||||||
|
buttonRow.orientation = .horizontal
|
||||||
|
buttonRow.spacing = 8
|
||||||
|
|
||||||
|
let stack = NSStackView(views: [titleLabel, modeControl, scroll, statusLabel,
|
||||||
|
screenReaderCheckbox, 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),
|
||||||
|
])
|
||||||
|
|
||||||
|
updateEnabledStates()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reflect the current mode: the app table only matters for only/except; the screen-reader
|
||||||
|
/// toggle is moot for `.only` (include-only already excludes the screen reader).
|
||||||
|
private func updateEnabledStates() {
|
||||||
|
let listActive = (mode != .everything)
|
||||||
|
tableView.isEnabled = listActive
|
||||||
|
tableView.alphaValue = listActive ? 1.0 : 0.45
|
||||||
|
screenReaderCheckbox?.isEnabled = (mode != .only)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadApps() {
|
||||||
|
Task { @MainActor in
|
||||||
|
do {
|
||||||
|
let content = try await SCShareableContent.current
|
||||||
|
var seen = Set<String>()
|
||||||
|
var entries: [AppEntry] = []
|
||||||
|
for app in content.applications {
|
||||||
|
let bid = app.bundleIdentifier
|
||||||
|
guard !bid.isEmpty, !seen.contains(bid) else { continue }
|
||||||
|
// Hide our own app (its audio is already excluded) and the screen reader
|
||||||
|
// (handled by its own toggle).
|
||||||
|
if bid == Bundle.main.bundleIdentifier { continue }
|
||||||
|
if ScreenAudioCapture.screenReaderBundleIDs.contains(bid) { continue }
|
||||||
|
seen.insert(bid)
|
||||||
|
let name = app.applicationName.isEmpty ? bid : app.applicationName
|
||||||
|
let icon = NSRunningApplication
|
||||||
|
.runningApplications(withBundleIdentifier: bid).first?.icon
|
||||||
|
entries.append(AppEntry(name: name, bundleID: bid, icon: icon))
|
||||||
|
}
|
||||||
|
entries.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||||
|
self.apps = entries
|
||||||
|
self.statusLabel?.isHidden = true
|
||||||
|
self.tableView.reloadData()
|
||||||
|
} catch {
|
||||||
|
self.statusLabel?.stringValue = "Screen Recording permission needed to list apps — "
|
||||||
|
+ "grant it in System Settings ▸ Privacy & Security, then reopen this."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
@objc private func modeChanged() {
|
||||||
|
mode = Mode(rawValue: modeControl?.selectedSegment ?? 0) ?? .everything
|
||||||
|
updateEnabledStates()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func screenReaderToggled() {
|
||||||
|
excludeScreenReader = (screenReaderCheckbox?.state == .on)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func appCheckboxToggled(_ sender: NSButton) {
|
||||||
|
let row = sender.tag
|
||||||
|
guard row >= 0, row < apps.count else { return }
|
||||||
|
let bid = apps[row].bundleID
|
||||||
|
if sender.state == .on { checked.insert(bid) } else { checked.remove(bid) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func shareClicked() {
|
||||||
|
let scope: ScreenAudioScope
|
||||||
|
switch mode {
|
||||||
|
case .everything: scope = .entireDesktop
|
||||||
|
case .only: scope = .onlyApps(Array(checked))
|
||||||
|
case .except: scope = .allExcept(Array(checked))
|
||||||
|
}
|
||||||
|
dismiss(nil)
|
||||||
|
onComplete?(ScreenAudioSelection(scope: scope, excludeScreenReader: excludeScreenReader))
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cancelClicked() {
|
||||||
|
dismiss(nil)
|
||||||
|
onComplete?(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - NSTableViewDataSource / Delegate
|
||||||
|
|
||||||
|
func numberOfRows(in tableView: NSTableView) -> Int { apps.count }
|
||||||
|
|
||||||
|
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
|
||||||
|
let app = apps[row]
|
||||||
|
let cell = AppCheckboxCell()
|
||||||
|
cell.checkbox.title = app.name
|
||||||
|
cell.checkbox.state = checked.contains(app.bundleID) ? .on : .off
|
||||||
|
cell.checkbox.isEnabled = (mode != .everything)
|
||||||
|
cell.checkbox.tag = row
|
||||||
|
cell.checkbox.target = self
|
||||||
|
cell.checkbox.action = #selector(appCheckboxToggled(_:))
|
||||||
|
cell.iconView.image = app.icon
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selecting a row shouldn't visually highlight — interaction is via the checkbox.
|
||||||
|
func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool { false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// One row: a leading app icon and a checkbox titled with the app name.
|
||||||
|
private final class AppCheckboxCell: NSTableCellView {
|
||||||
|
let iconView = NSImageView()
|
||||||
|
let checkbox = NSButton(checkboxWithTitle: "", target: nil, action: nil)
|
||||||
|
|
||||||
|
init() {
|
||||||
|
super.init(frame: .zero)
|
||||||
|
iconView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
checkbox.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
addSubview(iconView)
|
||||||
|
addSubview(checkbox)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
iconView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 4),
|
||||||
|
iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||||
|
iconView.widthAnchor.constraint(equalToConstant: 16),
|
||||||
|
iconView.heightAnchor.constraint(equalToConstant: 16),
|
||||||
|
checkbox.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 6),
|
||||||
|
checkbox.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4),
|
||||||
|
checkbox.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
}
|
||||||
@@ -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.nickname?.isEmpty == false ? server.nickname! : 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.nickname?.isEmpty == false ? server.nickname! : 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
|
||||||
|
|
||||||
|
|
||||||
1419
clients/apple/macOS/VoiceCatMac/Windows/MainWindowController.swift
Normal file
1419
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`.
|
||||||
|
|||||||
205
clients/windows/VoiceCat.App/Audio/AudioSessionEnumerator.cs
Normal file
205
clients/windows/VoiceCat.App/Audio/AudioSessionEnumerator.cs
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace VoiceCat.App.Audio;
|
||||||
|
|
||||||
|
// ── Scope types (mirror macOS ScreenAudioScope / ScreenAudioSelection) ────────
|
||||||
|
|
||||||
|
public abstract record AppAudioScope;
|
||||||
|
public sealed record EntireDesktop : AppAudioScope;
|
||||||
|
public sealed record OnlyApps(IReadOnlyList<int> Pids, IReadOnlyList<string> Names) : AppAudioScope;
|
||||||
|
public sealed record AllExceptApps(IReadOnlyList<int> Pids, IReadOnlyList<string> Names) : AppAudioScope;
|
||||||
|
|
||||||
|
public sealed record AudioAppInfo(int Pid, string DisplayName);
|
||||||
|
|
||||||
|
// ── Enumerator ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public static class AudioSessionEnumerator
|
||||||
|
{
|
||||||
|
// Returns all user-facing apps: visible-window processes (primary, like macOS
|
||||||
|
// SCShareableContent.current) plus any background audio-session-only processes
|
||||||
|
// (e.g. Spotify in mini-player). Excludes VoiceCat itself and system processes.
|
||||||
|
public static IReadOnlyList<AudioAppInfo> GetAudioApps()
|
||||||
|
{
|
||||||
|
var seen = new HashSet<int>();
|
||||||
|
var result = new List<AudioAppInfo>();
|
||||||
|
int selfPid = Environment.ProcessId;
|
||||||
|
|
||||||
|
// ── 1. Visible-window processes (EnumWindows) ─────────────────────────
|
||||||
|
// Same set macOS ScreenCaptureKit exposes: all apps with at least one
|
||||||
|
// visible top-level window. Shows apps even when not currently producing audio.
|
||||||
|
EnumWindows((hWnd, _) =>
|
||||||
|
{
|
||||||
|
if (!IsWindowVisible(hWnd)) return true;
|
||||||
|
GetWindowThreadProcessId(hWnd, out uint pid);
|
||||||
|
if (pid == 0 || pid == (uint)selfPid || !seen.Add((int)pid)) return true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var proc = Process.GetProcessById((int)pid);
|
||||||
|
string name = proc.MainWindowTitle.Length > 0
|
||||||
|
? $"{proc.ProcessName} — {proc.MainWindowTitle}"
|
||||||
|
: proc.ProcessName;
|
||||||
|
if (!string.IsNullOrEmpty(proc.ProcessName))
|
||||||
|
result.Add(new AudioAppInfo((int)pid, name));
|
||||||
|
}
|
||||||
|
catch { /* process exited between EnumWindows and GetProcessById */ }
|
||||||
|
|
||||||
|
return true; // continue enumeration
|
||||||
|
}, IntPtr.Zero);
|
||||||
|
|
||||||
|
// ── 2. Background audio-session processes (WASAPI, supplement) ────────
|
||||||
|
// Catches apps that produce audio but have no visible window (screen reader,
|
||||||
|
// background music player, etc.). Silently skipped if WASAPI is unavailable.
|
||||||
|
AppendAudioSessionApps(seen, selfPid, result);
|
||||||
|
|
||||||
|
return result.OrderBy(a => a.DisplayName, StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── EnumWindows P/Invoke ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern bool IsWindowVisible(IntPtr hWnd);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
|
||||||
|
|
||||||
|
// ── WASAPI audio session supplement ──────────────────────────────────────
|
||||||
|
|
||||||
|
private static void AppendAudioSessionApps(HashSet<int> seen, int selfPid,
|
||||||
|
List<AudioAppInfo> result)
|
||||||
|
{
|
||||||
|
IMMDeviceEnumerator? enumerator = null;
|
||||||
|
IMMDevice? device = null;
|
||||||
|
IAudioSessionManager2? manager = null;
|
||||||
|
IAudioSessionEnumerator? sessions = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
enumerator = (IMMDeviceEnumerator)Activator.CreateInstance(
|
||||||
|
Type.GetTypeFromCLSID(new Guid("BCDE0395-E52F-467C-8E3D-C4579291692E"))!)!;
|
||||||
|
|
||||||
|
enumerator.GetDefaultAudioEndpoint(0 /*eRender*/, 1 /*eMultimedia*/, out device);
|
||||||
|
|
||||||
|
var mgr2Iid = new Guid("77AA99A0-1BD6-484F-8BC7-2C654C9A9B6F");
|
||||||
|
device.Activate(ref mgr2Iid, 0x17 /*CLSCTX_ALL*/, IntPtr.Zero, out object mgr);
|
||||||
|
manager = (IAudioSessionManager2)mgr;
|
||||||
|
|
||||||
|
manager.GetSessionEnumerator(out sessions);
|
||||||
|
sessions.GetCount(out int count);
|
||||||
|
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
IAudioSessionControl? ctrl = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
sessions.GetSession(i, out ctrl);
|
||||||
|
var ctrl2 = (IAudioSessionControl2)ctrl;
|
||||||
|
ctrl2.GetProcessId(out uint pid);
|
||||||
|
|
||||||
|
int ipid = (int)pid;
|
||||||
|
if (pid == 0 || ipid == selfPid || !seen.Add(ipid)) continue;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var proc = Process.GetProcessById(ipid);
|
||||||
|
if (!string.IsNullOrEmpty(proc.ProcessName))
|
||||||
|
result.Add(new AudioAppInfo(ipid, proc.ProcessName));
|
||||||
|
}
|
||||||
|
catch { /* exited */ }
|
||||||
|
}
|
||||||
|
catch { /* stale session */ }
|
||||||
|
finally { if (ctrl != null) Marshal.ReleaseComObject(ctrl); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* no audio device or WASAPI unavailable — ignore */ }
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (sessions != null) Marshal.ReleaseComObject(sessions);
|
||||||
|
if (manager != null) Marshal.ReleaseComObject(manager);
|
||||||
|
if (device != null) Marshal.ReleaseComObject(device);
|
||||||
|
if (enumerator != null) Marshal.ReleaseComObject(enumerator);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── COM interface declarations ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
[ComImport, Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"),
|
||||||
|
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||||
|
internal interface IMMDeviceEnumerator
|
||||||
|
{
|
||||||
|
[PreserveSig] int EnumAudioEndpoints(int dataFlow, int stateMask, out IntPtr devices);
|
||||||
|
[PreserveSig] int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice endpoint);
|
||||||
|
[PreserveSig] int GetDevice([MarshalAs(UnmanagedType.LPWStr)] string id, out IMMDevice device);
|
||||||
|
[PreserveSig] int RegisterEndpointNotificationCallback(IntPtr client);
|
||||||
|
[PreserveSig] int UnregisterEndpointNotificationCallback(IntPtr client);
|
||||||
|
}
|
||||||
|
|
||||||
|
[ComImport, Guid("D666063F-1587-4E43-81F1-B948E807363F"),
|
||||||
|
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||||
|
internal interface IMMDevice
|
||||||
|
{
|
||||||
|
[PreserveSig] int Activate(ref Guid iid, int clsCtx, IntPtr activationParams,
|
||||||
|
[MarshalAs(UnmanagedType.IUnknown)] out object ppInterface);
|
||||||
|
[PreserveSig] int OpenPropertyStore(int stgmAccess, out IntPtr propStore);
|
||||||
|
[PreserveSig] int GetId([MarshalAs(UnmanagedType.LPWStr)] out string id);
|
||||||
|
[PreserveSig] int GetState(out int state);
|
||||||
|
}
|
||||||
|
|
||||||
|
[ComImport, Guid("77AA99A0-1BD6-484F-8BC7-2C654C9A9B6F"),
|
||||||
|
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||||
|
internal interface IAudioSessionManager2
|
||||||
|
{
|
||||||
|
[PreserveSig] int GetAudioSessionControl(ref Guid audioSessionGuid, int streamFlags,
|
||||||
|
out IAudioSessionControl session);
|
||||||
|
[PreserveSig] int GetSimpleAudioVolume(ref Guid audioSessionGuid, int streamFlags,
|
||||||
|
out IntPtr audioVolume);
|
||||||
|
[PreserveSig] int GetSessionEnumerator(out IAudioSessionEnumerator sessionEnum);
|
||||||
|
[PreserveSig] int RegisterSessionNotification(IntPtr notification);
|
||||||
|
[PreserveSig] int UnregisterSessionNotification(IntPtr notification);
|
||||||
|
[PreserveSig] int RegisterDuckNotification([MarshalAs(UnmanagedType.LPWStr)] string sessionID,
|
||||||
|
IntPtr notification);
|
||||||
|
[PreserveSig] int UnregisterDuckNotification(IntPtr notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
[ComImport, Guid("E2F5BB11-0570-40CA-ACDD-3AA01277DEE8"),
|
||||||
|
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||||
|
internal interface IAudioSessionEnumerator
|
||||||
|
{
|
||||||
|
[PreserveSig] int GetCount(out int sessionCount);
|
||||||
|
[PreserveSig] int GetSession(int sessionIndex, out IAudioSessionControl session);
|
||||||
|
}
|
||||||
|
|
||||||
|
[ComImport, Guid("F4B1A599-7266-4319-A8CA-E70ACB11E8CD"),
|
||||||
|
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||||
|
internal interface IAudioSessionControl
|
||||||
|
{
|
||||||
|
[PreserveSig] int GetState(out int state);
|
||||||
|
[PreserveSig] int GetDisplayName([MarshalAs(UnmanagedType.LPWStr)] out string name);
|
||||||
|
[PreserveSig] int SetDisplayName([MarshalAs(UnmanagedType.LPWStr)] string name,
|
||||||
|
ref Guid eventContext);
|
||||||
|
[PreserveSig] int GetIconPath([MarshalAs(UnmanagedType.LPWStr)] out string iconPath);
|
||||||
|
[PreserveSig] int SetIconPath([MarshalAs(UnmanagedType.LPWStr)] string iconPath,
|
||||||
|
ref Guid eventContext);
|
||||||
|
[PreserveSig] int GetGroupingParam(out Guid groupingParam);
|
||||||
|
[PreserveSig] int SetGroupingParam(ref Guid groupingParam, ref Guid eventContext);
|
||||||
|
[PreserveSig] int RegisterAudioSessionNotification(IntPtr notification);
|
||||||
|
[PreserveSig] int UnregisterAudioSessionNotification(IntPtr notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
[ComImport, Guid("BFB7FF88-7239-4FC9-8FA2-07C950BE9C6D"),
|
||||||
|
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||||
|
internal interface IAudioSessionControl2 : IAudioSessionControl
|
||||||
|
{
|
||||||
|
[PreserveSig] int GetSessionIdentifier([MarshalAs(UnmanagedType.LPWStr)] out string id);
|
||||||
|
[PreserveSig] int GetSessionInstanceIdentifier([MarshalAs(UnmanagedType.LPWStr)] out string id);
|
||||||
|
[PreserveSig] int GetProcessId(out uint pid);
|
||||||
|
[PreserveSig] int IsSystemSoundsSession();
|
||||||
|
[PreserveSig] int SetDuckingPreference(bool optOut);
|
||||||
|
}
|
||||||
|
}
|
||||||
146
clients/windows/VoiceCat.App/Audio/ProcessAudioMixer.cs
Normal file
146
clients/windows/VoiceCat.App/Audio/ProcessAudioMixer.cs
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
using VoiceCat.Interop;
|
||||||
|
|
||||||
|
// Owns N ProcessLoopbackCapture instances, mixes their PCM every 20 ms, and feeds
|
||||||
|
// the result to the core via vc_stream_feed_pcm. Used for per-app audio sharing.
|
||||||
|
namespace VoiceCat.App.Audio;
|
||||||
|
|
||||||
|
public sealed class ProcessAudioMixer : IDisposable
|
||||||
|
{
|
||||||
|
private const int SampleRate = 48000;
|
||||||
|
private const int FrameSamples = 960;
|
||||||
|
private const int Channels = 2; // stereo; captures fall back to mono if needed
|
||||||
|
|
||||||
|
private readonly List<ProcessLoopbackCapture> _captures = [];
|
||||||
|
|
||||||
|
// Per-capture latest frame, protected by _frameLock.
|
||||||
|
private readonly object _frameLock = new();
|
||||||
|
private List<short[]> _latestFrames = [];
|
||||||
|
private int _activeChannels = Channels;
|
||||||
|
|
||||||
|
private Thread? _mixThread;
|
||||||
|
private volatile bool _running;
|
||||||
|
private VoiceCatClient? _client;
|
||||||
|
private uint _streamId;
|
||||||
|
|
||||||
|
public void Start(AppAudioScope scope, VoiceCatClient client, uint streamId,
|
||||||
|
IReadOnlyList<AudioAppInfo> allApps)
|
||||||
|
{
|
||||||
|
if (_running) return;
|
||||||
|
_client = client;
|
||||||
|
_streamId = streamId;
|
||||||
|
|
||||||
|
var pids = ResolvePids(scope, allApps);
|
||||||
|
if (pids.Count == 0)
|
||||||
|
{
|
||||||
|
// nothing to capture — scope resolved to empty set
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_frameLock)
|
||||||
|
{
|
||||||
|
_latestFrames = new List<short[]>(new short[pids.Count][]);
|
||||||
|
_activeChannels = Channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < pids.Count; i++)
|
||||||
|
{
|
||||||
|
int captureIndex = i;
|
||||||
|
var cap = new ProcessLoopbackCapture(pids[i], ProcessLoopbackCapture.Mode.Include);
|
||||||
|
cap.PcmFrameReady += (pcm, spc, ch) => OnCaptureFrame(captureIndex, pcm, ch);
|
||||||
|
_captures.Add(cap);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var c in _captures) c.Start();
|
||||||
|
|
||||||
|
_running = true;
|
||||||
|
_mixThread = new Thread(MixLoop) { IsBackground = true, Name = "ProcessAudioMixer" };
|
||||||
|
_mixThread.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Stop()
|
||||||
|
{
|
||||||
|
_running = false;
|
||||||
|
_mixThread?.Join(500);
|
||||||
|
foreach (var c in _captures) { c.Stop(); c.Dispose(); }
|
||||||
|
_captures.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => Stop();
|
||||||
|
|
||||||
|
// ── Capture callback ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void OnCaptureFrame(int index, short[] pcm, int channels)
|
||||||
|
{
|
||||||
|
lock (_frameLock)
|
||||||
|
{
|
||||||
|
// Upmix mono → stereo interleave if the capture fell back to mono.
|
||||||
|
if (channels == 1 && _activeChannels == 2)
|
||||||
|
pcm = MonoToStereo(pcm);
|
||||||
|
if (index < _latestFrames.Count)
|
||||||
|
_latestFrames[index] = pcm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mix loop (20 ms timer) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void MixLoop()
|
||||||
|
{
|
||||||
|
// Use a target period close to 20 ms; small under-shoot avoids accumulating drift.
|
||||||
|
const int periodMs = 19;
|
||||||
|
while (_running)
|
||||||
|
{
|
||||||
|
Thread.Sleep(periodMs);
|
||||||
|
if (!_running) break;
|
||||||
|
|
||||||
|
short[] mix;
|
||||||
|
lock (_frameLock)
|
||||||
|
{
|
||||||
|
int len = FrameSamples * _activeChannels;
|
||||||
|
mix = new short[len];
|
||||||
|
|
||||||
|
foreach (var frame in _latestFrames)
|
||||||
|
{
|
||||||
|
if (frame == null) continue;
|
||||||
|
int frameLen = Math.Min(frame.Length, len);
|
||||||
|
for (int i = 0; i < frameLen; i++)
|
||||||
|
{
|
||||||
|
int sum = mix[i] + frame[i];
|
||||||
|
// Saturating clamp
|
||||||
|
mix[i] = (short)Math.Clamp(sum, short.MinValue, short.MaxValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_client?.StreamFeedPcm(_streamId, mix, FrameSamples, (uint)_activeChannels);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// For "AllExcept": enumerate all running audio apps and exclude the specified ones.
|
||||||
|
// For "OnlyApps": use their PIDs directly.
|
||||||
|
private static List<int> ResolvePids(AppAudioScope scope, IReadOnlyList<AudioAppInfo> allApps)
|
||||||
|
{
|
||||||
|
return scope switch
|
||||||
|
{
|
||||||
|
OnlyApps o => [.. o.Pids],
|
||||||
|
AllExceptApps a =>
|
||||||
|
allApps
|
||||||
|
.Where(app => !a.Pids.Contains(app.Pid))
|
||||||
|
.Select(app => app.Pid)
|
||||||
|
.ToList(),
|
||||||
|
_ => [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static short[] MonoToStereo(short[] mono)
|
||||||
|
{
|
||||||
|
var stereo = new short[mono.Length * 2];
|
||||||
|
for (int i = 0; i < mono.Length; i++)
|
||||||
|
{
|
||||||
|
stereo[i * 2] = mono[i];
|
||||||
|
stereo[i * 2 + 1] = mono[i];
|
||||||
|
}
|
||||||
|
return stereo;
|
||||||
|
}
|
||||||
|
}
|
||||||
434
clients/windows/VoiceCat.App/Audio/ProcessLoopbackCapture.cs
Normal file
434
clients/windows/VoiceCat.App/Audio/ProcessLoopbackCapture.cs
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
// Single-process WASAPI loopback capture via AUDIOCLIENT_ACTIVATION_PARAMS
|
||||||
|
// (Windows 10 2004+ / Build 19041+).
|
||||||
|
//
|
||||||
|
// Threading: ALL WASAPI init runs on the capture thread (MTA). If called from the
|
||||||
|
// WinForms UI thread (STA), ActivateAudioInterfaceAsync fires ActivateCompleted on
|
||||||
|
// an MTA pool thread; COM marshals that back to the STA pump — but the STA thread is
|
||||||
|
// blocked on CompletionEvent.Wait → deadlock. MTA capture thread avoids this.
|
||||||
|
//
|
||||||
|
// COM QI policy: the COM objects returned by the process-loopback activation path
|
||||||
|
// reject QueryInterface for their own IIDs under .NET's RCW mechanism. Every call
|
||||||
|
// to IAudioClient and IAudioCaptureClient is therefore dispatched via raw vtable
|
||||||
|
// pointer arithmetic, bypassing .NET COM interop entirely.
|
||||||
|
namespace VoiceCat.App.Audio;
|
||||||
|
|
||||||
|
public sealed class ProcessLoopbackCapture : IDisposable
|
||||||
|
{
|
||||||
|
public enum Mode { Include, Exclude }
|
||||||
|
|
||||||
|
// Fired on the capture thread every 20 ms (960 samples/channel @ 48 kHz, interleaved s16).
|
||||||
|
public event Action<short[], int /*samplesPerChannel*/, int /*channels*/>? PcmFrameReady;
|
||||||
|
|
||||||
|
private const int SampleRate = 48000;
|
||||||
|
private const int FrameSamples = 960; // 20 ms
|
||||||
|
private const string LoopbackDevicePath = "VAD\\Process_Loopback";
|
||||||
|
|
||||||
|
private readonly int _pid;
|
||||||
|
private readonly Mode _mode;
|
||||||
|
|
||||||
|
// Raw COM pointers — managed via explicit AddRef/Release, no RCW wrapping.
|
||||||
|
private IntPtr _audioClientPtr; // IAudioClient*
|
||||||
|
private IntPtr _captureClientPtr; // IAudioCaptureClient*
|
||||||
|
|
||||||
|
private AutoResetEvent? _bufferEvent;
|
||||||
|
private Thread? _captureThread;
|
||||||
|
private volatile bool _running;
|
||||||
|
private int _channels;
|
||||||
|
|
||||||
|
// Accumulator: assembles driver-callback-sized fragments into FrameSamples chunks.
|
||||||
|
private short[] _accumBuf = [];
|
||||||
|
private int _accumCount;
|
||||||
|
|
||||||
|
// Init-done signal: Set() by the capture thread after ActivateClient() completes.
|
||||||
|
private readonly ManualResetEventSlim _initDone = new(false);
|
||||||
|
private bool _initOk;
|
||||||
|
|
||||||
|
public ProcessLoopbackCapture(int pid, Mode mode)
|
||||||
|
{
|
||||||
|
_pid = pid;
|
||||||
|
_mode = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Starts capture. Blocks until WASAPI activation completes (typically <100 ms).
|
||||||
|
/// Returns false if the process cannot be captured.</summary>
|
||||||
|
public bool Start()
|
||||||
|
{
|
||||||
|
if (_running) return false;
|
||||||
|
_running = true;
|
||||||
|
_captureThread = new Thread(CaptureThreadProc)
|
||||||
|
{
|
||||||
|
IsBackground = true,
|
||||||
|
Name = $"ProcLoopback:{_pid}",
|
||||||
|
};
|
||||||
|
_captureThread.Start();
|
||||||
|
|
||||||
|
bool ok = _initDone.Wait(5000) && _initOk;
|
||||||
|
if (!ok) _running = false;
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Stop()
|
||||||
|
{
|
||||||
|
_running = false;
|
||||||
|
_bufferEvent?.Set();
|
||||||
|
_captureThread?.Join(500);
|
||||||
|
if (_audioClientPtr != IntPtr.Zero) AC_Stop(_audioClientPtr);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Stop();
|
||||||
|
ComRelease(ref _captureClientPtr);
|
||||||
|
ComRelease(ref _audioClientPtr);
|
||||||
|
_bufferEvent?.Dispose();
|
||||||
|
_initDone.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Capture thread (MTA) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void CaptureThreadProc()
|
||||||
|
{
|
||||||
|
_initOk = ActivateAndStart();
|
||||||
|
_initDone.Set();
|
||||||
|
if (!_initOk) return;
|
||||||
|
CaptureLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ActivateAndStart()
|
||||||
|
{
|
||||||
|
if (!ActivateClient()) return false;
|
||||||
|
|
||||||
|
_bufferEvent = new AutoResetEvent(false);
|
||||||
|
if (AC_SetEventHandle(_audioClientPtr, _bufferEvent.SafeWaitHandle.DangerousGetHandle()) < 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return AC_Start(_audioClientPtr) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Activation ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private unsafe bool ActivateClient()
|
||||||
|
{
|
||||||
|
var activationParams = new AudioClientActivationParams
|
||||||
|
{
|
||||||
|
ActivationType = 1, // AUDCLNT_ACTIVATIONTYPE_PROCESS_LOOPBACK
|
||||||
|
TargetProcessId = (uint)_pid,
|
||||||
|
ProcessLoopbackMode = _mode == Mode.Include ? 0u : 1u,
|
||||||
|
};
|
||||||
|
|
||||||
|
var handler = new ActivationCompletionHandler();
|
||||||
|
var audioClientIid = new Guid("1CB9AD4C-DBFA-4c32-B178-C2F568A703B2");
|
||||||
|
|
||||||
|
IntPtr opPtr;
|
||||||
|
{
|
||||||
|
AudioClientActivationParams* pParams = &activationParams;
|
||||||
|
// PROPVARIANT (VT_BLOB) x64: vt(2)+res(6)+cbSize(4)+pad(4)+pBlobData(8) = 24 B.
|
||||||
|
var pv = stackalloc byte[24];
|
||||||
|
*(ushort*)(pv + 0) = 65;
|
||||||
|
*(uint*) (pv + 8) = (uint)sizeof(AudioClientActivationParams);
|
||||||
|
*(nint*) (pv + 16) = (nint)pParams;
|
||||||
|
|
||||||
|
int hr = ActivateAudioInterfaceAsync(
|
||||||
|
LoopbackDevicePath, ref audioClientIid,
|
||||||
|
(IntPtr)pv, handler, out opPtr);
|
||||||
|
if (hr < 0) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!handler.CompletionEvent.Wait(3000))
|
||||||
|
{
|
||||||
|
ComRelease(ref opPtr);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call GetActivateResult via vtable (slot 3) — avoids QI on the async-op object.
|
||||||
|
if (!Vtable_GetActivateResult(handler.OperationPtr, out int activateHr, out IntPtr activatedPtr))
|
||||||
|
{
|
||||||
|
handler.ReleaseOp();
|
||||||
|
ComRelease(ref opPtr);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
handler.ReleaseOp();
|
||||||
|
ComRelease(ref opPtr);
|
||||||
|
|
||||||
|
if (activateHr < 0 || activatedPtr == IntPtr.Zero) return false;
|
||||||
|
|
||||||
|
// Store the raw IAudioClient* — do NOT create an RCW; use vtable dispatch instead.
|
||||||
|
_audioClientPtr = activatedPtr;
|
||||||
|
// activatedPtr already has ref count from GetActivateResult; don't double-release.
|
||||||
|
|
||||||
|
return InitializeStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
// IActivateAudioInterfaceAsyncOperation vtable slot 3: GetActivateResult(HRESULT*, IUnknown**)
|
||||||
|
private static unsafe bool Vtable_GetActivateResult(IntPtr op,
|
||||||
|
out int activateHr, out IntPtr activatedPtr)
|
||||||
|
{
|
||||||
|
activateHr = unchecked((int)0x80004005);
|
||||||
|
activatedPtr = IntPtr.Zero;
|
||||||
|
if (op == IntPtr.Zero) return false;
|
||||||
|
void** vtable = *(void***)op.ToPointer();
|
||||||
|
var fn = (delegate* unmanaged[Stdcall]<IntPtr, int*, IntPtr*, int>)vtable[3];
|
||||||
|
fixed (int* pHr = &activateHr)
|
||||||
|
fixed (IntPtr* pPtr = &activatedPtr)
|
||||||
|
return fn(op, pHr, pPtr) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private unsafe bool InitializeStream()
|
||||||
|
{
|
||||||
|
// Try s16 stereo first; fall back to s16 mono.
|
||||||
|
foreach (int ch in new[] { 2, 1 })
|
||||||
|
{
|
||||||
|
var fmt = new WaveFormatEx
|
||||||
|
{
|
||||||
|
wFormatTag = 1, // WAVE_FORMAT_PCM
|
||||||
|
nChannels = (ushort)ch,
|
||||||
|
nSamplesPerSec = SampleRate,
|
||||||
|
wBitsPerSample = 16,
|
||||||
|
nBlockAlign = (ushort)(ch * 2),
|
||||||
|
nAvgBytesPerSec = (uint)(SampleRate * ch * 2),
|
||||||
|
cbSize = 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process-loopback requires LOOPBACK (deliver rendered audio) + EVENTCALLBACK +
|
||||||
|
// AUTOCONVERTPCM (resample the app's native format to our requested s16/48k). Without
|
||||||
|
// LOOPBACK every buffer comes back AUDCLNT_BUFFERFLAGS_SILENT; without AUTOCONVERTPCM
|
||||||
|
// the requested format is rejected. Matches the MS ApplicationLoopback sample.
|
||||||
|
// AUDCLNT_SHAREMODE_SHARED = 0
|
||||||
|
// AUDCLNT_STREAMFLAGS_LOOPBACK = 0x00020000
|
||||||
|
// AUDCLNT_STREAMFLAGS_EVENTCALLBACK = 0x00040000
|
||||||
|
// AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM = 0x80000000
|
||||||
|
const uint streamFlags = 0x00020000u | 0x00040000u | 0x80000000u;
|
||||||
|
int hr = AC_Initialize(_audioClientPtr, 0, streamFlags,
|
||||||
|
2_000_000 /*200ms hns*/, 0, &fmt, null);
|
||||||
|
if (hr >= 0)
|
||||||
|
{
|
||||||
|
_channels = ch;
|
||||||
|
_accumBuf = new short[FrameSamples * ch];
|
||||||
|
_accumCount = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (ch == 1) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var captureIid = new Guid("C8ADBD64-E71E-48a0-A4DE-185C395CD317");
|
||||||
|
int getHr = AC_GetService(_audioClientPtr, ref captureIid, out _captureClientPtr);
|
||||||
|
return getHr >= 0 && _captureClientPtr != IntPtr.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Capture loop ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void CaptureLoop()
|
||||||
|
{
|
||||||
|
while (_running)
|
||||||
|
{
|
||||||
|
_bufferEvent!.WaitOne(100);
|
||||||
|
if (!_running) break;
|
||||||
|
|
||||||
|
while (_running)
|
||||||
|
{
|
||||||
|
int hr = CC_GetNextPacketSize(_captureClientPtr, out uint packetSize);
|
||||||
|
if (hr < 0 || packetSize == 0) break;
|
||||||
|
|
||||||
|
hr = CC_GetBuffer(_captureClientPtr, out IntPtr dataPtr, out uint framesAvailable,
|
||||||
|
out uint flags);
|
||||||
|
if (hr < 0) break;
|
||||||
|
|
||||||
|
bool silent = (flags & 2) != 0; // AUDCLNT_BUFFERFLAGS_SILENT
|
||||||
|
if (framesAvailable > 0)
|
||||||
|
{
|
||||||
|
if (silent) AccumulateSilence((int)framesAvailable);
|
||||||
|
else AccumulatePcm(dataPtr, (int)framesAvailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
CC_ReleaseBuffer(_captureClientPtr, framesAvailable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private unsafe void AccumulatePcm(IntPtr data, int frames)
|
||||||
|
{
|
||||||
|
var src = (short*)data.ToPointer();
|
||||||
|
int total = frames * _channels;
|
||||||
|
int idx = 0;
|
||||||
|
while (idx < total)
|
||||||
|
{
|
||||||
|
int space = _accumBuf.Length - _accumCount;
|
||||||
|
int copy = Math.Min(total - idx, space);
|
||||||
|
fixed (short* dst = _accumBuf)
|
||||||
|
Buffer.MemoryCopy(src + idx, dst + _accumCount, copy * 2L, copy * 2L);
|
||||||
|
_accumCount += copy;
|
||||||
|
idx += copy;
|
||||||
|
if (_accumCount == _accumBuf.Length)
|
||||||
|
FlushFrame();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AccumulateSilence(int frames)
|
||||||
|
{
|
||||||
|
int total = frames * _channels;
|
||||||
|
int idx = 0;
|
||||||
|
while (idx < total)
|
||||||
|
{
|
||||||
|
int space = _accumBuf.Length - _accumCount;
|
||||||
|
int fill = Math.Min(total - idx, space);
|
||||||
|
Array.Clear(_accumBuf, _accumCount, fill);
|
||||||
|
_accumCount += fill;
|
||||||
|
idx += fill;
|
||||||
|
if (_accumCount == _accumBuf.Length)
|
||||||
|
FlushFrame();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FlushFrame()
|
||||||
|
{
|
||||||
|
var copy = new short[_accumBuf.Length];
|
||||||
|
_accumBuf.AsSpan().CopyTo(copy);
|
||||||
|
PcmFrameReady?.Invoke(copy, FrameSamples, _channels);
|
||||||
|
_accumCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IAudioClient vtable helpers (raw dispatch, no RCW / no QI) ───────────
|
||||||
|
//
|
||||||
|
// Vtable layout (IUnknown base: 0=QI 1=AddRef 2=Release; then IAudioClient methods):
|
||||||
|
// 3=Initialize 4=GetBufferSize 5=GetStreamLatency 6=GetCurrentPadding
|
||||||
|
// 7=IsFormatSupported 8=GetMixFormat 9=GetDevicePeriod
|
||||||
|
// 10=Start 11=Stop 12=Reset 13=SetEventHandle 14=GetService
|
||||||
|
|
||||||
|
private static unsafe int AC_Initialize(IntPtr ac, int shareMode, uint streamFlags,
|
||||||
|
long hnsBufferDuration, long hnsPeriodicity, WaveFormatEx* pFormat, Guid* pSession)
|
||||||
|
{
|
||||||
|
var fn = (delegate* unmanaged[Stdcall]<IntPtr, int, uint, long, long, WaveFormatEx*, Guid*, int>)
|
||||||
|
(*(void***)ac)[3];
|
||||||
|
return fn(ac, shareMode, streamFlags, hnsBufferDuration, hnsPeriodicity, pFormat, pSession);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static unsafe int AC_Start(IntPtr ac)
|
||||||
|
{
|
||||||
|
var fn = (delegate* unmanaged[Stdcall]<IntPtr, int>)(*(void***)ac)[10];
|
||||||
|
return fn(ac);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static unsafe int AC_Stop(IntPtr ac)
|
||||||
|
{
|
||||||
|
var fn = (delegate* unmanaged[Stdcall]<IntPtr, int>)(*(void***)ac)[11];
|
||||||
|
return fn(ac);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static unsafe int AC_SetEventHandle(IntPtr ac, IntPtr eventHandle)
|
||||||
|
{
|
||||||
|
var fn = (delegate* unmanaged[Stdcall]<IntPtr, IntPtr, int>)(*(void***)ac)[13];
|
||||||
|
return fn(ac, eventHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static unsafe int AC_GetService(IntPtr ac, ref Guid riid, out IntPtr ppv)
|
||||||
|
{
|
||||||
|
var fn = (delegate* unmanaged[Stdcall]<IntPtr, Guid*, IntPtr*, int>)(*(void***)ac)[14];
|
||||||
|
fixed (Guid* pIid = &riid)
|
||||||
|
fixed (IntPtr* pPpv = &ppv)
|
||||||
|
return fn(ac, pIid, pPpv);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IAudioCaptureClient vtable helpers ────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Vtable (IUnknown: 0-2; then): 3=GetBuffer 4=ReleaseBuffer 5=GetNextPacketSize
|
||||||
|
|
||||||
|
private static unsafe int CC_GetBuffer(IntPtr cc, out IntPtr ppData,
|
||||||
|
out uint pNumFrames, out uint pdwFlags)
|
||||||
|
{
|
||||||
|
var fn = (delegate* unmanaged[Stdcall]<IntPtr, IntPtr*, uint*, uint*, ulong*, ulong*, int>)
|
||||||
|
(*(void***)cc)[3];
|
||||||
|
ulong devPos = 0, qpcPos = 0;
|
||||||
|
fixed (IntPtr* p0 = &ppData)
|
||||||
|
fixed (uint* p1 = &pNumFrames)
|
||||||
|
fixed (uint* p2 = &pdwFlags)
|
||||||
|
return fn(cc, p0, p1, p2, &devPos, &qpcPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static unsafe int CC_ReleaseBuffer(IntPtr cc, uint numFrames)
|
||||||
|
{
|
||||||
|
var fn = (delegate* unmanaged[Stdcall]<IntPtr, uint, int>)(*(void***)cc)[4];
|
||||||
|
return fn(cc, numFrames);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static unsafe int CC_GetNextPacketSize(IntPtr cc, out uint pNumFrames)
|
||||||
|
{
|
||||||
|
var fn = (delegate* unmanaged[Stdcall]<IntPtr, uint*, int>)(*(void***)cc)[5];
|
||||||
|
fixed (uint* p = &pNumFrames)
|
||||||
|
return fn(cc, p);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── COM utilities ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static unsafe void ComRelease(ref IntPtr ptr)
|
||||||
|
{
|
||||||
|
if (ptr == IntPtr.Zero) return;
|
||||||
|
var fn = (delegate* unmanaged[Stdcall]<IntPtr, uint>)(*(void***)ptr)[2]; // IUnknown::Release
|
||||||
|
fn(ptr);
|
||||||
|
ptr = IntPtr.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── P/Invoke & structs ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[DllImport("Mmdevapi.dll", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern int ActivateAudioInterfaceAsync(
|
||||||
|
string deviceInterfacePath,
|
||||||
|
ref Guid riid,
|
||||||
|
IntPtr activationParams,
|
||||||
|
[MarshalAs(UnmanagedType.Interface)] IActivateAudioInterfaceCompletionHandler completionHandler,
|
||||||
|
out IntPtr activationOperation);
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct AudioClientActivationParams
|
||||||
|
{
|
||||||
|
public int ActivationType; // AUDCLNT_ACTIVATIONTYPE_PROCESS_LOOPBACK = 1
|
||||||
|
public uint TargetProcessId;
|
||||||
|
public uint ProcessLoopbackMode; // INCLUDE=0, EXCLUDE=1
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential, Pack = 2)]
|
||||||
|
private struct WaveFormatEx
|
||||||
|
{
|
||||||
|
public ushort wFormatTag;
|
||||||
|
public ushort nChannels;
|
||||||
|
public uint nSamplesPerSec;
|
||||||
|
public uint nAvgBytesPerSec;
|
||||||
|
public ushort nBlockAlign;
|
||||||
|
public ushort wBitsPerSample;
|
||||||
|
public ushort cbSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Completion handler CCW ────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Only this object still uses .NET COM interop (as a CCW). The activateOperation
|
||||||
|
// parameter is IntPtr to avoid QI on the incoming async-op pointer.
|
||||||
|
|
||||||
|
[ComVisible(true), ClassInterface(ClassInterfaceType.None)]
|
||||||
|
private sealed class ActivationCompletionHandler : IActivateAudioInterfaceCompletionHandler
|
||||||
|
{
|
||||||
|
public readonly ManualResetEventSlim CompletionEvent = new(false);
|
||||||
|
public IntPtr OperationPtr { get; private set; }
|
||||||
|
|
||||||
|
public void ActivateCompleted(IntPtr activateOperation)
|
||||||
|
{
|
||||||
|
OperationPtr = activateOperation;
|
||||||
|
if (OperationPtr != IntPtr.Zero) Marshal.AddRef(OperationPtr);
|
||||||
|
CompletionEvent.Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ReleaseOp()
|
||||||
|
{
|
||||||
|
if (OperationPtr == IntPtr.Zero) return;
|
||||||
|
Marshal.Release(OperationPtr);
|
||||||
|
OperationPtr = IntPtr.Zero;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[ComImport, Guid("41D949AB-9862-444A-80F6-C261334DA5EB"),
|
||||||
|
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||||
|
private interface IActivateAudioInterfaceCompletionHandler
|
||||||
|
{
|
||||||
|
void ActivateCompleted(IntPtr activateOperation);
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
166
clients/windows/VoiceCat.App/Forms/AppAudioPickerDialog.cs
Normal file
166
clients/windows/VoiceCat.App/Forms/AppAudioPickerDialog.cs
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
using VoiceCat.App.Audio;
|
||||||
|
|
||||||
|
namespace VoiceCat.App.Forms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Modal dialog for selecting which apps' audio to share.
|
||||||
|
/// Returns the chosen <see cref="AppAudioScope"/> via <see cref="ChosenScope"/>,
|
||||||
|
/// or <see cref="DialogResult.Cancel"/> if the user dismissed without sharing.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AppAudioPickerDialog : Form
|
||||||
|
{
|
||||||
|
private readonly RadioButton _rdoAll;
|
||||||
|
private readonly RadioButton _rdoOnly;
|
||||||
|
private readonly RadioButton _rdoExcept;
|
||||||
|
private readonly ListView _appList;
|
||||||
|
private readonly Label _lblApps;
|
||||||
|
|
||||||
|
// Snapshot taken when the dialog opens (refresh on open, not on every check change).
|
||||||
|
private IReadOnlyList<AudioAppInfo> _apps = [];
|
||||||
|
|
||||||
|
public AppAudioScope? ChosenScope { get; private set; }
|
||||||
|
|
||||||
|
public AppAudioPickerDialog()
|
||||||
|
{
|
||||||
|
// ── Radio buttons ───────────────────────────────────────────────────
|
||||||
|
_rdoAll = new RadioButton
|
||||||
|
{
|
||||||
|
Text = "&Entire desktop",
|
||||||
|
Checked = true,
|
||||||
|
Location = new Point(12, 12),
|
||||||
|
Size = new Size(360, 20),
|
||||||
|
TabIndex = 0,
|
||||||
|
};
|
||||||
|
_rdoOnly = new RadioButton
|
||||||
|
{
|
||||||
|
Text = "&Only selected apps",
|
||||||
|
Location = new Point(12, 36),
|
||||||
|
Size = new Size(360, 20),
|
||||||
|
TabIndex = 1,
|
||||||
|
};
|
||||||
|
_rdoExcept = new RadioButton
|
||||||
|
{
|
||||||
|
Text = "All apps e&xcept selected",
|
||||||
|
Location = new Point(12, 60),
|
||||||
|
Size = new Size(360, 20),
|
||||||
|
TabIndex = 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
_rdoAll.CheckedChanged += OnModeChanged;
|
||||||
|
_rdoOnly.CheckedChanged += OnModeChanged;
|
||||||
|
_rdoExcept.CheckedChanged += OnModeChanged;
|
||||||
|
|
||||||
|
// ── App list ────────────────────────────────────────────────────────
|
||||||
|
_lblApps = new Label
|
||||||
|
{
|
||||||
|
Text = "Apps with active audio sessions:",
|
||||||
|
AutoSize = true,
|
||||||
|
Location = new Point(12, 90),
|
||||||
|
Visible = false,
|
||||||
|
TabIndex = 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
_appList = new ListView
|
||||||
|
{
|
||||||
|
Location = new Point(12, 112),
|
||||||
|
Size = new Size(360, 180),
|
||||||
|
CheckBoxes = true,
|
||||||
|
View = View.List,
|
||||||
|
Visible = false,
|
||||||
|
TabIndex = 4,
|
||||||
|
FullRowSelect = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Buttons ─────────────────────────────────────────────────────────
|
||||||
|
var btnShare = new Button
|
||||||
|
{
|
||||||
|
Text = "&Share",
|
||||||
|
DialogResult = DialogResult.OK,
|
||||||
|
Location = new Point(216, 308),
|
||||||
|
Size = new Size(75, 27),
|
||||||
|
TabIndex = 5,
|
||||||
|
};
|
||||||
|
var btnCancel = new Button
|
||||||
|
{
|
||||||
|
Text = "&Cancel",
|
||||||
|
DialogResult = DialogResult.Cancel,
|
||||||
|
Location = new Point(297, 308),
|
||||||
|
Size = new Size(75, 27),
|
||||||
|
TabIndex = 6,
|
||||||
|
};
|
||||||
|
btnShare.Click += OnShareClick;
|
||||||
|
|
||||||
|
AcceptButton = btnShare;
|
||||||
|
CancelButton = btnCancel;
|
||||||
|
AutoScaleMode = AutoScaleMode.Font;
|
||||||
|
ClientSize = new Size(384, 348);
|
||||||
|
Controls.AddRange([_rdoAll, _rdoOnly, _rdoExcept, _lblApps, _appList, btnShare, btnCancel]);
|
||||||
|
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||||
|
MaximizeBox = false;
|
||||||
|
MinimizeBox = false;
|
||||||
|
StartPosition = FormStartPosition.CenterParent;
|
||||||
|
Text = "Share App Audio";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnLoad(EventArgs e)
|
||||||
|
{
|
||||||
|
base.OnLoad(e);
|
||||||
|
RefreshAppList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshAppList()
|
||||||
|
{
|
||||||
|
_apps = AudioSessionEnumerator.GetAudioApps();
|
||||||
|
_appList.Items.Clear();
|
||||||
|
foreach (var app in _apps)
|
||||||
|
_appList.Items.Add(new ListViewItem($"{app.DisplayName} (PID {app.Pid})") { Tag = app.Pid });
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnModeChanged(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
bool showList = _rdoOnly.Checked || _rdoExcept.Checked;
|
||||||
|
_lblApps.Visible = showList;
|
||||||
|
_appList.Visible = showList;
|
||||||
|
|
||||||
|
if (showList && _appList.Items.Count == 0)
|
||||||
|
RefreshAppList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnShareClick(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (_rdoAll.Checked)
|
||||||
|
{
|
||||||
|
ChosenScope = new EntireDesktop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var checkedPids = new List<int>();
|
||||||
|
var checkedNames = new List<string>();
|
||||||
|
foreach (ListViewItem item in _appList.CheckedItems)
|
||||||
|
{
|
||||||
|
if (item.Tag is int pid)
|
||||||
|
{
|
||||||
|
checkedPids.Add(pid);
|
||||||
|
checkedNames.Add(item.Text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkedPids.Count == 0)
|
||||||
|
{
|
||||||
|
MessageBox.Show(
|
||||||
|
"Select at least one app, or choose 'Entire desktop'.",
|
||||||
|
"No apps selected",
|
||||||
|
MessageBoxButtons.OK,
|
||||||
|
MessageBoxIcon.Warning);
|
||||||
|
DialogResult = DialogResult.None; // prevent close
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ChosenScope = _rdoOnly.Checked
|
||||||
|
? new OnlyApps(checkedPids, checkedNames)
|
||||||
|
: new AllExceptApps(checkedPids, checkedNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>All apps that were visible in the list when the user clicked Share.</summary>
|
||||||
|
public IReadOnlyList<AudioAppInfo> VisibleApps => _apps;
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
397
clients/windows/VoiceCat.App/Forms/ChannelEditDialog.cs
Normal file
397
clients/windows/VoiceCat.App/Forms/ChannelEditDialog.cs
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
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 CheckBox _chkDred = 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, false);
|
||||||
|
|
||||||
|
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);
|
||||||
|
y += 28;
|
||||||
|
|
||||||
|
_chkDred = new CheckBox
|
||||||
|
{
|
||||||
|
Text = "D&RED (deep redundancy)",
|
||||||
|
Location = new Point(inputX, y),
|
||||||
|
AutoSize = true,
|
||||||
|
Checked = audio.Dred,
|
||||||
|
TabIndex = 19,
|
||||||
|
};
|
||||||
|
page.Controls.Add(_chkDred);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
Dred: _chkDred.Checked);
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
1083
clients/windows/VoiceCat.App/Forms/MainForm.cs
Normal file
1083
clients/windows/VoiceCat.App/Forms/MainForm.cs
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user