Compare commits
106
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6c003b8a7 | ||
|
|
4f71b784fe | ||
|
|
575e2907d0 | ||
|
|
f687370e47 | ||
|
|
552ffceb66 | ||
|
|
a4b3838125 | ||
|
|
eafa5eb90c | ||
|
|
158a2df062 | ||
|
|
826b3bfb86 | ||
|
|
cc81d19d02 | ||
|
|
8844325efa | ||
|
|
bd844e4710 | ||
|
|
5c03e5f261 | ||
|
|
bba605401d | ||
|
|
bda37ec27b | ||
|
|
9612b0af89 | ||
|
|
2c8178fa02 | ||
|
|
9a20953c08 | ||
|
|
99937c9446 | ||
|
|
44a336cc89 | ||
|
|
6fe7bf0158 | ||
|
|
2baefddbe4 | ||
|
|
65736df464 | ||
|
|
8bb2ba933c | ||
|
|
7ef560ba8a | ||
|
|
47124b15a2 | ||
|
|
04bdb70d47 | ||
|
|
b44a200b95 | ||
|
|
f72219ddf3 | ||
|
|
b14cf2a4e8 | ||
|
|
19c2fb6ec9 | ||
|
|
cd9c08a47a | ||
|
|
2e0e0caccb | ||
|
|
bad9c7533a | ||
|
|
7249a8fd30 | ||
|
|
a48b47d4ca | ||
|
|
95f1fb70b0 | ||
|
|
d30c4ee2f5 | ||
|
|
7547b8e140 | ||
|
|
ce2035f271 | ||
|
|
e155e342f4 | ||
|
|
a460009a2f | ||
|
|
50416c33a2 | ||
|
|
725bd8e925 | ||
|
|
483f889910 | ||
|
|
5e18dfa1c9 | ||
|
|
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 |
@@ -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
|
||||||
@@ -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
|
||||||
+18
-3
@@ -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
|
||||||
@@ -11,9 +14,8 @@
|
|||||||
*.exe
|
*.exe
|
||||||
*.pdb
|
*.pdb
|
||||||
|
|
||||||
# vcpkg
|
# vcpkg (bundled as a submodule at /vcpkg — see docs/building.md §2)
|
||||||
/vcpkg_installed/
|
/vcpkg_installed/
|
||||||
/vcpkg/
|
|
||||||
|
|
||||||
# Generated protobuf
|
# Generated protobuf
|
||||||
*.pb.cc
|
*.pb.cc
|
||||||
@@ -33,8 +35,21 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Apple / Windows client build artifacts (added in M4)
|
# Apple / Windows client build artifacts
|
||||||
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__/
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "vcpkg"]
|
||||||
|
path = vcpkg
|
||||||
|
url = https://github.com/microsoft/vcpkg.git
|
||||||
@@ -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,43 @@ making progress.
|
|||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
Skeleton (no third-party deps — works immediately):
|
Default development preset (real deps via vcpkg — works on Windows/Linux/macOS). vcpkg is
|
||||||
|
bundled as a git submodule at `vcpkg/`, pinned to `vcpkg.json`'s `builtin-baseline`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
git submodule update --init vcpkg # one-time, after cloning
|
||||||
|
./vcpkg/bootstrap-vcpkg.sh # .bat on Windows
|
||||||
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:
|
To use an external vcpkg checkout instead, `export VCPKG_ROOT=/path/to/vcpkg` — it always
|
||||||
|
takes priority over the bundled submodule.
|
||||||
|
|
||||||
|
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), which also resolves
|
||||||
|
`VCPKG_ROOT` (env var override, else the bundled `vcpkg/` submodule).
|
||||||
|
|
||||||
## Where each subsystem lives (and its doc)
|
## Where each subsystem lives (and its doc)
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,18 @@ 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 — 29/29 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`).**
|
||||||
|
> **Noise suppression shipped (RNNoise, vendored at `third_party/rnnoise/`)** — both send-side
|
||||||
|
> mic NR (`vc_set_input_noise_reduction`) and per-listener receive NR; client on/off toggles ship
|
||||||
|
on all three clients (receive NR now denoises stereo mic streams too — fixed 2026-06-23).
|
||||||
|
> 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 +25,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 +53,34 @@ 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.
|
||||||
|
|
||||||
|
vcpkg is bundled as a git submodule at `vcpkg/`, pinned to the commit in `vcpkg.json`'s
|
||||||
|
`builtin-baseline`. One-time setup after cloning:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git submodule update --init vcpkg
|
||||||
|
./vcpkg/bootstrap-vcpkg.sh # .bat on Windows
|
||||||
|
```
|
||||||
|
|
||||||
|
To use an external vcpkg checkout instead (e.g. one shared across projects), set
|
||||||
|
`VCPKG_ROOT` — it always takes priority over the bundled submodule:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 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 +149,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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+24
-10
@@ -3,14 +3,17 @@ cmake_minimum_required(VERSION 3.25)
|
|||||||
project(voicecat
|
project(voicecat
|
||||||
VERSION 0.0.1
|
VERSION 0.0.1
|
||||||
DESCRIPTION "Self-hosted native voice & text chat (see docs/)"
|
DESCRIPTION "Self-hosted native voice & text chat (see docs/)"
|
||||||
LANGUAGES CXX)
|
# C is needed for the vendored RNNoise noise-suppression lib (third_party/rnnoise).
|
||||||
|
LANGUAGES CXX C)
|
||||||
|
|
||||||
|
# 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
|
|
||||||
# stub that returns VC_ERR_NOT_IMPLEMENTED. As each subsystem is built out, flip
|
|
||||||
# VOICECAT_USE_VCPKG_DEPS=ON so CMake pulls the real libraries (mbedTLS, libsodium,
|
|
||||||
# opus, protobuf, ...) via the vcpkg toolchain (see vcpkg.json / docs/tech-stack.md).
|
|
||||||
option(VOICECAT_USE_VCPKG_DEPS "Link real third-party deps via vcpkg" OFF)
|
|
||||||
option(VOICECAT_BUILD_SERVER "Build voicecat-server" ON)
|
option(VOICECAT_BUILD_SERVER "Build voicecat-server" ON)
|
||||||
option(VOICECAT_BUILD_TOOLS "Build the vccli headless test client" ON)
|
option(VOICECAT_BUILD_TOOLS "Build the vccli headless test client" ON)
|
||||||
option(VOICECAT_BUILD_TESTS "Build tests" ON)
|
option(VOICECAT_BUILD_TESTS "Build tests" ON)
|
||||||
@@ -29,6 +32,20 @@ set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
|
|||||||
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
|
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
|
||||||
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
|
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
|
||||||
|
|
||||||
|
# ── Static MinGW runtime (Windows) ────────────────────────────────────────────
|
||||||
|
# x64-mingw-static only statically links vcpkg's OWN deps (protobuf, sodium, ...);
|
||||||
|
# the GCC/MinGW runtime stays dynamic by default, so every produced .exe/.dll
|
||||||
|
# otherwise depends on libgcc_s_seh-1.dll / libwinpthread-1.dll / libstdc++-6.dll
|
||||||
|
# at runtime — DLLs that exist on a dev box (MSYS2/UCRT64) but not on a clean
|
||||||
|
# Windows machine, where the server then fails to start with "… was not found".
|
||||||
|
# Apply the fully-static-MinGW recipe to ALL targets so binaries are portable.
|
||||||
|
# (The SHARED voicecat.dll already sets these in core/CMakeLists.txt; the duplicate
|
||||||
|
# is harmless. Verify with: objdump -p build/<preset>/bin/voicecat-server.exe |
|
||||||
|
# grep "DLL Name" — only Windows system DLLs should remain.)
|
||||||
|
if(WIN32 AND MINGW)
|
||||||
|
add_link_options(-static-libgcc -static-libstdc++ -static -lwinpthread)
|
||||||
|
endif()
|
||||||
|
|
||||||
# ── Targets ───────────────────────────────────────────────────────────────────
|
# ── Targets ───────────────────────────────────────────────────────────────────
|
||||||
add_subdirectory(core)
|
add_subdirectory(core)
|
||||||
|
|
||||||
@@ -38,9 +55,7 @@ endif()
|
|||||||
|
|
||||||
if(VOICECAT_BUILD_TOOLS)
|
if(VOICECAT_BUILD_TOOLS)
|
||||||
add_subdirectory(tools/vccli)
|
add_subdirectory(tools/vccli)
|
||||||
if(VOICECAT_USE_VCPKG_DEPS)
|
|
||||||
add_subdirectory(tools/voicecat-admin)
|
add_subdirectory(tools/voicecat-admin)
|
||||||
endif()
|
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
if(VOICECAT_BUILD_TESTS)
|
if(VOICECAT_BUILD_TESTS)
|
||||||
@@ -49,5 +64,4 @@ if(VOICECAT_BUILD_TESTS)
|
|||||||
endif()
|
endif()
|
||||||
|
|
||||||
message(STATUS "VoiceCat ${PROJECT_VERSION} configured "
|
message(STATUS "VoiceCat ${PROJECT_VERSION} configured "
|
||||||
"(vcpkg deps: ${VOICECAT_USE_VCPKG_DEPS}, "
|
"(server: ${VOICECAT_BUILD_SERVER}, tools: ${VOICECAT_BUILD_TOOLS})")
|
||||||
"server: ${VOICECAT_BUILD_SERVER}, tools: ${VOICECAT_BUILD_TOOLS})")
|
|
||||||
|
|||||||
+102
-45
@@ -3,75 +3,132 @@
|
|||||||
"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. Resolves vcpkg from the bundled git submodule (vcpkg/) unless VCPKG_ROOT points at an external checkout.",
|
||||||
"generator": "Ninja",
|
"generator": "Ninja",
|
||||||
|
"toolchainFile": "${sourceDir}/cmake/voicecat-toolchain.cmake",
|
||||||
|
"cacheVariables": {
|
||||||
|
"VOICECAT_USE_VCPKG_DEPS": "ON"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dev",
|
||||||
|
"displayName": "Dev (full real-deps build, vcpkg)",
|
||||||
|
"description": "Day-to-day development preset. Real protocol, crypto, voice, server. Builds server + tools + tests. Auto-triplet: x64-mingw-static on Windows, x64-linux on Linux, arm64-osx on Apple Silicon. Requires vcpkg bootstrapped (bundled submodule or VCPKG_ROOT).",
|
||||||
|
"inherits": "vcpkg-common",
|
||||||
"binaryDir": "${sourceDir}/build/dev",
|
"binaryDir": "${sourceDir}/build/dev",
|
||||||
"cacheVariables": {
|
"cacheVariables": {
|
||||||
"CMAKE_BUILD_TYPE": "Debug",
|
"CMAKE_BUILD_TYPE": "Debug",
|
||||||
"VOICECAT_USE_VCPKG_DEPS": "OFF"
|
"VOICECAT_BUILD_TOOLS": "ON",
|
||||||
|
"VOICECAT_BUILD_TESTS": "ON"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "vcpkg-base",
|
"name": "release",
|
||||||
"hidden": true,
|
"displayName": "Release (optimized, tests on, symbols kept)",
|
||||||
"description": "Shared base for presets that link real deps via vcpkg. Requires VCPKG_ROOT in the environment.",
|
"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 bootstrapped (bundled submodule or VCPKG_ROOT).",
|
||||||
"generator": "Ninja",
|
"inherits": "vcpkg-common",
|
||||||
"toolchainFile": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake",
|
"binaryDir": "${sourceDir}/build/release",
|
||||||
"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": "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": "m2-dev",
|
|
||||||
"inherits": "vcpkg-base",
|
|
||||||
"displayName": "M2 Dev (voice + media, deps via vcpkg)",
|
|
||||||
"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).",
|
|
||||||
"binaryDir": "${sourceDir}/build/m2-dev",
|
|
||||||
"cacheVariables": {
|
|
||||||
"CMAKE_BUILD_TYPE": "Debug",
|
|
||||||
"VOICECAT_USE_VCPKG_DEPS": "ON",
|
|
||||||
"VOICECAT_BUILD_TOOLS": "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 bootstrapped (bundled submodule or 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)",
|
||||||
|
"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 bootstrapped (bundled submodule or 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 bootstrapped (bundled submodule or 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 bootstrapped (bundled submodule or 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": "dev", "configurePreset": "dev" },
|
{ "name": "dev", "configurePreset": "dev" },
|
||||||
{ "name": "m1-dev", "configurePreset": "m1-dev" },
|
{ "name": "release", "configurePreset": "release" },
|
||||||
{ "name": "m2-dev", "configurePreset": "m2-dev" },
|
{ "name": "server-release", "configurePreset": "server-release" },
|
||||||
{ "name": "server-release", "configurePreset": "server-release" }
|
{ "name": "windows-client", "configurePreset": "windows-client" },
|
||||||
|
{ "name": "apple-dev", "configurePreset": "apple-dev" },
|
||||||
|
{ "name": "apple-ios", "configurePreset": "apple-ios" },
|
||||||
|
{ "name": "apple-ios-sim", "configurePreset": "apple-ios-sim" }
|
||||||
],
|
],
|
||||||
"testPresets": [
|
"testPresets": [
|
||||||
{ "name": "dev", "configurePreset": "dev", "output": { "outputOnFailure": true } },
|
{ "name": "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
@@ -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"]
|
||||||
+1151
-187
File diff suppressed because it is too large
Load Diff
@@ -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,37 @@ 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 (bundled as a submodule, pinned to vcpkg.json's builtin-baseline):
|
||||||
|
git submodule update --init vcpkg
|
||||||
|
./vcpkg/bootstrap-vcpkg.sh # .bat on Windows
|
||||||
|
|
||||||
|
# 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`).
|
To use an external vcpkg checkout instead, set `VCPKG_ROOT=/path/to/vcpkg` (or
|
||||||
|
`$env:VCPKG_ROOT` on PowerShell) — it always takes priority over the bundled submodule.
|
||||||
|
|
||||||
When you start implementing a subsystem that needs real libraries, build with vcpkg deps:
|
Artifacts land in `build/dev/bin/` (`voicecat-server`, `vccli`, `voicecat-admin`).
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,68 @@
|
|||||||
|
// 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",
|
||||||
|
// Event-cue WAVs (shared with the Windows client) bundled into the package's
|
||||||
|
// resource bundle; EventFeedback loads them via Bundle.module. Copied from
|
||||||
|
// assets/sounds/ into Sources/VoiceCatCore/Sounds/.
|
||||||
|
resources: [.process("Sounds")]
|
||||||
|
),
|
||||||
|
// 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]
|
||||||
|
)
|
||||||
+156
-15
@@ -1,19 +1,160 @@
|
|||||||
# 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/`,
|
||||||
|
and the vendored RNNoise noise-suppression lib (`third_party/rnnoise/`, built as a CMake
|
||||||
|
target → `build/<preset>/lib/librnnoise.a`) is another. A Swift Package binary target can
|
||||||
|
only link ONE `.a` per XCFramework slice, so `build-xcframework.sh` merges them all — vcpkg
|
||||||
|
deps plus the locally-built vendored libs — into a single self-contained `libvoicecat-fat.a`
|
||||||
|
(~33 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)). If you add another vendored (non-vcpkg)
|
||||||
|
static-lib target to the core, it's picked up automatically as long as it lands in
|
||||||
|
`build/<preset>/lib/` and isn't named `libvoicecat*`.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// C callbacks use an unretained `user` context. Client destruction joins callback threads,
|
||||||
|
// and transient event pointers are copied before the callback returns.
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// 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
|
||||||
|
/// 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
|
||||||
|
/// Reply to `joinChannel()` — see `VoiceCatEvent.result` / `.channelId`.
|
||||||
|
case joinResult = 12
|
||||||
|
/// The TOFU server-identity gate — see `VoiceCatEvent.tofuStatus` / `.text`.
|
||||||
|
case serverIdentity = 13
|
||||||
|
/// Async result for moderation/admin/channel operations.
|
||||||
|
case genericResult = 14
|
||||||
|
/// Reply to `requestAccountList()` — call `listAccounts()` to read.
|
||||||
|
case accountList = 15
|
||||||
|
/// Voice-plane subscription state. `u32a` = 1 (subscribed) or 0 (unsubscribed).
|
||||||
|
case voiceState = 16
|
||||||
|
|
||||||
|
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) }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// EventFeedback — shared audible + spoken feedback for session events, used by both the macOS
|
||||||
|
// (AppKit) and iOS (SwiftUI) clients. Mirrors the Windows client's EventFeedback policy
|
||||||
|
// (clients/windows/.../Notifications/EventFeedback.cs): the platform event handlers decide WHAT
|
||||||
|
// to play (they own the model/nickname/channel context); this type owns the "should I, and how"
|
||||||
|
// policy plus the AVFoundation playback/synthesis.
|
||||||
|
//
|
||||||
|
// Sound effects use AVAudioPlayer; spoken announcements use the OS-native AVSpeechSynthesizer.
|
||||||
|
//
|
||||||
|
// NOTE (iOS): on the voice path the app runs a play-and-record AVAudioSession (VPIO). Playing
|
||||||
|
// these cues / speech over that session can interact with the live call (ducking, route, or the
|
||||||
|
// mute switch). The session category should allow mixing — verify on device. This is the most
|
||||||
|
// likely place for platform bugs to surface.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
/// User preferences for event sounds and spoken feedback, backed by UserDefaults so the macOS
|
||||||
|
/// and iOS settings screens and this player share one source of truth.
|
||||||
|
public struct FeedbackSettings: Sendable {
|
||||||
|
public var sounds: Bool
|
||||||
|
public var speech: Bool
|
||||||
|
public var volume: Float
|
||||||
|
public var selfTalkSounds: Bool
|
||||||
|
public var pttSound: Bool
|
||||||
|
|
||||||
|
static let keySounds = "feedback.sounds"
|
||||||
|
static let keySpeech = "feedback.speech"
|
||||||
|
static let keyVolume = "feedback.volume"
|
||||||
|
static let keySelfTalk = "feedback.selfTalk"
|
||||||
|
static let keyPtt = "feedback.ptt"
|
||||||
|
|
||||||
|
/// Default values registered with UserDefaults (so "unset" reads as the intended default
|
||||||
|
/// rather than false/0).
|
||||||
|
static let defaults: [String: Any] = [
|
||||||
|
keySounds: true,
|
||||||
|
keySpeech: false,
|
||||||
|
keyVolume: 1.0,
|
||||||
|
keySelfTalk: false,
|
||||||
|
keyPtt: false,
|
||||||
|
]
|
||||||
|
|
||||||
|
/// The current settings, read live from UserDefaults.
|
||||||
|
public static var current: FeedbackSettings {
|
||||||
|
let d = UserDefaults.standard
|
||||||
|
d.register(defaults: defaults) // idempotent — ensures "unset" reads as the intended default
|
||||||
|
return FeedbackSettings(
|
||||||
|
sounds: d.bool(forKey: keySounds),
|
||||||
|
speech: d.bool(forKey: keySpeech),
|
||||||
|
volume: d.float(forKey: keyVolume),
|
||||||
|
selfTalkSounds: d.bool(forKey: keySelfTalk),
|
||||||
|
pttSound: d.bool(forKey: keyPtt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public final class EventFeedback {
|
||||||
|
public static let shared = EventFeedback()
|
||||||
|
|
||||||
|
private var players: [SoundEvent: AVAudioPlayer] = [:]
|
||||||
|
private let synthesizer = AVSpeechSynthesizer()
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
UserDefaults.standard.register(defaults: FeedbackSettings.defaults)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sounds
|
||||||
|
|
||||||
|
/// Play an event cue, honouring the user's settings. The two opt-in categories (your own
|
||||||
|
/// voice-activity, and the PTT cue) are gated by their own flags.
|
||||||
|
public func play(_ event: SoundEvent) {
|
||||||
|
let s = FeedbackSettings.current
|
||||||
|
guard s.sounds, s.volume > 0 else { return }
|
||||||
|
if (event == .vaStart || event == .vaStop), !s.selfTalkSounds { return }
|
||||||
|
if event == .ptt, !s.pttSound { return }
|
||||||
|
|
||||||
|
guard let player = player(for: event) else { return }
|
||||||
|
player.volume = s.volume
|
||||||
|
player.currentTime = 0
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lazily load and cache an AVAudioPlayer for the event's bundled WAV. Returns nil (silent)
|
||||||
|
/// if the resource is missing or fails to load.
|
||||||
|
private func player(for event: SoundEvent) -> AVAudioPlayer? {
|
||||||
|
if let cached = players[event] { return cached }
|
||||||
|
guard let url = Bundle.module.url(forResource: event.resourceName, withExtension: "wav"),
|
||||||
|
let player = try? AVAudioPlayer(contentsOf: url) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
player.prepareToPlay()
|
||||||
|
players[event] = player
|
||||||
|
return player
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Speech
|
||||||
|
|
||||||
|
/// Speak `text` when spoken feedback is enabled. Utterances queue (do not interrupt prior
|
||||||
|
/// speech) so a burst of events is read in order.
|
||||||
|
public func speak(_ text: String) {
|
||||||
|
guard FeedbackSettings.current.speech else { return }
|
||||||
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return }
|
||||||
|
synthesizer.speak(AVSpeechUtterance(string: trimmed))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// SoundEvent — the cross-platform set of audible event cues. Each maps to a WAV bundled as an
|
||||||
|
// SPM resource (Sources/VoiceCatCore/Sounds/, copied from assets/sounds/). The same logical set
|
||||||
|
// is mirrored in the Windows client (clients/windows/.../Notifications/SoundEvent.cs) so feedback
|
||||||
|
// stays consistent across platforms.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum SoundEvent: CaseIterable, Sendable {
|
||||||
|
case channelJoin // another user joined my channel
|
||||||
|
case channelLeave // another user left my channel
|
||||||
|
case channelRecv // channel text message from someone else
|
||||||
|
case channelSent // channel text message I sent
|
||||||
|
case pmRecv // private message received
|
||||||
|
case pmSent // private message I sent
|
||||||
|
case login // connected / authenticated
|
||||||
|
case logout // clean disconnect
|
||||||
|
case connectionLost // unexpected disconnect
|
||||||
|
case voiceOn // my microphone stream started
|
||||||
|
case voiceOff // my microphone stream stopped
|
||||||
|
case vaStart // my voice-activity began (off by default)
|
||||||
|
case vaStop // my voice-activity ended (off by default)
|
||||||
|
case ptt // push-to-talk engaged (off by default)
|
||||||
|
|
||||||
|
/// Resource name (without extension) as bundled in Sources/VoiceCatCore/Sounds/.
|
||||||
|
var resourceName: String {
|
||||||
|
switch self {
|
||||||
|
case .channelJoin: return "channel_join"
|
||||||
|
case .channelLeave: return "channel_leave"
|
||||||
|
case .channelRecv: return "channel_recv"
|
||||||
|
case .channelSent: return "channel_sent"
|
||||||
|
case .pmRecv: return "pm_recv"
|
||||||
|
case .pmSent: return "pm_sent"
|
||||||
|
case .login: return "login"
|
||||||
|
case .logout: return "logout"
|
||||||
|
case .connectionLost: return "connection_lost"
|
||||||
|
case .voiceOn: return "voice_on"
|
||||||
|
case .voiceOff: return "voice_off"
|
||||||
|
case .vaStart: return "va_start"
|
||||||
|
case .vaStop: return "va_stop"
|
||||||
|
case .ptt: return "ptt"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// 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, sortOrder: c.sort_order,
|
||||||
|
audio: audioConfig(c.audio)))
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
voiceSubscribed: u.voice_subscribed != 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
// 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 let sortOrder: UInt32
|
||||||
|
/// Authoritative channel Opus params (docs/voice.md §3). Populated from the Channel proto
|
||||||
|
/// so the edit dialog can read back the current config.
|
||||||
|
public let audio: AudioConfig
|
||||||
|
|
||||||
|
public init(id: UInt32, parentId: UInt32, name: String, topic: String,
|
||||||
|
passwordProtected: Bool, maxUsers: UInt32, sortOrder: UInt32,
|
||||||
|
audio: AudioConfig) {
|
||||||
|
self.id = id; self.parentId = parentId; self.name = name; self.topic = topic
|
||||||
|
self.passwordProtected = passwordProtected; self.maxUsers = maxUsers
|
||||||
|
self.sortOrder = sortOrder; self.audio = audio
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 let voiceSubscribed: Bool
|
||||||
|
|
||||||
|
public init(id: UInt32, nickname: String, isGuest: Bool, channelId: UInt32,
|
||||||
|
selfMicMuted: Bool, selfDeafened: Bool, serverMuted: Bool,
|
||||||
|
serverDeafened: Bool, voiceSubscribed: 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
|
||||||
|
self.voiceSubscribed = voiceSubscribed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Permission bitset — mirrors `vc_permissions`.
|
||||||
|
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` (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
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,594 @@
|
|||||||
|
// Swift binding invariants: native config strings outlive the handle, destroy joins callback
|
||||||
|
// threads before deallocation, and callback payloads are copied before main-queue delivery.
|
||||||
|
// See docs/architecture.md §4 for the complete binding contract.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
private var handle: OpaquePointer?
|
||||||
|
|
||||||
|
/// Unretained callback context; destroying the handle joins callback threads first.
|
||||||
|
private var selfPointer: UnsafeMutableRawPointer {
|
||||||
|
Unmanaged.passUnretained(self).toOpaque()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The core retains these pointers for the handle's lifetime.
|
||||||
|
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)?
|
||||||
|
|
||||||
|
private let bufferLock = NSLock()
|
||||||
|
private var eventBuffer: [VoiceCatEvent] = []
|
||||||
|
private var levelSamples: [UInt32: Float] = [:]
|
||||||
|
private var drainScheduled = false
|
||||||
|
|
||||||
|
// MARK: - Init / deinit
|
||||||
|
|
||||||
|
public init(config: VoiceCatConfig) {
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
/// 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func joinVoice() -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_join_voice(handle))
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func leaveVoice() -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_leave_voice(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`.
|
||||||
|
@discardableResult
|
||||||
|
public func setOutputVolume(_ gain: Float) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_set_output_volume(handle, gain < 0 ? 0 : gain))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send-side microphone input gain. Applied to captured MIC PCM before the VAD/PTT gate and
|
||||||
|
/// Opus encode (so boosting a quiet mic also helps it cross the VAD threshold). gain 0.0 =
|
||||||
|
/// silent, 1.0 = unity (default), >1.0 amplifies (clamped to int16). Always LOCAL.
|
||||||
|
@discardableResult
|
||||||
|
public func setInputGain(_ gain: Float) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_set_input_gain(handle, gain < 0 ? 0 : gain))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send-side microphone noise suppression (RNNoise). Denoises captured MIC PCM before the
|
||||||
|
/// input gain and VAD/PTT gate, so everyone hears the cleaned signal (one pass for all
|
||||||
|
/// listeners). MIC stream only, mono only; always LOCAL — no protocol traffic. Independent
|
||||||
|
/// of the per-listener receive-side NR in `setRemoteStream` (docs/voice.md §10).
|
||||||
|
@discardableResult
|
||||||
|
public func setInputNoiseReduction(_ enable: Bool) -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_set_input_noise_reduction(handle, enable ? 1 : 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AVAudioSession interruption hooks (iOS)
|
||||||
|
|
||||||
|
/// Pause miniaudio device I/O. Call when AVAudioSession interruption begins.
|
||||||
|
@discardableResult
|
||||||
|
public func audioSuspend() -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_audio_suspend(handle))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resume miniaudio device I/O. Call after re-activating AVAudioSession.
|
||||||
|
@discardableResult
|
||||||
|
public func audioResume() -> VoiceCatResult {
|
||||||
|
VoiceCatResult(vc_audio_resume(handle))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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: - 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: - 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: - 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
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 })")
|
||||||
|
|
||||||
|
// Permissions getter round-trip.
|
||||||
|
let perms = client.getPermissions()
|
||||||
|
XCTAssertFalse(perms.isAdmin)
|
||||||
|
XCTAssertFalse(perms.canKick)
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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.me.iamtalon.voicecat</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,616 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 60;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
AAAA00000000000000000002 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000001 /* Assets.xcassets */; };
|
||||||
|
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 */
|
||||||
|
AAAA00000000000000000001 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
|
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 = (
|
||||||
|
AAAA00000000000000000001 /* Assets.xcassets */,
|
||||||
|
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 = (
|
||||||
|
AAAA00000000000000000002 /* Assets.xcassets in Resources */,
|
||||||
|
);
|
||||||
|
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 = me.iamtalon.voicecat;
|
||||||
|
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 = me.iamtalon.voicecat;
|
||||||
|
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 = me.iamtalon.voicecat.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 = me.iamtalon.voicecat.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>
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
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
|
||||||
|
|
||||||
|
/// Retained after authentication so an interrupted session can be restored.
|
||||||
|
private var connectedServer: SavedServer?
|
||||||
|
|
||||||
|
// MARK: - Reconnect state
|
||||||
|
|
||||||
|
/// Distinguishes an explicit disconnect from a transport failure.
|
||||||
|
private var userInitiatedDisconnect = false
|
||||||
|
|
||||||
|
private struct LastSession {
|
||||||
|
let server: SavedServer
|
||||||
|
let channelId: UInt32
|
||||||
|
let voiceSubscribed: Bool
|
||||||
|
let micMuted: Bool
|
||||||
|
let deafened: Bool
|
||||||
|
}
|
||||||
|
private var lastSession: LastSession?
|
||||||
|
|
||||||
|
private var reconnectAttempt = 0
|
||||||
|
|
||||||
|
private var reconnectTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
/// Detects interface changes before TCP keepalive notices a dead path.
|
||||||
|
private var pathMonitor: NWPathMonitor?
|
||||||
|
private let pathQueue = DispatchQueue(label: "cat.voice.network.path")
|
||||||
|
|
||||||
|
private var lastPathSignature: String?
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
connectTo(server, restoring: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func connectTo(_ server: SavedServer, restoring: LastSession?) {
|
||||||
|
guard !isConnecting else { return }
|
||||||
|
isConnecting = true
|
||||||
|
connectStatus = (restoring != nil) ? "Reconnecting…" : "Connecting…"
|
||||||
|
connectingServer = server
|
||||||
|
identityHandled = false
|
||||||
|
userInitiatedDisconnect = false
|
||||||
|
|
||||||
|
// Releasing the wrapper joins the core's I/O thread before freeing native strings.
|
||||||
|
connectingClient = nil
|
||||||
|
|
||||||
|
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, restoring: restoring)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Authentication can start audio, so select the external path before connecting.
|
||||||
|
client.setExternalPlayback(true)
|
||||||
|
client.connect(host: server.host, port: server.port)
|
||||||
|
|
||||||
|
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() {
|
||||||
|
// Set before disconnect so its event cannot arm reconnect.
|
||||||
|
userInitiatedDisconnect = true
|
||||||
|
cancelReconnect()
|
||||||
|
lastSession = nil
|
||||||
|
session?.leaveVoice()
|
||||||
|
session?.client.disconnect()
|
||||||
|
IOSAudioEngine.shared.stop()
|
||||||
|
AudioSessionManager.shared.deactivateSession()
|
||||||
|
session = nil
|
||||||
|
connectingClient?.disconnect()
|
||||||
|
connectingClient = nil
|
||||||
|
connectingServer = nil
|
||||||
|
connectedServer = 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() {
|
||||||
|
// User explicitly cancelled — no reconnect for the resulting .disconnected event.
|
||||||
|
userInitiatedDisconnect = true
|
||||||
|
cancelReconnect()
|
||||||
|
lastSession = nil
|
||||||
|
connectingClient?.disconnect()
|
||||||
|
connectingClient = nil
|
||||||
|
connectingServer = nil
|
||||||
|
connectedServer = nil
|
||||||
|
isConnecting = false
|
||||||
|
connectStatus = ""
|
||||||
|
showPasswordPrompt = false
|
||||||
|
pendingIdentity = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Reconnect orchestration
|
||||||
|
|
||||||
|
private func cancelReconnect() {
|
||||||
|
reconnectTask?.cancel()
|
||||||
|
reconnectTask = nil
|
||||||
|
stopPathMonitor()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schedules the next reconnect with exponential backoff capped at 30 seconds.
|
||||||
|
private func scheduleReconnect() {
|
||||||
|
guard !userInitiatedDisconnect, let last = lastSession else { return }
|
||||||
|
reconnectTask?.cancel()
|
||||||
|
reconnectAttempt = max(1, reconnectAttempt + 1)
|
||||||
|
let delaySec = min(pow(2.0, Double(reconnectAttempt - 1)), 30.0)
|
||||||
|
connectStatus = "Reconnecting (attempt \(reconnectAttempt))…"
|
||||||
|
|
||||||
|
startPathMonitor()
|
||||||
|
|
||||||
|
let task = Task { [weak self, last] in
|
||||||
|
guard let self else { return }
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(delaySec * 1_000_000_000))
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
guard !self.userInitiatedDisconnect else { return }
|
||||||
|
guard self.lastSession != nil else { return }
|
||||||
|
guard self.session == nil else { return }
|
||||||
|
self.connectTo(last.server, restoring: last)
|
||||||
|
}
|
||||||
|
reconnectTask = task
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startPathMonitor() {
|
||||||
|
guard pathMonitor == nil else { return }
|
||||||
|
let monitor = NWPathMonitor()
|
||||||
|
monitor.pathUpdateHandler = { [weak self] path in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
guard !self.userInitiatedDisconnect else { return }
|
||||||
|
let sig = Self.pathSignature(path)
|
||||||
|
let prevSig = self.lastPathSignature
|
||||||
|
self.lastPathSignature = sig
|
||||||
|
if prevSig == nil { return }
|
||||||
|
|
||||||
|
if self.session != nil {
|
||||||
|
if path.status != .satisfied || sig != prevSig {
|
||||||
|
self.proactiveReconnect()
|
||||||
|
}
|
||||||
|
} else if self.lastSession != nil {
|
||||||
|
if path.status == .satisfied {
|
||||||
|
self.reconnectAttempt = 0
|
||||||
|
self.scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
monitor.start(queue: pathQueue)
|
||||||
|
pathMonitor = monitor
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopPathMonitor() {
|
||||||
|
pathMonitor?.cancel()
|
||||||
|
pathMonitor = nil
|
||||||
|
lastPathSignature = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func pathSignature(_ path: NWPath) -> String {
|
||||||
|
guard path.status == .satisfied else { return "unsatisfied" }
|
||||||
|
var parts: [String] = []
|
||||||
|
if path.usesInterfaceType(.wifi) { parts.append("wifi") }
|
||||||
|
if path.usesInterfaceType(.cellular) { parts.append("cellular") }
|
||||||
|
if path.usesInterfaceType(.wiredEthernet) { parts.append("wired") }
|
||||||
|
if path.usesInterfaceType(.other) { parts.append("other") }
|
||||||
|
return parts.isEmpty ? "none" : parts.sorted().joined(separator: "+")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Live-session disconnect (called by SessionState)
|
||||||
|
|
||||||
|
/// Receives disconnects after `SessionState` takes ownership of authenticated events.
|
||||||
|
func onLiveSessionDisconnected() {
|
||||||
|
guard !userInitiatedDisconnect else { return }
|
||||||
|
teardownLiveSessionAndReconnect(sound: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func proactiveReconnect() {
|
||||||
|
guard !userInitiatedDisconnect else { return }
|
||||||
|
guard session != nil else { return }
|
||||||
|
teardownLiveSessionAndReconnect(sound: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func teardownLiveSessionAndReconnect(sound: Bool) {
|
||||||
|
if let s = session, let srv = connectedServer {
|
||||||
|
lastSession = LastSession(
|
||||||
|
server: srv,
|
||||||
|
channelId: s.currentChannelId,
|
||||||
|
voiceSubscribed: s.voiceState.voiceSubscribed,
|
||||||
|
micMuted: s.voiceState.selfMuted,
|
||||||
|
deafened: s.voiceState.selfDeafened)
|
||||||
|
}
|
||||||
|
IOSAudioEngine.shared.stop()
|
||||||
|
AudioSessionManager.shared.deactivateSession()
|
||||||
|
session = nil
|
||||||
|
isConnecting = false
|
||||||
|
connectingClient = nil
|
||||||
|
connectedServer = nil
|
||||||
|
if sound {
|
||||||
|
EventFeedback.shared.play(.connectionLost)
|
||||||
|
EventFeedback.shared.speak("Network changed — reconnecting")
|
||||||
|
}
|
||||||
|
reconnectAttempt = 0
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Connect event handler
|
||||||
|
|
||||||
|
private func handleConnectEvent(_ ev: VoiceCatEvent, server: SavedServer,
|
||||||
|
restoring: LastSession?) {
|
||||||
|
switch ev.type {
|
||||||
|
case .connectionState:
|
||||||
|
switch ev.connectionState {
|
||||||
|
case .connecting: connectStatus = (restoring != nil) ? "Reconnecting…" : "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)
|
||||||
|
newSession.appState = self
|
||||||
|
connectingClient = nil
|
||||||
|
isConnecting = false
|
||||||
|
connectStatus = ""
|
||||||
|
showPasswordPrompt = false
|
||||||
|
connectedServer = server
|
||||||
|
self.session = newSession
|
||||||
|
EventFeedback.shared.play(.login)
|
||||||
|
EventFeedback.shared.speak(restoring != nil ? "Reconnected" : "Connected")
|
||||||
|
// External-playback mode was enabled before connect() so the core never opens a
|
||||||
|
// miniaudio device on iOS (the single ordering rule of the unified audio path).
|
||||||
|
// Now activate the session and start the engine in listening mode so remote audio
|
||||||
|
// plays the moment someone talks, even before we join voice (no "can't hear anyone").
|
||||||
|
do {
|
||||||
|
try AudioSessionManager.shared.ensureSessionActive()
|
||||||
|
} catch {
|
||||||
|
print("Audio session activate on connect failed: \(error)")
|
||||||
|
}
|
||||||
|
IOSAudioEngine.shared.startListening(client: client)
|
||||||
|
// The path monitor runs the whole time we're connected so a network change fires
|
||||||
|
// proactiveReconnect immediately instead of waiting for the C core's TCP keepalive
|
||||||
|
// timeout (~30-60 s on a hard Wi-Fi drop). It stays armed across reconnects and is
|
||||||
|
// stopped only on user-initiated disconnect.
|
||||||
|
startPathMonitor()
|
||||||
|
|
||||||
|
// Reconnect restore: rejoin the prior channel and re-enable voice/mic if they
|
||||||
|
// were on. The session is fresh (server auto-places us in Lobby), so the restore
|
||||||
|
// is driven through SessionState.requestRestore, which issues a JoinChannel then
|
||||||
|
// (on the resulting .joinResult) re-arms voice + mute/deafen. A successful auth
|
||||||
|
// means the server is reachable, so the backoff counter resets and `lastSession`
|
||||||
|
// clears; the path monitor keeps watching for the next change.
|
||||||
|
if let restoring {
|
||||||
|
newSession.requestRestore(channelId: restoring.channelId,
|
||||||
|
voiceSubscribed: restoring.voiceSubscribed,
|
||||||
|
micMuted: restoring.micMuted,
|
||||||
|
deafened: restoring.deafened)
|
||||||
|
reconnectAttempt = 0
|
||||||
|
lastSession = nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
connectStatus = "Auth failed: \(ev.result.description)"
|
||||||
|
showPasswordPrompt = true
|
||||||
|
}
|
||||||
|
case .disconnected:
|
||||||
|
// This handler runs ONLY during the connecting phase — after auth success
|
||||||
|
// `SessionState.init` overwrites `client.onEvent`, so a live-session disconnect
|
||||||
|
// reaches `SessionState.handleEvent` and comes back via
|
||||||
|
// `onLiveSessionDisconnected`, not here. Two outcomes for this branch:
|
||||||
|
// - A reconnect's connecting phase failed (`lastSession != nil`, set by a prior
|
||||||
|
// teardown) → re-arm `scheduleReconnect` so the backoff loop continues.
|
||||||
|
// - A fresh connect failed before auth (`lastSession == nil`) → show the error, do
|
||||||
|
// not auto-reconnect (the user should retry manually once the server is reachable).
|
||||||
|
connectingClient = nil
|
||||||
|
isConnecting = false
|
||||||
|
IOSAudioEngine.shared.stop()
|
||||||
|
AudioSessionManager.shared.deactivateSession()
|
||||||
|
|
||||||
|
if userInitiatedDisconnect {
|
||||||
|
connectStatus = ""
|
||||||
|
showPasswordPrompt = false
|
||||||
|
pendingIdentity = nil
|
||||||
|
lastSession = nil
|
||||||
|
connectedServer = nil
|
||||||
|
cancelReconnect()
|
||||||
|
} else if lastSession != nil {
|
||||||
|
// Mid-reconnect drop — keep the backoff loop going.
|
||||||
|
EventFeedback.shared.play(.connectionLost)
|
||||||
|
EventFeedback.shared.speak("Connection lost — reconnecting")
|
||||||
|
scheduleReconnect()
|
||||||
|
} else {
|
||||||
|
// Fresh connect failed before auth. Surface the reason; no auto-reconnect.
|
||||||
|
connectStatus = ev.text ?? "Disconnected"
|
||||||
|
showPasswordPrompt = false
|
||||||
|
pendingIdentity = nil
|
||||||
|
connectedServer = nil
|
||||||
|
cancelReconnect()
|
||||||
|
}
|
||||||
|
case .error:
|
||||||
|
connectStatus = ev.text ?? "Unknown error"
|
||||||
|
// Errors don't disconnect us; the .disconnected event handles teardown/reconnect.
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "AppIcon-1024.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import os
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "AudioSessionManager")
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AudioSessionManager {
|
||||||
|
static let shared = AudioSessionManager()
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
|
||||||
|
/// Whether the AVAudioSession is currently active (we activated it). Read by `IOSAudioRouter`
|
||||||
|
/// to decide whether the post-activation A2DP speaker fallback can be applied.
|
||||||
|
var isActive: Bool { isSessionActive }
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Idempotently restores audio after an interruption or external route change.
|
||||||
|
func recoverAudio() {
|
||||||
|
guard IOSAudioEngine.shared.isConnected else { return }
|
||||||
|
do {
|
||||||
|
try ensureSessionActive()
|
||||||
|
} catch {
|
||||||
|
logger.error("recoverAudio — session activate failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
IOSAudioRouter.shared.applyConfiguration()
|
||||||
|
if isSessionActive { IOSAudioRouter.shared.applyA2dpSpeakerFallback() }
|
||||||
|
IOSAudioEngine.shared.reconfigure()
|
||||||
|
logSessionState("after recoverAudio")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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:
|
||||||
|
// The system stops our AVAudioEngine and deactivates the session. Nothing to tear
|
||||||
|
// down — `IOSAudioEngine` rebuilds on resume.
|
||||||
|
logger.info("interruption began — session suspended by system")
|
||||||
|
isSessionActive = false
|
||||||
|
case .ended:
|
||||||
|
// Always attempt recovery when we have a live session. iOS sometimes ends an
|
||||||
|
// interruption without the `.shouldResume` hint (e.g. Siri), and the previous
|
||||||
|
// behavior of only reactivating when `.shouldResume` was set left the session
|
||||||
|
// permanently dead — audio never came back. `recoverAudio()` is intent-gated on
|
||||||
|
// `IOSAudioEngine.isConnected` and idempotent, so speculatively calling it is safe.
|
||||||
|
logger.info("interruption ended — recovery requested")
|
||||||
|
recoverAudio()
|
||||||
|
@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 + recovery")
|
||||||
|
IOSAudioRouter.shared.refreshRoutes()
|
||||||
|
NotificationCenter.default.post(name: .voiceCatDeviceListChanged, object: nil)
|
||||||
|
recoverAudio()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("routeChange reason=\(self.reasonLabel(reason))")
|
||||||
|
IOSAudioRouter.shared.refreshRoutes()
|
||||||
|
NotificationCenter.default.post(name: .voiceCatDeviceListChanged, object: nil)
|
||||||
|
|
||||||
|
// Ignore notifications caused by our own configuration calls; rebuilding for them
|
||||||
|
// recursively emits more route changes. Engine-configuration notifications remain
|
||||||
|
// the recovery path if a self-initiated change actually stops AVAudioEngine.
|
||||||
|
if reason != .categoryChange && reason != .routeConfigurationChange && reason != .override {
|
||||||
|
recoverAudio()
|
||||||
|
}
|
||||||
|
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"
|
||||||
|
case .unknown: return "unknown"
|
||||||
|
@unknown default: return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
static let voiceCatDeviceListChanged = Notification.Name("cat.voice.deviceListChanged")
|
||||||
|
}
|
||||||
@@ -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() } }
|
||||||
|
}
|
||||||
@@ -0,0 +1,650 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import os
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "IOSAudioRouter")
|
||||||
|
|
||||||
|
/// Owns `AVAudioSession` routing for the iOS external-audio path.
|
||||||
|
/// Route configuration and ordering constraints are documented in `docs/voice.md`.
|
||||||
|
@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?
|
||||||
|
/// Master voice-processing switch (Apple VPIO: AEC + noise suppression bundled together).
|
||||||
|
/// iOS exposes no per-stage toggle, so this is the finest "echo cancellation / noise
|
||||||
|
/// reduction" control available. Only takes effect on a VPIO-capable config (mono + standard
|
||||||
|
/// + not A2DP); stereo / A2DP configs can't use VPIO regardless. Default on.
|
||||||
|
@Published var voiceProcessingEnabled: Bool = true
|
||||||
|
/// VPIO automatic gain control — the one VPIO sub-stage iOS lets us toggle independently.
|
||||||
|
/// Only meaningful when voice processing is active. Default on.
|
||||||
|
@Published var agcEnabled: Bool = true
|
||||||
|
@Published var showsRawModeSpeakerWarning: Bool = false
|
||||||
|
@Published var showsA2dpNoAecWarning: Bool = false
|
||||||
|
@Published var hasBluetoothDevice: Bool = false
|
||||||
|
@Published var hasWiredHeadset: Bool = false
|
||||||
|
|
||||||
|
/// Audio presets — the four scenarios from the product spec. Pick a preset for a quick start,
|
||||||
|
/// then fine-tune individual settings under "Advanced". HFP / wired headsets are not separate
|
||||||
|
/// presets: Voice Chat lets the system route to them, and Advanced exposes manual selection.
|
||||||
|
enum AudioPreset: String, CaseIterable, Identifiable {
|
||||||
|
/// Voice chat: Apple VPIO does real AEC + noise suppression + AGC. Mono. The system picks
|
||||||
|
/// the best route (Bluetooth HFP / wired / speaker / earpiece). Always available.
|
||||||
|
case voiceChat = "Voice Chat"
|
||||||
|
/// Internal **stereo** built-in mic regardless of the output route. A2DP output when a
|
||||||
|
/// Bluetooth headset is connected, else built-in speaker / wired. No VPIO (stereo can't
|
||||||
|
/// use it). Always available.
|
||||||
|
case stereoMic = "Stereo Mic"
|
||||||
|
/// Internal **mono** built-in mic regardless of the output route. A2DP output when a
|
||||||
|
/// Bluetooth headset is connected, else built-in speaker / wired. No VPIO. Always available.
|
||||||
|
case monoMic = "Mono Mic"
|
||||||
|
/// Everything manual — input port, mic orientation / polar pattern, mono/stereo, Bluetooth
|
||||||
|
/// mode, raw vs standard, and the VPIO / AGC toggles. Also the display state when the
|
||||||
|
/// individual settings don't match a named preset.
|
||||||
|
case advanced = "Advanced"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var bluetoothMode: BluetoothMode {
|
||||||
|
switch self {
|
||||||
|
case .voiceChat: return .btHfpVoice
|
||||||
|
// Internal-mic presets: A2DP output when BT is connected; speaker/wired when not.
|
||||||
|
case .stereoMic, .monoMic: return .builtInMicBtA2dp
|
||||||
|
case .advanced: return .builtInMicSpeaker // placeholder; Advanced sets it manually
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var captureChannels: CaptureChannels {
|
||||||
|
self == .stereoMic ? .stereo : .mono
|
||||||
|
}
|
||||||
|
|
||||||
|
var micMode: MicMode { .standard }
|
||||||
|
|
||||||
|
/// Whether this preset explicitly pins the built-in mic port (the internal-mic presets).
|
||||||
|
var usesBuiltInMic: Bool {
|
||||||
|
switch self {
|
||||||
|
case .stereoMic, .monoMic: 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"
|
||||||
|
private let kVoiceProcessing = "cat.voice.audio.voiceProcessing"
|
||||||
|
private let kAgc = "cat.voice.audio.agc"
|
||||||
|
|
||||||
|
/// AVAudioSession setters can synchronously emit route-change notifications.
|
||||||
|
private var isApplyingConfiguration = false
|
||||||
|
|
||||||
|
/// Prevents redundant overrides; `setCategory` invalidates the cached value.
|
||||||
|
private var lastAppliedOutputOverride: AVAudioSession.PortOverride?
|
||||||
|
|
||||||
|
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 the user can pick. All four are always available — the named presets simply
|
||||||
|
/// describe what to do "regardless of the output route", and Advanced is always offered.
|
||||||
|
var availablePresets: [AudioPreset] { AudioPreset.allCases }
|
||||||
|
|
||||||
|
/// Which named preset matches the current settings, or `.advanced` if nothing matches.
|
||||||
|
var activePreset: AudioPreset {
|
||||||
|
for preset in [AudioPreset.voiceChat, .stereoMic, .monoMic] {
|
||||||
|
if bluetoothMode == preset.bluetoothMode
|
||||||
|
&& captureChannels == preset.captureChannels
|
||||||
|
&& micMode == preset.micMode {
|
||||||
|
return preset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return .advanced
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the current configuration should engage Apple's Voice-Processing I/O unit (VPIO:
|
||||||
|
/// real AEC + noise suppression + AGC, driven by `IOSAudioEngine`). VPIO forces mono and
|
||||||
|
/// can't run on an A2DP route, so it is available only for a mono + standard + non-A2DP
|
||||||
|
/// config, and then only when the user hasn't disabled it via the Advanced master toggle.
|
||||||
|
var currentConfigUsesVoiceProcessing: Bool {
|
||||||
|
voiceProcessingEnabled && voiceProcessingAvailable
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the current config *could* use VPIO (mono + standard + non-A2DP), independent of
|
||||||
|
/// the user's master toggle. Drives whether the Advanced "Voice Processing" switch is shown.
|
||||||
|
var voiceProcessingAvailable: Bool {
|
||||||
|
captureChannels == .mono && micMode == .standard && bluetoothMode != .builtInMicBtA2dp
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Apply configuration
|
||||||
|
|
||||||
|
/// Apply the full audio configuration to AVAudioSession. Call this before (re)building the
|
||||||
|
/// `IOSAudioEngine` graph so the engine binds to the intended route (`applyAndReconfigure`
|
||||||
|
/// does both). 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
|
||||||
|
// setCategory below can reset the override out from under us, so drop our cached
|
||||||
|
// value — applyA2dpSpeakerFallback will re-derive and re-apply it from scratch.
|
||||||
|
lastAppliedOutputOverride = nil
|
||||||
|
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 {
|
||||||
|
// See configureStereoCapture's doc comment for the full stereo-capture recipe
|
||||||
|
// and why each step is necessary.
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anchors the built-in stereo data source without using
|
||||||
|
/// `setPreferredInputNumberOfChannels`, which disrupts A2DP routing.
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
// VPIO toggles default ON when never set (object(forKey:) is nil → use true).
|
||||||
|
voiceProcessingEnabled = (UserDefaults.standard.object(forKey: kVoiceProcessing) as? Bool) ?? true
|
||||||
|
agcEnabled = (UserDefaults.standard.object(forKey: kAgc) as? Bool) ?? true
|
||||||
|
}
|
||||||
|
/// 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)
|
||||||
|
UserDefaults.standard.set(voiceProcessingEnabled, forKey: kVoiceProcessing)
|
||||||
|
UserDefaults.standard.set(agcEnabled, forKey: kAgc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Selection setters (called from SettingsView pickers)
|
||||||
|
|
||||||
|
/// Persists the selection and rebuilds the engine against the resulting route.
|
||||||
|
private func applyAndReconfigure() {
|
||||||
|
savePreferences()
|
||||||
|
applyConfiguration()
|
||||||
|
if AudioSessionManager.shared.isActive { applyA2dpSpeakerFallback() }
|
||||||
|
refreshRoutes()
|
||||||
|
IOSAudioEngine.shared.reconfigure()
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectInputPort(_ portId: String) {
|
||||||
|
selectedInputPortId = portId
|
||||||
|
selectedDataSourceId = nil
|
||||||
|
selectedPolarPattern = nil
|
||||||
|
applyAndReconfigure()
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectDataSource(_ dataSourceId: String) {
|
||||||
|
selectedDataSourceId = dataSourceId
|
||||||
|
selectedPolarPattern = nil
|
||||||
|
applyAndReconfigure()
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectPolarPattern(_ pattern: String) {
|
||||||
|
selectedPolarPattern = pattern
|
||||||
|
applyAndReconfigure()
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectBluetoothMode(_ mode: BluetoothMode) {
|
||||||
|
bluetoothMode = mode
|
||||||
|
applyAndReconfigure()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setForceSpeaker(_ on: Bool) {
|
||||||
|
forceSpeaker = on
|
||||||
|
applyAndReconfigure()
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectMicMode(_ mode: MicMode) {
|
||||||
|
micMode = mode
|
||||||
|
applyAndReconfigure()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setVoiceProcessingEnabled(_ on: Bool) {
|
||||||
|
voiceProcessingEnabled = on
|
||||||
|
applyAndReconfigure()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setAgcEnabled(_ on: Bool) {
|
||||||
|
agcEnabled = on
|
||||||
|
// No session reconfigure needed — just rebuild the engine so VPIO picks up the AGC flag.
|
||||||
|
savePreferences()
|
||||||
|
IOSAudioEngine.shared.reconfigure()
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectCaptureChannels(_ channels: CaptureChannels) {
|
||||||
|
captureChannels = channels
|
||||||
|
savePreferences()
|
||||||
|
applyConfiguration()
|
||||||
|
if AudioSessionManager.shared.isActive { applyA2dpSpeakerFallback() }
|
||||||
|
refreshRoutes()
|
||||||
|
// Push the channel count into the core's MIC stream, then rebuild the engine graph so the
|
||||||
|
// mic tap captures the right number of channels. The engine owns the route now, so there's
|
||||||
|
// no stereo-vs-A2DP race to sequence around.
|
||||||
|
IOSAudioEngine.shared.setCaptureChannels(channels.channelCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Presets
|
||||||
|
|
||||||
|
/// Apply a named preset — set all individual settings to the preset's values, then re-apply
|
||||||
|
/// the configuration and rebind the engine. The internal-mic presets pin the built-in mic.
|
||||||
|
func applyPreset(_ preset: AudioPreset) {
|
||||||
|
guard preset != .advanced else { return } // Advanced is a display state, not "applied"
|
||||||
|
|
||||||
|
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 {
|
||||||
|
// Pin the built-in mic. In stereo, iOS uses multiple capsules automatically; in mono
|
||||||
|
// the default orientation is fine — so don't force a specific data source / pattern.
|
||||||
|
if let builtInMic = (AVAudioSession.sharedInstance().availableInputs ?? []).first(where: {
|
||||||
|
$0.portType == .builtInMic
|
||||||
|
}) {
|
||||||
|
selectedInputPortId = builtInMic.uid
|
||||||
|
}
|
||||||
|
selectedDataSourceId = nil
|
||||||
|
selectedPolarPattern = nil
|
||||||
|
} else {
|
||||||
|
// Voice Chat: let the system pick the input (Bluetooth HFP / wired / built-in).
|
||||||
|
selectedInputPortId = nil
|
||||||
|
selectedDataSourceId = nil
|
||||||
|
selectedPolarPattern = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
UserDefaults.standard.set(preset.rawValue, forKey: kPreset)
|
||||||
|
savePreferences()
|
||||||
|
applyConfiguration()
|
||||||
|
if AudioSessionManager.shared.isActive { applyA2dpSpeakerFallback() }
|
||||||
|
refreshRoutes()
|
||||||
|
// Push the channel count to the core, then rebuild the engine graph (VPIO on/off + tap).
|
||||||
|
IOSAudioEngine.shared.setCaptureChannels(preset.captureChannels.channelCount)
|
||||||
|
IOSAudioEngine.shared.reconfigure()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uses the speaker only when an A2DP-capable preset has no external output.
|
||||||
|
/// The cached override avoids recursively generated route-change notifications.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
let desired: AVAudioSession.PortOverride = hasExternalOutput ? .none : .speaker
|
||||||
|
if desired == lastAppliedOutputOverride {
|
||||||
|
logger.debug("A2DP fallback — desired=\(self.overrideLabel(desired)) already applied, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try session.overrideOutputAudioPort(desired)
|
||||||
|
lastAppliedOutputOverride = desired
|
||||||
|
logger.info("A2DP mode — override applied: \(self.overrideLabel(desired))")
|
||||||
|
} catch {
|
||||||
|
// Drop the cache so the next call re-derives from the live session state.
|
||||||
|
lastAppliedOutputOverride = nil
|
||||||
|
logger.error("A2DP speaker fallback failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func overrideLabel(_ o: AVAudioSession.PortOverride) -> String {
|
||||||
|
switch o {
|
||||||
|
case .none: return "none"
|
||||||
|
case .speaker: return "speaker"
|
||||||
|
@unknown default: return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import Darwin
|
||||||
|
import os
|
||||||
|
import VoiceCatCore
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "IOSAudioEngine")
|
||||||
|
|
||||||
|
/// In-process single-producer/single-consumer int16 PCM ring for the 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consumer-side snapshot of how many interleaved int16 samples are currently buffered. Lets a
|
||||||
|
/// paced consumer check for a full frame *before* calling `read`, so it never reads (and thus
|
||||||
|
/// discards) a partial frame. Single consumer only (same thread that calls `read`).
|
||||||
|
var availableSamples: Int {
|
||||||
|
let r = readIdx
|
||||||
|
OSMemoryBarrier()
|
||||||
|
let w = writeIdx
|
||||||
|
return Int(w &- r)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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" apart
|
||||||
|
/// from "PCM arrived but produced no sound" (the AVAudioEngine output graph).
|
||||||
|
var debugTotalWritten: UInt64 { writeIdx }
|
||||||
|
var debugTotalRead: UInt64 { readIdx }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The single iOS audio engine (docs/voice.md §8 "iOS audio engine").
|
||||||
|
///
|
||||||
|
/// **One path, always external.** On iOS the core never opens a miniaudio device: a MIC stream is
|
||||||
|
/// always started with `external_feed=1`, `vc_set_external_playback(1)` is set once at connect, and
|
||||||
|
/// this engine drives *both* directions through one `AVAudioEngine`:
|
||||||
|
/// - **core → speaker:** the core's mixed-output sink fills `ring`; an `AVAudioSourceNode` pulls
|
||||||
|
/// from it and renders through the engine output. This runs the whole time we're connected,
|
||||||
|
/// so remote audio plays even before the user joins voice (no "can't hear anyone").
|
||||||
|
/// - **mic → core:** when the mic is active a tap on the input node converts to 48 kHz int16 and
|
||||||
|
/// writes to a pacing ring; a 20 ms timer releases steady 960-sample frames to
|
||||||
|
/// `client.feedPcm(micStreamId)`. The core sends each captured frame synchronously, so this
|
||||||
|
/// steady cadence is what keeps packets from bursting and fluttering the receiver's playout.
|
||||||
|
///
|
||||||
|
/// Echo cancellation / noise suppression / AGC come from Apple's Voice-Processing I/O unit (VPIO),
|
||||||
|
/// which `inputNode.setVoiceProcessingEnabled(true)` enables. VPIO forces mono, so it is engaged
|
||||||
|
/// only when the active preset wants it (`IOSAudioRouter.currentConfigUsesVoiceProcessing`) — the
|
||||||
|
/// Stereo Mic / A2DP configs run the same engine with VPIO off.
|
||||||
|
///
|
||||||
|
/// Every preset / route / interruption change funnels through `reconfigure()`: a single
|
||||||
|
/// deterministic stop → AVAudioSession reconfigure → rebuild graph → start. There is no second
|
||||||
|
/// (miniaudio) audio path to hand off to, so a switch cannot leave one direction dropped.
|
||||||
|
@MainActor
|
||||||
|
final class IOSAudioEngine {
|
||||||
|
static let shared = IOSAudioEngine()
|
||||||
|
|
||||||
|
/// True while connected (between `startListening` and `stop`) — the playback graph should run.
|
||||||
|
private(set) var isConnected = false
|
||||||
|
/// True while a local mic stream is active — the input tap should be installed.
|
||||||
|
private(set) var micActive = false
|
||||||
|
|
||||||
|
private let engine = AVAudioEngine()
|
||||||
|
private var sourceNode: AVAudioSourceNode?
|
||||||
|
private weak var client: VoiceCatClient?
|
||||||
|
private var micStreamId: UInt32 = 0
|
||||||
|
private var captureChannels: UInt32 = 1
|
||||||
|
|
||||||
|
// AVAudioEngine may deliver several codec frames per callback. Pace complete 20 ms frames
|
||||||
|
// through an SPSC ring; never consume partial frames, and recreate the timer when the channel
|
||||||
|
// count changes.
|
||||||
|
private let micRing = PCMRing(capacitySamples: 48000 * 2) // ~1 s stereo — ample elastic slack
|
||||||
|
private var micTimer: DispatchSourceTimer?
|
||||||
|
private let micQueue = DispatchQueue(label: "cat.voice.mic.feedPump")
|
||||||
|
private let micDrainScratch: UnsafeMutablePointer<Int16>
|
||||||
|
private static let micFrameSamplesPerChannel = 960 // 20 ms @ 48 kHz — core's frame size
|
||||||
|
|
||||||
|
/// Feed-pump state, touched only on `micQueue` (the pump's serial queue). A reference type so
|
||||||
|
/// the timer closure mutates it without capturing `self` (which is @MainActor). `targetFrames`
|
||||||
|
/// is the prebuffer depth: the pump fills this many frames before it starts releasing, so the
|
||||||
|
/// tap's bursty delivery (~2 frames at once) can't drain it to empty between bursts. It persists
|
||||||
|
/// across rebuilds and self-heals upward (capped) on an underrun, so it tunes to whatever IO
|
||||||
|
/// buffer size the active route/VPIO actually uses without a hard-coded guess.
|
||||||
|
private final class PumpState {
|
||||||
|
var primed = false
|
||||||
|
var targetFrames = 3 // ~60 ms initial cushion; grows on underrun up to maxTargetFrames
|
||||||
|
static let maxTargetFrames = 6 // ~120 ms cap — bounds added latency
|
||||||
|
}
|
||||||
|
private let pumpState = PumpState()
|
||||||
|
|
||||||
|
// 48 kHz stereo Float32 (deinterleaved) — the format the source node renders. The core
|
||||||
|
// delivers 48 kHz stereo int16 via the mixed-output sink; mainMixerNode adapts to the route.
|
||||||
|
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>
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
renderScratch = UnsafeMutablePointer<Int16>.allocate(capacity: renderScratchFrames * 2)
|
||||||
|
renderScratch.initialize(repeating: 0, count: renderScratchFrames * 2)
|
||||||
|
micDrainScratch = UnsafeMutablePointer<Int16>.allocate(capacity: 960 * 2)
|
||||||
|
micDrainScratch.initialize(repeating: 0, count: 960 * 2)
|
||||||
|
|
||||||
|
// AVAudioEngine stops itself on a mid-session route/configuration change (it stops
|
||||||
|
// if its I/O graph no longer matches the active route). Our route-change handler in
|
||||||
|
// AudioSessionManager normally rebuilds us before the user notices, but if the engine
|
||||||
|
// stops itself AFTER our recovery (because the route-change notification raced ahead
|
||||||
|
// of the engine's own self-stop), nothing restarts it. Catch that case here.
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self, selector: #selector(handleEngineConfigurationChange),
|
||||||
|
name: .AVAudioEngineConfigurationChange, object: engine)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The engine stopped itself because its configuration no longer matches the active AVAudio
|
||||||
|
/// route (this fires after a route change that the route-change handler can't always outrun).
|
||||||
|
/// Dispatch to main and call the unified `recoverAudio()` — it's intent-gated on
|
||||||
|
/// `isConnected`, idempotent, and no-ops if the engine is already running (the common case
|
||||||
|
/// where our route-change handler got there first).
|
||||||
|
@objc private func handleEngineConfigurationChange(_ notification: Notification) {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
guard self.isConnected, !self.engine.isRunning else { return }
|
||||||
|
logger.info("engine configuration-change — engine stopped itself, recovering")
|
||||||
|
AudioSessionManager.shared.recoverAudio()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
|
/// Begin playback-only (listening) operation. Called once at connect, after
|
||||||
|
/// `client.setExternalPlayback(true)` and `AudioSessionManager.ensureSessionActive()`. Attaches
|
||||||
|
/// the source node, wires the core's mixed-output sink into the ring, and starts the engine so
|
||||||
|
/// remote audio plays immediately.
|
||||||
|
func startListening(client: VoiceCatClient) {
|
||||||
|
self.client = client
|
||||||
|
guard !isConnected else { return }
|
||||||
|
isConnected = true
|
||||||
|
ring.reset()
|
||||||
|
|
||||||
|
// Wire the core's mixed-output sink into the ring (C function pointer, no captures). Stays
|
||||||
|
// registered for the whole connection; the ring is drained by the source-node render block.
|
||||||
|
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)
|
||||||
|
|
||||||
|
rebuild()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tear down the engine and unhook the core sink. Called on disconnect.
|
||||||
|
func stop() {
|
||||||
|
guard isConnected else { return }
|
||||||
|
micActive = false
|
||||||
|
stopMicTimer()
|
||||||
|
isConnected = false
|
||||||
|
client?.setMixedOutputSink(nil, user: nil)
|
||||||
|
engine.inputNode.removeTap(onBus: 0)
|
||||||
|
if engine.isRunning { engine.stop() }
|
||||||
|
logger.info("audio engine stopped — ring written=\(self.ring.debugTotalWritten) read=\(self.ring.debugTotalRead) samples")
|
||||||
|
try? engine.inputNode.setVoiceProcessingEnabled(false)
|
||||||
|
if let src = sourceNode {
|
||||||
|
engine.detach(src)
|
||||||
|
sourceNode = nil
|
||||||
|
}
|
||||||
|
ring.reset()
|
||||||
|
micRing.reset()
|
||||||
|
client = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Mic transitions
|
||||||
|
|
||||||
|
/// Engage the mic: install the input tap and (if the preset wants it) VPIO. Called when the
|
||||||
|
/// user joins voice, after the MIC stream (external_feed) is started.
|
||||||
|
func startMic(streamId: UInt32, channels: UInt32) {
|
||||||
|
micStreamId = streamId
|
||||||
|
captureChannels = channels
|
||||||
|
micActive = true
|
||||||
|
rebuild()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disengage the mic: remove the tap and VPIO, keep playback running for remaining remote audio.
|
||||||
|
func stopMic() {
|
||||||
|
guard micActive else { return }
|
||||||
|
micActive = false
|
||||||
|
rebuild()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the capture channel count (mono↔stereo) for the active mic and rebuild.
|
||||||
|
func setCaptureChannels(_ channels: UInt32) {
|
||||||
|
captureChannels = channels
|
||||||
|
if let client, micStreamId != 0 {
|
||||||
|
client.setCaptureChannels(streamId: micStreamId, channels: channels)
|
||||||
|
}
|
||||||
|
if micActive { rebuild() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-apply the engine graph against the current AVAudioSession config (preset / route change).
|
||||||
|
/// Safe to call when only listening — it just rebuilds the playback graph against the new route.
|
||||||
|
func reconfigure() {
|
||||||
|
guard isConnected else { return }
|
||||||
|
rebuild()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Graph (re)build
|
||||||
|
|
||||||
|
/// The single place that (re)builds and starts the engine graph. Deterministic: stop → set
|
||||||
|
/// VPIO → (re)install the mic tap → start. The caller is responsible for having applied the
|
||||||
|
/// AVAudioSession config (category/mode/route) first (`IOSAudioRouter.applyConfiguration`).
|
||||||
|
private func rebuild() {
|
||||||
|
guard isConnected else { return }
|
||||||
|
// Stop the feed pump before touching the tap / ring so the timer (on micQueue) can't race
|
||||||
|
// the ring reset in installMicTap. It is restarted at the end with the current channel count.
|
||||||
|
stopMicTimer()
|
||||||
|
if engine.isRunning { engine.stop() }
|
||||||
|
engine.inputNode.removeTap(onBus: 0)
|
||||||
|
|
||||||
|
let useVPIO = micActive && IOSAudioRouter.shared.currentConfigUsesVoiceProcessing
|
||||||
|
do {
|
||||||
|
try engine.inputNode.setVoiceProcessingEnabled(useVPIO)
|
||||||
|
} catch {
|
||||||
|
logger.error("setVoiceProcessingEnabled(\(useVPIO)) failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
if useVPIO {
|
||||||
|
// AGC is the one VPIO sub-stage iOS exposes; AEC+NS are bundled into the master switch.
|
||||||
|
engine.inputNode.isVoiceProcessingAGCEnabled = IOSAudioRouter.shared.agcEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// The source node must bind to the selected voice-processing output unit.
|
||||||
|
rebuildSourceNode()
|
||||||
|
if micActive { installMicTap() }
|
||||||
|
|
||||||
|
engine.prepare()
|
||||||
|
do {
|
||||||
|
try engine.start()
|
||||||
|
let inFmt = engine.inputNode.outputFormat(forBus: 0)
|
||||||
|
let outFmt = engine.outputNode.outputFormat(forBus: 0)
|
||||||
|
let route = AVAudioSession.sharedInstance().currentRoute.outputs
|
||||||
|
.map { "\($0.portName)[\($0.portType.rawValue)]" }.joined(separator: ", ")
|
||||||
|
logger.info("""
|
||||||
|
engine started — mic=\(self.micActive) vpio=\(useVPIO) captureCh=\(self.captureChannels) \
|
||||||
|
inFormat=\(inFmt) outputNode=\(outFmt) outputRoute=[\(route)]
|
||||||
|
""")
|
||||||
|
} catch {
|
||||||
|
// Route changes can leave AVAudioSession inactive; retry once after reactivation.
|
||||||
|
logger.error("engine start failed: \(error.localizedDescription) — attempting one-shot recovery")
|
||||||
|
do {
|
||||||
|
try AudioSessionManager.shared.ensureSessionActive()
|
||||||
|
} catch {
|
||||||
|
logger.error("recovery — session re-activate failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
IOSAudioRouter.shared.applyConfiguration()
|
||||||
|
if AudioSessionManager.shared.isActive {
|
||||||
|
IOSAudioRouter.shared.applyA2dpSpeakerFallback()
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try engine.start()
|
||||||
|
logger.info("engine start succeeded after one-shot recovery")
|
||||||
|
} catch {
|
||||||
|
logger.error("engine start failed after recovery: \(error.localizedDescription)")
|
||||||
|
// Not fatal — a subsequent route-change or AVAudioEngine configuration-change
|
||||||
|
// notification will trigger recoverAudio() and re-attempt the rebuild.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the feed pump last, with the current channel count, so it never carries a stale
|
||||||
|
// (frozen) channel count across a mono↔stereo switch.
|
||||||
|
if micActive { startMicTimer() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detach any previous source node and attach a fresh one pulling mixed PCM from the ring.
|
||||||
|
/// Rebuilt on every graph rebuild so it always connects against the current output unit (the
|
||||||
|
/// VPIO state can change the output between rebuilds). Its format is route-independent —
|
||||||
|
/// `mainMixerNode` adapts 48 kHz stereo to whatever the output route is.
|
||||||
|
private func rebuildSourceNode() {
|
||||||
|
if let old = sourceNode {
|
||||||
|
engine.detach(old)
|
||||||
|
sourceNode = nil
|
||||||
|
}
|
||||||
|
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
|
||||||
|
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 {
|
||||||
|
base[i] = i < got ? Float(scratch[i * 2 + min(ch, 1)]) * scale : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return noErr
|
||||||
|
}
|
||||||
|
sourceNode = src
|
||||||
|
engine.attach(src)
|
||||||
|
engine.connect(src, to: engine.mainMixerNode, format: outFormat)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install the mic tap: convert the input node's native format to 48 kHz int16 (mono or
|
||||||
|
/// stereo per `captureChannels`) and write it to the pacing ring. The 20 ms feed pump
|
||||||
|
/// (`startMicTimer`) releases steady 960-sample frames to `feedPcm` — see the mic-feed comment
|
||||||
|
/// above for why the tap must NOT call feedPcm directly (it bursts packets → receiver flutter).
|
||||||
|
/// Rebuilds the converter each time because the input format depends on the VPIO state and the
|
||||||
|
/// active route.
|
||||||
|
private func installMicTap() {
|
||||||
|
guard client != nil else { return }
|
||||||
|
// Fresh ring on every (re)install — a rebuild must not feed stale pre-roll into the new tap.
|
||||||
|
// Safe here: the feed pump was stopped at the top of rebuild(), so no consumer is running.
|
||||||
|
micRing.reset()
|
||||||
|
let ring = micRing // captured by the closure as a `let` — no self capture (see mic-feed comment)
|
||||||
|
let inFormat = engine.inputNode.outputFormat(forBus: 0)
|
||||||
|
guard inFormat.sampleRate > 0 else {
|
||||||
|
logger.error("input format unavailable (\(inFormat)) — mic will not transmit")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let targetCh = max(1, min(2, captureChannels))
|
||||||
|
guard let target = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: 48000,
|
||||||
|
channels: AVAudioChannelCount(targetCh), interleaved: true),
|
||||||
|
let converter = AVAudioConverter(from: inFormat, to: target) else {
|
||||||
|
logger.error("mic converter unavailable (in=\(inFormat), ch=\(targetCh)) — mic will not transmit")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let chInt = Int(targetCh)
|
||||||
|
engine.inputNode.installTap(onBus: 0, bufferSize: 960, format: inFormat) { buffer, _ in
|
||||||
|
// Convert this tap buffer to 48 kHz int16. Output capacity scaled for any upsample.
|
||||||
|
let ratio = target.sampleRate / buffer.format.sampleRate
|
||||||
|
let outCap = AVAudioFrameCount(Double(buffer.frameLength) * ratio + 16)
|
||||||
|
guard let outBuf = AVAudioPCMBuffer(pcmFormat: target, 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 }
|
||||||
|
// int16 interleaved → channelData[0] is the interleaved buffer. Write the converter's
|
||||||
|
// variable-length output to the pacing ring; the 20 ms feed pump releases steady
|
||||||
|
// 960-sample frames to feedPcm so packets leave the core at a steady 20 ms cadence.
|
||||||
|
ring.write(chData[0], count: Int(outBuf.frameLength) * chInt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Mic feed pump (paces feedPcm at a steady 20 ms cadence)
|
||||||
|
|
||||||
|
/// Start the 20 ms feed pump. After priming a small cushion (`pumpState.targetFrames`), it
|
||||||
|
/// releases ONE 960-sample frame per tick from `micRing` to `feedPcm`, so the core (which sends
|
||||||
|
/// synchronously per captured frame) emits packets at a steady 20 ms — the cadence its receivers
|
||||||
|
/// expect. The cushion is essential: the receiver's playout deliberately keeps near-zero
|
||||||
|
/// buffering (low latency), so it tolerates a steady stream but not bursts; the iOS tap delivers
|
||||||
|
/// ~2 frames at once, and without the cushion the pump runs at ~0 depth and underruns on every
|
||||||
|
/// tap/timer phase beat (crackle). Recreated on every `rebuild()` so `ch` always reflects the
|
||||||
|
/// current `captureChannels` (mono↔stereo switches). Captures only locals + the reference-type
|
||||||
|
/// ring/client/state (no `self`, which is @MainActor).
|
||||||
|
private func startMicTimer() {
|
||||||
|
stopMicTimer()
|
||||||
|
guard let client else { return }
|
||||||
|
let ring = micRing
|
||||||
|
let scratch = micDrainScratch
|
||||||
|
let state = pumpState
|
||||||
|
let sid = micStreamId
|
||||||
|
let ch = max(1, min(2, Int(captureChannels)))
|
||||||
|
let frameSamples = Self.micFrameSamplesPerChannel
|
||||||
|
let full = frameSamples * ch
|
||||||
|
let chU32 = UInt32(ch)
|
||||||
|
// The ring was just reset in installMicTap, so the cushion must be refilled before sending.
|
||||||
|
state.primed = false
|
||||||
|
let feed: () -> Void = {
|
||||||
|
_ = ring.read(into: scratch, count: full) // caller guarantees a full frame is present
|
||||||
|
_ = client.feedPcm(streamId: sid, pcm: scratch,
|
||||||
|
samplesPerChannel: frameSamples, channels: chU32)
|
||||||
|
}
|
||||||
|
let t = DispatchSource.makeTimerSource(queue: micQueue)
|
||||||
|
t.schedule(deadline: .now(), repeating: .milliseconds(20), leeway: .milliseconds(2))
|
||||||
|
t.setEventHandler {
|
||||||
|
let frames = ring.availableSamples / full // whole frames currently buffered
|
||||||
|
if !state.primed {
|
||||||
|
if frames < state.targetFrames { return } // still filling the cushion (into silence)
|
||||||
|
state.primed = true
|
||||||
|
} else if frames == 0 {
|
||||||
|
// Re-prime with a larger cushion; consuming a partial frame would lose samples.
|
||||||
|
if state.targetFrames < PumpState.maxTargetFrames { state.targetFrames += 1 }
|
||||||
|
state.primed = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
feed() // one steady frame per tick (frames >= 1 here)
|
||||||
|
// Catch-up: if the backlog grew past the cushion (pump descheduled, or producer ran
|
||||||
|
// ahead via a burst), release one extra frame to drain it and keep latency bounded.
|
||||||
|
if frames - 1 > state.targetFrames + 1 { feed() }
|
||||||
|
}
|
||||||
|
t.resume()
|
||||||
|
micTimer = t
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopMicTimer() {
|
||||||
|
micTimer?.cancel()
|
||||||
|
micTimer = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>UILaunchScreen</key>
|
||||||
|
<dict/>
|
||||||
|
<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>
|
||||||
@@ -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)" }
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,553 @@
|
|||||||
|
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 voiceSubscribed = false
|
||||||
|
var selfMuted = false
|
||||||
|
var selfDeafened = false
|
||||||
|
var serverMuted = false
|
||||||
|
var serverDeafened = false
|
||||||
|
var inputMode: VoiceCatInputMode = .voiceActivation
|
||||||
|
var vadThreshold: Float = 0.025
|
||||||
|
var inputGain: Float = 1.0
|
||||||
|
var inputNoiseReduction: Bool = false
|
||||||
|
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] = []
|
||||||
|
|
||||||
|
/// Back-reference to the app state. Once `SessionState.init` overwrites `client.onEvent`,
|
||||||
|
/// `AppState.handleConnectEvent` no longer receives per-session events — so the
|
||||||
|
/// `.disconnected` event for a LIVE session arrives here in `handleEvent`, not in AppState.
|
||||||
|
/// This weak ref lets us hand the disconnect back to AppState (which owns the reconnect
|
||||||
|
/// state machine) so the auto-reconnect fires. Set by AppState on auth success.
|
||||||
|
weak var appState: AppState?
|
||||||
|
|
||||||
|
// MARK: - Reconnect restore state
|
||||||
|
//
|
||||||
|
// When the iOS client auto-reconnects after a network drop, AppState captures the prior
|
||||||
|
// session's channel + voice/mic state and asks the new SessionState (created on auth success)
|
||||||
|
// to restore it. We rejoin the channel explicitly (the server auto-placed us in Lobby on
|
||||||
|
// auth), and on the resulting `.joinResult` we re-arm voice subscription + mute/deafen. The
|
||||||
|
// drive is here, not in AppState, because once SessionState is created it owns
|
||||||
|
// `client.onEvent` and AppState no longer sees per-session events.
|
||||||
|
private struct RestoreRequest {
|
||||||
|
let channelId: UInt32
|
||||||
|
let voiceSubscribed: Bool
|
||||||
|
let micMuted: Bool
|
||||||
|
let deafened: Bool
|
||||||
|
}
|
||||||
|
private var pendingRestore: RestoreRequest?
|
||||||
|
private var didIssueRestoreJoin = false
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
loadAndApplyVoiceSettings()
|
||||||
|
refreshChannels()
|
||||||
|
refreshUsers()
|
||||||
|
syncSelfChannel()
|
||||||
|
refreshDevices()
|
||||||
|
client.onEvent = { [weak self] ev in
|
||||||
|
Task { @MainActor [weak self] in self?.handleEvent(ev) }
|
||||||
|
}
|
||||||
|
client.onLevel = { [weak self] _, rms in
|
||||||
|
Task { @MainActor [weak self] in self?.voiceState.level = rms }
|
||||||
|
}
|
||||||
|
broadcastPump.onBroadcastStarted = { [weak self] in self?.startScreenShare() }
|
||||||
|
broadcastPump.onBroadcastFinished = { [weak self] in self?.stopScreenShare() }
|
||||||
|
broadcastPump.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
broadcastPump.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Event dispatch
|
||||||
|
|
||||||
|
func handleEvent(_ ev: VoiceCatEvent) {
|
||||||
|
switch ev.type {
|
||||||
|
case .channelList:
|
||||||
|
refreshChannels()
|
||||||
|
syncSelfChannel()
|
||||||
|
case .userJoined:
|
||||||
|
// ev.text = nickname, ev.channelId = the channel they joined (per voicecat.h).
|
||||||
|
if ev.userId != selfUserId && ev.channelId == currentChannelId {
|
||||||
|
EventFeedback.shared.play(.channelJoin)
|
||||||
|
EventFeedback.shared.speak("\(ev.text ?? "Someone") joined")
|
||||||
|
}
|
||||||
|
refreshUsers()
|
||||||
|
syncSelfChannel()
|
||||||
|
case .userLeft:
|
||||||
|
// Capture the leaving user's prior nickname/channel before refreshUsers() drops them.
|
||||||
|
if ev.userId != selfUserId,
|
||||||
|
let gone = users.first(where: { $0.id == ev.userId }),
|
||||||
|
gone.channelId == currentChannelId {
|
||||||
|
EventFeedback.shared.play(.channelLeave)
|
||||||
|
EventFeedback.shared.speak("\(gone.nickname) left")
|
||||||
|
}
|
||||||
|
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"
|
||||||
|
let body = ev.text ?? ""
|
||||||
|
let isSelf = ev.userId == selfUserId
|
||||||
|
let isPrivate = ev.textScope == .private
|
||||||
|
messages.append(ChatMessage(
|
||||||
|
timestamp: Date(timeIntervalSince1970: Double(ev.timestampUnixMs) / 1000),
|
||||||
|
senderName: sender,
|
||||||
|
text: body,
|
||||||
|
scope: ev.textScope))
|
||||||
|
EventFeedback.shared.play(isPrivate
|
||||||
|
? (isSelf ? .pmSent : .pmRecv)
|
||||||
|
: (isSelf ? .channelSent : .channelRecv))
|
||||||
|
if !isSelf {
|
||||||
|
EventFeedback.shared.speak(isPrivate
|
||||||
|
? "Private message from \(sender): \(body)"
|
||||||
|
: "\(sender): \(body)")
|
||||||
|
}
|
||||||
|
case .talkState:
|
||||||
|
let talking = ev.u32a != 0
|
||||||
|
if ev.userId == selfUserId {
|
||||||
|
EventFeedback.shared.play(talking ? .vaStart : .vaStop)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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:
|
||||||
|
if ev.userId == selfUserId {
|
||||||
|
if ev.streamId == voiceState.localStreamId {
|
||||||
|
voiceState.localStreamId = 0
|
||||||
|
voiceState.micActive = false
|
||||||
|
voiceState.level = 0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
addActivity("Stream stopped (user \(ev.userId))")
|
||||||
|
}
|
||||||
|
case .voiceState:
|
||||||
|
let subscribed = ev.u32a != 0
|
||||||
|
voiceState.voiceSubscribed = subscribed
|
||||||
|
if subscribed {
|
||||||
|
doStartMicStream()
|
||||||
|
} else {
|
||||||
|
voiceState.micActive = false
|
||||||
|
voiceState.level = 0
|
||||||
|
EventFeedback.shared.play(.voiceOff)
|
||||||
|
}
|
||||||
|
case .joinResult:
|
||||||
|
if ev.result == .ok {
|
||||||
|
currentChannelId = ev.channelId
|
||||||
|
addActivity("Joined channel")
|
||||||
|
refreshUsers()
|
||||||
|
// Reconnect restore: this was our restore-join. Now that the server has
|
||||||
|
// processed the channel move, re-arm voice subscription (if the user was
|
||||||
|
// transmitting before the drop) and re-apply the local mute/deafen state.
|
||||||
|
// The server returns ok even when joining the channel we're already in, so
|
||||||
|
// this fires reliably for the Lobby-too case.
|
||||||
|
if didIssueRestoreJoin, let r = pendingRestore, r.channelId == ev.channelId {
|
||||||
|
didIssueRestoreJoin = false
|
||||||
|
completeRestore()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
addActivity("Join failed: \(ev.result.description)")
|
||||||
|
// Restore-join failed (channel was deleted, became password-protected or
|
||||||
|
// full while we were away). Give up on the voice/mute restore cleanly so we
|
||||||
|
// don't leave dangling state or attempt voice without being in a channel.
|
||||||
|
if didIssueRestoreJoin {
|
||||||
|
didIssueRestoreJoin = false
|
||||||
|
pendingRestore = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
case .disconnected:
|
||||||
|
// Audible cue, then hand the disconnect back to AppState so its reconnect state
|
||||||
|
// machine fires. This is the ONLY way AppState learns a live session dropped —
|
||||||
|
// after auth success, `SessionState.init` overwrites `client.onEvent`, so
|
||||||
|
// `AppState.handleConnectEvent` never sees this event. (Without this callback, a
|
||||||
|
// network drop on a live session would just play the cue and leave the session as a
|
||||||
|
// zombie — the user would have to tap Disconnect manually.)
|
||||||
|
EventFeedback.shared.play(ev.result == .ok ? .logout : .connectionLost)
|
||||||
|
EventFeedback.shared.speak(ev.result == .ok ? "Disconnected" : "Connection lost")
|
||||||
|
appState?.onLiveSessionDisconnected()
|
||||||
|
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's bootstrap/event-handling sync. 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's handling
|
||||||
|
/// of UserEvent.UPDATED for the self user.
|
||||||
|
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 joinVoice() {
|
||||||
|
AVAudioApplication.requestRecordPermission { [weak self] granted in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard let self else { return }
|
||||||
|
if granted {
|
||||||
|
let result = self.client.joinVoice()
|
||||||
|
if result != .ok {
|
||||||
|
self.addActivity("Failed to join voice: \(result.description)")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.addActivity("Microphone permission denied — grant in Settings > Privacy > Microphone")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func doStartMicStream() {
|
||||||
|
do {
|
||||||
|
try AudioSessionManager.shared.ensureSessionActive()
|
||||||
|
} catch {
|
||||||
|
addActivity("AVAudioSession activate failed: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let desc = StreamDescriptor(kind: .mic, deviceId: voiceState.currentDeviceId, label: "Mic",
|
||||||
|
externalFeed: true)
|
||||||
|
let (result, streamId) = client.startStream(desc)
|
||||||
|
guard result == .ok else {
|
||||||
|
addActivity("Failed to start mic: \(result.description)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
voiceState.micActive = true
|
||||||
|
voiceState.localStreamId = streamId
|
||||||
|
EventFeedback.shared.play(.voiceOn)
|
||||||
|
|
||||||
|
let channels = IOSAudioRouter.shared.captureChannels.channelCount
|
||||||
|
if channels != 1 {
|
||||||
|
client.setCaptureChannels(streamId: streamId, channels: channels)
|
||||||
|
}
|
||||||
|
IOSAudioEngine.shared.startMic(streamId: streamId, channels: channels)
|
||||||
|
}
|
||||||
|
|
||||||
|
func leaveVoice() {
|
||||||
|
if voiceState.screenStreamId != 0 { stopScreenShare() }
|
||||||
|
client.setPushToTalk(false)
|
||||||
|
IOSAudioEngine.shared.stopMic()
|
||||||
|
client.leaveVoice()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Reconnect restore
|
||||||
|
|
||||||
|
/// Called by `AppState` after a reconnect's auth success to rejoin the prior channel and
|
||||||
|
/// re-enable the prior voice/mic state. Drives the restore through the `.joinResult` event
|
||||||
|
/// so we re-arm voice only AFTER the server processed the join — joining voice before the
|
||||||
|
/// channel move would be rejected server-side. `micMuted`/`deafened` are the user's LOCAL
|
||||||
|
/// mute/deafen state at the moment of the drop; the server resets those on a fresh auth, so
|
||||||
|
/// we re-push them via `setMute` after the channel is restored.
|
||||||
|
func requestRestore(channelId: UInt32, voiceSubscribed: Bool,
|
||||||
|
micMuted: Bool, deafened: Bool) {
|
||||||
|
pendingRestore = RestoreRequest(channelId: channelId,
|
||||||
|
voiceSubscribed: voiceSubscribed,
|
||||||
|
micMuted: micMuted,
|
||||||
|
deafened: deafened)
|
||||||
|
didIssueRestoreJoin = false
|
||||||
|
if channelId != 0 {
|
||||||
|
// The server auto-placed us in the Lobby on auth; join our prior channel explicitly.
|
||||||
|
// `vc_join_channel` is idempotent server-side (joining the channel you're already in
|
||||||
|
// returns ok), so this is safe even if the prior channel was the Lobby.
|
||||||
|
client.joinChannel(channelId)
|
||||||
|
didIssueRestoreJoin = true
|
||||||
|
} else {
|
||||||
|
// No prior channel — go straight to the voice/mute restore. (voiceSubscribed with
|
||||||
|
// channelId == 0 is contradictory; `completeRestore` further guards on
|
||||||
|
// currentChannelId != 0 before subscribing to voice.)
|
||||||
|
completeRestore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finish the restore after the channel is in place (or there was no channel to restore):
|
||||||
|
/// re-subscribe to voice if the user was transmitting, and re-apply the local mute/deafen
|
||||||
|
/// state. Safe to call once per `pendingRestore`; clears it.
|
||||||
|
private func completeRestore() {
|
||||||
|
guard let r = pendingRestore else { return }
|
||||||
|
if r.voiceSubscribed && currentChannelId != 0 {
|
||||||
|
joinVoice()
|
||||||
|
}
|
||||||
|
setMute(r.micMuted, deafened: r.deafened)
|
||||||
|
addActivity("Restored to channel \(currentChannelId)"
|
||||||
|
+ (r.voiceSubscribed ? " with voice" : ""))
|
||||||
|
pendingRestore = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
UserDefaults.standard.set(Int(mode.rawValue), forKey: DefaultsKey.inputMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setVadThreshold(_ threshold: Float) {
|
||||||
|
client.setVadThreshold(threshold)
|
||||||
|
voiceState.vadThreshold = threshold
|
||||||
|
UserDefaults.standard.set(threshold, forKey: DefaultsKey.vadThreshold)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setInputGain(_ gain: Float) {
|
||||||
|
client.setInputGain(gain)
|
||||||
|
voiceState.inputGain = gain
|
||||||
|
UserDefaults.standard.set(gain, forKey: DefaultsKey.inputGain)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setInputNoiseReduction(_ on: Bool) {
|
||||||
|
client.setInputNoiseReduction(on)
|
||||||
|
voiceState.inputNoiseReduction = on
|
||||||
|
UserDefaults.standard.set(on, forKey: DefaultsKey.inputNoiseReduction)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Persisted input settings
|
||||||
|
|
||||||
|
private enum DefaultsKey {
|
||||||
|
static let inputMode = "voice.inputMode"
|
||||||
|
static let vadThreshold = "voice.vadThreshold"
|
||||||
|
static let inputGain = "voice.inputGain"
|
||||||
|
static let inputNoiseReduction = "voice.inputNoiseReduction"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore the saved input mode / VAD threshold / mic gain and push them into the core so a
|
||||||
|
/// relaunch keeps the user's transmission settings instead of resetting to VAD defaults.
|
||||||
|
private func loadAndApplyVoiceSettings() {
|
||||||
|
let d = UserDefaults.standard
|
||||||
|
if d.object(forKey: DefaultsKey.inputMode) != nil {
|
||||||
|
let raw = UInt32(d.integer(forKey: DefaultsKey.inputMode))
|
||||||
|
voiceState.inputMode = VoiceCatInputMode(rawValue: raw) ?? .voiceActivation
|
||||||
|
}
|
||||||
|
if d.object(forKey: DefaultsKey.vadThreshold) != nil {
|
||||||
|
voiceState.vadThreshold = d.float(forKey: DefaultsKey.vadThreshold)
|
||||||
|
}
|
||||||
|
if d.object(forKey: DefaultsKey.inputGain) != nil {
|
||||||
|
voiceState.inputGain = d.float(forKey: DefaultsKey.inputGain)
|
||||||
|
}
|
||||||
|
if d.object(forKey: DefaultsKey.inputNoiseReduction) != nil {
|
||||||
|
voiceState.inputNoiseReduction = d.bool(forKey: DefaultsKey.inputNoiseReduction)
|
||||||
|
}
|
||||||
|
client.setInputMode(voiceState.inputMode)
|
||||||
|
client.setVadThreshold(voiceState.vadThreshold)
|
||||||
|
client.setInputGain(voiceState.inputGain)
|
||||||
|
client.setInputNoiseReduction(voiceState.inputNoiseReduction)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var pttEngaged = false
|
||||||
|
func setPushToTalk(_ active: Bool) {
|
||||||
|
client.setPushToTalk(active)
|
||||||
|
// Play the PTT cue only on the press transition (the gesture fires repeatedly while held).
|
||||||
|
if active && !pttEngaged { EventFeedback.shared.play(.ptt) }
|
||||||
|
pttEngaged = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" : "")")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
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) — populated from the channel's current config when editing.
|
||||||
|
@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)"
|
||||||
|
sortOrder = "\(ch.sortOrder)"
|
||||||
|
stereo = ch.audio.stereo
|
||||||
|
bitrate = "\(ch.audio.bitrateBps)"
|
||||||
|
sampleRate = "\(ch.audio.sampleRate)"
|
||||||
|
frameMs = ch.audio.frameMs
|
||||||
|
application = ch.audio.application
|
||||||
|
packetLoss = "\(ch.audio.expectedPacketLoss)"
|
||||||
|
complexity = Int(ch.audio.complexity)
|
||||||
|
fec = ch.audio.fec
|
||||||
|
dtx = ch.audio.dtx
|
||||||
|
dred = ch.audio.dred
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" : "")")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, targetId: session.currentChannelId)
|
||||||
|
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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
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...4, 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 {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
// Notification feedback prefs — keys shared with VoiceCatCore's FeedbackSettings, so the
|
||||||
|
// EventFeedback player reads the same values these toggles write.
|
||||||
|
@AppStorage("feedback.sounds") private var soundsEnabled = true
|
||||||
|
@AppStorage("feedback.speech") private var speechEnabled = false
|
||||||
|
@AppStorage("feedback.volume") private var soundsVolume = 1.0
|
||||||
|
@AppStorage("feedback.selfTalk") private var selfTalkEnabled = false
|
||||||
|
@AppStorage("feedback.ptt") private var pttSoundEnabled = 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 Voice Chat the native iOS
|
||||||
|
// Voice-Processing unit (VPIO) does echo cancellation, noise suppression and
|
||||||
|
// automatic gain control; the stereo / mono-mic / A2DP configs 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 configuration (stereo / A2DP / off)",
|
||||||
|
systemImage: "waveform.slash")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityLabel("Echo cancellation is off in this configuration")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
|
||||||
|
// Voice-processing (VPIO) controls — only meaningful on a VPIO-capable
|
||||||
|
// config (mono + standard + non-A2DP). iOS bundles echo cancellation and
|
||||||
|
// noise suppression into one master switch (no per-stage toggle); AGC is
|
||||||
|
// the one sub-stage it lets us control independently.
|
||||||
|
if router.voiceProcessingAvailable {
|
||||||
|
Toggle("Voice Processing (AEC + noise suppression)", isOn: Binding(
|
||||||
|
get: { router.voiceProcessingEnabled },
|
||||||
|
set: { router.setVoiceProcessingEnabled($0) }
|
||||||
|
))
|
||||||
|
.accessibilityLabel("Voice processing")
|
||||||
|
.accessibilityHint("Echo cancellation and noise suppression, bundled together by iOS.")
|
||||||
|
|
||||||
|
if router.voiceProcessingEnabled {
|
||||||
|
Toggle("Automatic Gain Control", isOn: Binding(
|
||||||
|
get: { router.agcEnabled },
|
||||||
|
set: { router.setAgcEnabled($0) }
|
||||||
|
))
|
||||||
|
.accessibilityLabel("Automatic gain control")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Mic Volume: \(Int((session.voiceState.inputGain * 100).rounded()))%")
|
||||||
|
.font(.caption)
|
||||||
|
Slider(
|
||||||
|
value: Binding(
|
||||||
|
get: { Double(session.voiceState.inputGain) },
|
||||||
|
set: { session.setInputGain(Float($0)) }
|
||||||
|
),
|
||||||
|
in: 0...4, step: 0.05
|
||||||
|
)
|
||||||
|
.accessibilityLabel("Microphone volume")
|
||||||
|
.accessibilityValue("\(Int((session.voiceState.inputGain * 100).rounded())) percent")
|
||||||
|
}
|
||||||
|
|
||||||
|
Toggle("Noise Reduction (RNNoise)", isOn: Binding(
|
||||||
|
get: { session.voiceState.inputNoiseReduction },
|
||||||
|
set: { session.setInputNoiseReduction($0) }
|
||||||
|
))
|
||||||
|
.accessibilityLabel("Microphone noise reduction")
|
||||||
|
.accessibilityHint("Denoises your microphone signal for everyone listening.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Notifications
|
||||||
|
Section("Notifications") {
|
||||||
|
Toggle("Event sounds", isOn: $soundsEnabled)
|
||||||
|
.accessibilityLabel("Play event sounds")
|
||||||
|
if soundsEnabled {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Sound volume")
|
||||||
|
.font(.caption)
|
||||||
|
Slider(value: $soundsVolume, in: 0...1)
|
||||||
|
.accessibilityLabel("Sound volume")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Toggle("Speak events (text-to-speech)", isOn: $speechEnabled)
|
||||||
|
.accessibilityLabel("Speak events")
|
||||||
|
.accessibilityHint("Announces joins and leaves and reads message text aloud.")
|
||||||
|
Toggle("Your own voice-activity sounds", isOn: $selfTalkEnabled)
|
||||||
|
.accessibilityLabel("Voice activity sounds")
|
||||||
|
Toggle("Push-to-talk cue", isOn: $pttSoundEnabled)
|
||||||
|
.accessibilityLabel("Push to talk cue")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.leaveVoice()
|
||||||
|
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) {}
|
||||||
|
}
|
||||||
@@ -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.")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
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 }
|
||||||
|
// The context menu is long-press only, which VoiceOver doesn't surface — mirror the
|
||||||
|
// same buttons as accessibility actions so VoiceOver users can reach per-user tuning
|
||||||
|
// (and the admin actions) via the actions rotor on the focused row.
|
||||||
|
.accessibilityActions { 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" : "")")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.leaveVoice()
|
||||||
|
} else {
|
||||||
|
session.joinVoice()
|
||||||
|
}
|
||||||
|
} 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" : "Join Voice")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.leaveVoice()
|
||||||
|
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.joinVoice() }
|
||||||
|
session.setPushToTalk(true)
|
||||||
|
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||||
|
}
|
||||||
|
.onEnded { _ in
|
||||||
|
session.setPushToTalk(false)
|
||||||
|
session.leaveVoice()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.me.iamtalon.voicecat</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
// !$*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 */; };
|
||||||
|
AAAA00000000000000000051 /* InputDeviceCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00000000000000000050 /* InputDeviceCapture.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>"; };
|
||||||
|
AAAA00000000000000000050 /* InputDeviceCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputDeviceCapture.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 */,
|
||||||
|
AAAA00000000000000000050 /* InputDeviceCapture.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 */,
|
||||||
|
AAAA00000000000000000051 /* InputDeviceCapture.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>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import CoreAudio
|
||||||
|
|
||||||
|
// InputDeviceCapture — captures a single hardware INPUT device (mic / line-in / aux) on macOS and
|
||||||
|
// emits 20 ms (960 samples/channel @ 48 kHz, interleaved int16) frames for the aux outgoing stream.
|
||||||
|
//
|
||||||
|
// The input-device analogue of ScreenAudioCapture (which captures system audio via
|
||||||
|
// ScreenCaptureKit). The core already owns ONE capture device (the mic) and can't open a second
|
||||||
|
// arbitrary input, so for the aux stream the client captures the chosen device here and feeds PCM
|
||||||
|
// into the core via `vc_stream_feed_pcm` — the same external-feed pipeline screen audio uses.
|
||||||
|
//
|
||||||
|
// Device selection: an AVAudioEngine's input node wraps an AUHAL audio unit; setting
|
||||||
|
// kAudioOutputUnitProperty_CurrentDevice on it pins capture to a specific Core Audio device. We
|
||||||
|
// pass the device's *UID* (stable across reboots/replugs, unlike AudioDeviceID) and resolve it at
|
||||||
|
// start. The tap delivers Float32; AVAudioConverter resamples/quantises to 48 kHz int16.
|
||||||
|
final class InputDeviceCapture {
|
||||||
|
|
||||||
|
/// Receives a full 20 ms frame: (interleaved int16 PCM, samplesPerChannel = 960, channels).
|
||||||
|
typealias PcmHandler = (UnsafePointer<Int16>, Int, UInt32) -> Void
|
||||||
|
|
||||||
|
enum CaptureError: Error { case deviceNotFound, engineStartFailed(OSStatus) }
|
||||||
|
|
||||||
|
private static let frameSamplesPerChannel = 960 // 20 ms @ 48 kHz
|
||||||
|
|
||||||
|
private let deviceUID: String? // nil = system default input device
|
||||||
|
private let onPcm: PcmHandler
|
||||||
|
private let engine = AVAudioEngine()
|
||||||
|
private var converter: AVAudioConverter?
|
||||||
|
private var outFormat: AVAudioFormat?
|
||||||
|
private var channels = 1
|
||||||
|
|
||||||
|
/// Interleaved int16 carry-over between tap callbacks (the tap buffer doesn't align to 20 ms),
|
||||||
|
/// drained in whole `frameSamplesPerChannel * channels` chunks. Only touched on the tap queue.
|
||||||
|
private var pending: [Int16] = []
|
||||||
|
|
||||||
|
init(deviceUID: String?, onPcm: @escaping PcmHandler) {
|
||||||
|
self.deviceUID = deviceUID
|
||||||
|
self.onPcm = onPcm
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Begin capture. Throws if the device can't be resolved or the engine fails to start.
|
||||||
|
func start() throws {
|
||||||
|
let input = engine.inputNode
|
||||||
|
|
||||||
|
// Pin the engine's AUHAL to the chosen device (skip for default — the engine already uses
|
||||||
|
// the system default input). Must happen before reading inputFormat, which changes with
|
||||||
|
// the selected device.
|
||||||
|
if let deviceUID, let devId = Self.deviceID(forUID: deviceUID) {
|
||||||
|
var dev = devId
|
||||||
|
let status = AudioUnitSetProperty(input.audioUnit!,
|
||||||
|
kAudioOutputUnitProperty_CurrentDevice,
|
||||||
|
kAudioUnitScope_Global, 0,
|
||||||
|
&dev, UInt32(MemoryLayout<AudioDeviceID>.size))
|
||||||
|
if status != noErr { throw CaptureError.engineStartFailed(status) }
|
||||||
|
} else if deviceUID != nil {
|
||||||
|
throw CaptureError.deviceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
let inFormat = input.inputFormat(forBus: 0)
|
||||||
|
channels = max(1, min(2, Int(inFormat.channelCount)))
|
||||||
|
guard let out = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: 48000,
|
||||||
|
channels: AVAudioChannelCount(channels), interleaved: true)
|
||||||
|
else { throw CaptureError.deviceNotFound }
|
||||||
|
outFormat = out
|
||||||
|
converter = AVAudioConverter(from: inFormat, to: out)
|
||||||
|
|
||||||
|
input.installTap(onBus: 0, bufferSize: 960, format: inFormat) { [weak self] buf, _ in
|
||||||
|
self?.process(buf)
|
||||||
|
}
|
||||||
|
engine.prepare()
|
||||||
|
do { try engine.start() }
|
||||||
|
catch { throw CaptureError.engineStartFailed(-1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop capture and tear down the engine. Safe to call multiple times.
|
||||||
|
func stop() {
|
||||||
|
engine.inputNode.removeTap(onBus: 0)
|
||||||
|
if engine.isRunning { engine.stop() }
|
||||||
|
converter = nil
|
||||||
|
pending.removeAll(keepingCapacity: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conversion (called on the tap's realtime thread)
|
||||||
|
|
||||||
|
private func process(_ inBuf: AVAudioPCMBuffer) {
|
||||||
|
guard let converter, let outFormat else { return }
|
||||||
|
|
||||||
|
// Output capacity must cover up-sampling (e.g. 44.1 → 48 kHz) plus slack.
|
||||||
|
let ratio = outFormat.sampleRate / inBuf.format.sampleRate
|
||||||
|
let cap = AVAudioFrameCount(Double(inBuf.frameLength) * ratio + 32)
|
||||||
|
guard cap > 0, let outBuf = AVAudioPCMBuffer(pcmFormat: outFormat, frameCapacity: cap)
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
var fed = false
|
||||||
|
var err: NSError?
|
||||||
|
let status = converter.convert(to: outBuf, error: &err) { _, outStatus in
|
||||||
|
if fed { outStatus.pointee = .noDataNow; return nil }
|
||||||
|
fed = true
|
||||||
|
outStatus.pointee = .haveData
|
||||||
|
return inBuf
|
||||||
|
}
|
||||||
|
guard status != .error, outBuf.frameLength > 0,
|
||||||
|
let ch = outBuf.int16ChannelData else { return }
|
||||||
|
|
||||||
|
// Interleaved int16: all channels live in the first buffer (ch[0]).
|
||||||
|
let n = Int(outBuf.frameLength) * channels
|
||||||
|
pending.append(contentsOf: UnsafeBufferPointer(start: ch[0], count: n))
|
||||||
|
emit()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fire `onPcm` for every whole 20 ms frame accumulated.
|
||||||
|
private func emit() {
|
||||||
|
let full = Self.frameSamplesPerChannel * channels
|
||||||
|
while pending.count >= full {
|
||||||
|
pending.withUnsafeBufferPointer { buf in
|
||||||
|
onPcm(buf.baseAddress!, Self.frameSamplesPerChannel, UInt32(channels))
|
||||||
|
}
|
||||||
|
pending.removeFirst(full)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UID → AudioDeviceID resolution
|
||||||
|
|
||||||
|
private static func deviceID(forUID uid: String) -> AudioDeviceID? {
|
||||||
|
var addr = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyTranslateUIDToDevice,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var deviceID = AudioDeviceID(0)
|
||||||
|
var cfUID = uid as CFString
|
||||||
|
var size = UInt32(MemoryLayout<AudioDeviceID>.size)
|
||||||
|
let status = withUnsafeMutablePointer(to: &cfUID) { uidPtr -> OSStatus in
|
||||||
|
AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &addr,
|
||||||
|
UInt32(MemoryLayout<CFString>.size), uidPtr,
|
||||||
|
&size, &deviceID)
|
||||||
|
}
|
||||||
|
return (status == noErr && deviceID != 0) ? deviceID : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InputDeviceInfo / InputDeviceEnumerator — Core Audio input-device enumeration for the aux-stream
|
||||||
|
// picker. Separate from the core's vc_list_devices (whose ids are miniaudio-opaque and can't be
|
||||||
|
// passed to Core Audio); the aux device is client-captured, so the picker uses device UIDs.
|
||||||
|
struct InputDeviceInfo: Equatable {
|
||||||
|
let uid: String // stable across reboots/replugs — what we persist
|
||||||
|
let name: String
|
||||||
|
let isDefault: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
enum InputDeviceEnumerator {
|
||||||
|
|
||||||
|
/// All Core Audio devices that expose at least one input channel.
|
||||||
|
static func list() -> [InputDeviceInfo] {
|
||||||
|
let defaultUID = defaultInputUID()
|
||||||
|
var result: [InputDeviceInfo] = []
|
||||||
|
|
||||||
|
for devID in allDeviceIDs() {
|
||||||
|
guard inputChannelCount(devID) > 0 else { continue }
|
||||||
|
guard let uid = stringProperty(devID, kAudioDevicePropertyDeviceUID) else { continue }
|
||||||
|
let name = stringProperty(devID, kAudioObjectPropertyName)
|
||||||
|
?? stringProperty(devID, kAudioDevicePropertyDeviceNameCFString)
|
||||||
|
?? "Unknown input device"
|
||||||
|
result.append(InputDeviceInfo(uid: uid, name: name, isDefault: uid == defaultUID))
|
||||||
|
}
|
||||||
|
return result.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Core Audio helpers
|
||||||
|
|
||||||
|
private static func allDeviceIDs() -> [AudioDeviceID] {
|
||||||
|
var addr = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDevices,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var size = UInt32(0)
|
||||||
|
guard AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil,
|
||||||
|
&size) == noErr, size > 0 else { return [] }
|
||||||
|
let count = Int(size) / MemoryLayout<AudioDeviceID>.size
|
||||||
|
var ids = [AudioDeviceID](repeating: 0, count: count)
|
||||||
|
let status = AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &addr, 0,
|
||||||
|
nil, &size, &ids)
|
||||||
|
return status == noErr ? ids : []
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func inputChannelCount(_ devID: AudioDeviceID) -> Int {
|
||||||
|
var addr = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioDevicePropertyStreamConfiguration,
|
||||||
|
mScope: kAudioObjectPropertyScopeInput,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var size = UInt32(0)
|
||||||
|
guard AudioObjectGetPropertyDataSize(devID, &addr, 0, nil, &size) == noErr, size > 0
|
||||||
|
else { return 0 }
|
||||||
|
let bufList = UnsafeMutableRawPointer.allocate(byteCount: Int(size),
|
||||||
|
alignment: MemoryLayout<AudioBufferList>.alignment)
|
||||||
|
defer { bufList.deallocate() }
|
||||||
|
guard AudioObjectGetPropertyData(devID, &addr, 0, nil, &size, bufList) == noErr
|
||||||
|
else { return 0 }
|
||||||
|
let abl = UnsafeMutableAudioBufferListPointer(
|
||||||
|
bufList.assumingMemoryBound(to: AudioBufferList.self))
|
||||||
|
return abl.reduce(0) { $0 + Int($1.mNumberChannels) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func defaultInputUID() -> String? {
|
||||||
|
var addr = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDefaultInputDevice,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var devID = AudioDeviceID(0)
|
||||||
|
var size = UInt32(MemoryLayout<AudioDeviceID>.size)
|
||||||
|
guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil,
|
||||||
|
&size, &devID) == noErr, devID != 0 else { return nil }
|
||||||
|
return stringProperty(devID, kAudioDevicePropertyDeviceUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func stringProperty(_ devID: AudioDeviceID,
|
||||||
|
_ selector: AudioObjectPropertySelector) -> String? {
|
||||||
|
var addr = AudioObjectPropertyAddress(
|
||||||
|
mSelector: selector,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var cf: CFString? = nil
|
||||||
|
var size = UInt32(MemoryLayout<CFString?>.size)
|
||||||
|
let status = withUnsafeMutablePointer(to: &cf) { ptr in
|
||||||
|
AudioObjectGetPropertyData(devID, &addr, 0, nil, &size, ptr)
|
||||||
|
}
|
||||||
|
guard status == noErr else { return nil }
|
||||||
|
return cf as String?
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import ScreenCaptureKit
|
||||||
|
|
||||||
|
// ScreenCaptureKit filters audio by application bundle identifier.
|
||||||
|
enum ScreenAudioScope: Equatable {
|
||||||
|
case entireDesktop
|
||||||
|
case onlyApps([String])
|
||||||
|
case allExcept([String])
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SCStream requires a minimal video configuration even for audio-only capture. Only its audio
|
||||||
|
// output is registered, and current-process audio is excluded to prevent feedback.
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user