Compare commits
105 Commits
post-m3-va
...
cs-port
| 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 |
24
.dockerignore
Normal file
24
.dockerignore
Normal file
@@ -0,0 +1,24 @@
|
||||
# Git history — large and never needed inside the build context
|
||||
.git/
|
||||
|
||||
# Previous build outputs
|
||||
build/
|
||||
|
||||
# Native GUI client code (Swift/Xcode, C#/WinForms) — server build doesn't need these
|
||||
clients/
|
||||
|
||||
# Documentation and prose — not compiled
|
||||
docs/
|
||||
*.md
|
||||
AGENTS.md
|
||||
PROGRESS.md
|
||||
CLAUDE.md
|
||||
|
||||
# Editor / tooling config
|
||||
.clang-format
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# OS noise
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
87
.github/workflows/build-linux.yml
vendored
Normal file
87
.github/workflows/build-linux.yml
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
name: Build Linux Binaries
|
||||
|
||||
# Builds stripped voicecat-server + voicecat-admin for linux/amd64 and linux/arm64.
|
||||
# Run manually from the Actions tab, or on any push to main.
|
||||
# Artifacts are downloadable from the workflow run for ~90 days.
|
||||
|
||||
on:
|
||||
workflow_dispatch: # manual trigger from the Actions tab
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'core/**'
|
||||
- 'server/**'
|
||||
- 'tools/**'
|
||||
- 'cmake/**'
|
||||
- 'CMakeLists.txt'
|
||||
- 'CMakePresets.json'
|
||||
- 'vcpkg.json'
|
||||
- 'Dockerfile'
|
||||
- '.github/workflows/build-linux.yml'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
runner: ubuntu-24.04
|
||||
vcpkg_triplet: x64-linux
|
||||
- arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
vcpkg_triplet: arm64-linux
|
||||
|
||||
name: linux/${{ matrix.arch }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Cache vcpkg packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/vcpkg
|
||||
/usr/local/share/vcpkg/buildtrees
|
||||
key: vcpkg-${{ matrix.vcpkg_triplet }}-${{ hashFiles('vcpkg.json') }}
|
||||
restore-keys: |
|
||||
vcpkg-${{ matrix.vcpkg_triplet }}-
|
||||
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
build-essential cmake ninja-build git curl zip unzip tar \
|
||||
pkg-config autoconf autoconf-archive automake libtool nasm python3
|
||||
|
||||
- name: Set up vcpkg
|
||||
run: |
|
||||
VCPKG_COMMIT=$(jq -r '."builtin-baseline"' vcpkg.json)
|
||||
git init /tmp/vcpkg
|
||||
git -C /tmp/vcpkg remote add origin https://github.com/microsoft/vcpkg.git
|
||||
git -C /tmp/vcpkg fetch --depth=1 origin "$VCPKG_COMMIT"
|
||||
git -C /tmp/vcpkg checkout FETCH_HEAD
|
||||
/tmp/vcpkg/bootstrap-vcpkg.sh -disableMetrics
|
||||
echo "VCPKG_ROOT=/tmp/vcpkg" >> "$GITHUB_ENV"
|
||||
echo "VCPKG_DISABLE_METRICS=1" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build (server-release preset)
|
||||
run: |
|
||||
cmake --preset server-release
|
||||
cmake --build --preset server-release
|
||||
|
||||
- name: Collect binaries
|
||||
run: |
|
||||
mkdir -p dist
|
||||
cp build/server-release/bin/voicecat-server dist/
|
||||
cp build/server-release/bin/voicecat-admin dist/
|
||||
file dist/voicecat-server dist/voicecat-admin
|
||||
ls -lh dist/
|
||||
|
||||
- name: Upload binaries
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: voicecat-linux-${{ matrix.arch }}
|
||||
path: dist/
|
||||
retention-days: 90
|
||||
21
.gitignore
vendored
21
.gitignore
vendored
@@ -1,6 +1,9 @@
|
||||
# Build output
|
||||
/build/
|
||||
/out/
|
||||
|
||||
# Staged distribution artifacts (scripts/build-*.sh output)
|
||||
/dist/
|
||||
*.o
|
||||
*.obj
|
||||
*.a
|
||||
@@ -11,9 +14,8 @@
|
||||
*.exe
|
||||
*.pdb
|
||||
|
||||
# vcpkg
|
||||
# vcpkg (bundled as a submodule at /vcpkg — see docs/building.md §2)
|
||||
/vcpkg_installed/
|
||||
/vcpkg/
|
||||
|
||||
# Generated protobuf
|
||||
*.pb.cc
|
||||
@@ -33,8 +35,21 @@
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Apple / Windows client build artifacts (added in M4)
|
||||
# Apple / Windows client build artifacts
|
||||
clients/apple/**/build/
|
||||
clients/apple/**/*.xcodeproj/xcuserdata/
|
||||
clients/apple/**/*.xcodeproj/project.xcworkspace/
|
||||
clients/apple/**/*.xcframework/
|
||||
clients/windows/**/bin/
|
||||
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__/
|
||||
|
||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "vcpkg"]
|
||||
path = vcpkg
|
||||
url = https://github.com/microsoft/vcpkg.git
|
||||
41
AGENTS.md
41
AGENTS.md
@@ -11,9 +11,11 @@ Read those, then use the method below.
|
||||
|
||||
## What this repo is right now
|
||||
|
||||
A complete **design** ([`docs/`](docs/)) plus an **M0 skeleton**: it compiles and links, but
|
||||
`libvoicecat`'s subsystems are stubs that return `VC_ERR_NOT_IMPLEMENTED`. Your job is to turn
|
||||
the design into working software, one milestone at a time.
|
||||
A complete **design** ([`docs/`](docs/)) plus a working implementation through M5: real TLS
|
||||
control plane, encrypted UDP voice (Opus), multi-stream, TOFU identity pinning, channel tree,
|
||||
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)
|
||||
|
||||
@@ -36,24 +38,43 @@ making progress.
|
||||
|
||||
## 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
|
||||
git submodule update --init vcpkg # one-time, after cloning
|
||||
./vcpkg/bootstrap-vcpkg.sh # .bat on Windows
|
||||
cmake --preset dev
|
||||
cmake --build --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
|
||||
export VCPKG_ROOT=/path/to/vcpkg # bootstrap vcpkg first; cross-platform
|
||||
cmake --preset server-release # installs deps pinned in vcpkg.json
|
||||
cmake --build --preset server-release
|
||||
cmake --preset skeleton
|
||||
cmake --build --preset skeleton
|
||||
ctest --preset skeleton
|
||||
```
|
||||
|
||||
`vcpkg.json` currently has a placeholder `builtin-baseline` — set it to a real vcpkg commit
|
||||
SHA the first time you enable `VOICECAT_USE_VCPKG_DEPS`.
|
||||
> **Windows gotcha — always run `ctest` and built binaries via PowerShell, not Git Bash.**
|
||||
> 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)
|
||||
|
||||
|
||||
63
CLAUDE.md
63
CLAUDE.md
@@ -4,13 +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
|
||||
and what's next* read [`PROGRESS.md`](PROGRESS.md); for *design* read [`docs/`](docs/).
|
||||
|
||||
> **One-line status:** M3 (multi-stream, per-channel tuning) is complete, plus a follow-up
|
||||
> pass closing the device enumeration / VAD-PTT input gate / stereo playback / WASAPI loopback
|
||||
> gaps it left open (`ctest --test-dir build/m1-dev` green — 12/12 tests, including
|
||||
> `test_vad_ptt_devices`, and `vccli --voice`/`--list-devices` manually verified live). Real
|
||||
> `webrtc-audio-processing` (AEC/NS/AGC) is still unbuilt — no working Windows/MSVC port
|
||||
> upstream — so v1 ships a lightweight energy/RMS VAD instead. Next up is **M4** (native
|
||||
> clients). See [`PROGRESS.md`](PROGRESS.md).
|
||||
> **One-line status:** M5 (moderation & admin UI) is complete — permissions, kick/ban/move,
|
||||
> server-mute, channel CRUD, in-app account management, disconnect/keepalive/reaper. Windows
|
||||
> WinForms C# client shipped (M4). **macOS AppKit client shipped** — `VoiceCatMac.xcodeproj`
|
||||
> at `clients/apple/macOS/`. **iOS SwiftUI client shipped** — `VoiceCatiOS.xcodeproj` at
|
||||
> `clients/apple/iOS/`. `ctest --preset dev` green — 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)
|
||||
+ UDP (media), no WebRTC, encrypted by default. A shared C++ core (`libvoicecat`) drives
|
||||
@@ -20,18 +25,21 @@ native clients (Swift on macOS/iOS, C# on Windows) and the server.
|
||||
|
||||
## Build & test commands
|
||||
|
||||
The **M0 skeleton builds with no third-party dependencies** — just CMake + Ninja + a C++20
|
||||
compiler. Deps (vcpkg) are off until a subsystem needs them.
|
||||
The default development preset is **`dev`** — it builds everything (server + tools + tests)
|
||||
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
|
||||
# Configure + build the skeleton (default; no vcpkg needed)
|
||||
# Configure + build (default development preset; needs VCPKG_ROOT)
|
||||
cmake --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
|
||||
|
||||
# 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/voicecat-server --help
|
||||
./build/dev/bin/voicecat-server --name "My Server"
|
||||
@@ -45,14 +53,34 @@ rm -rf build/dev # nuke; or:
|
||||
cmake --build --preset dev --target clean
|
||||
```
|
||||
|
||||
When you start a subsystem that needs real libraries (mbedTLS, libsodium, opus, protobuf, …),
|
||||
turn vcpkg deps on:
|
||||
Other presets (see [`docs/building.md`](docs/building.md) for full detail):
|
||||
|
||||
```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
|
||||
# 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
|
||||
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):
|
||||
@@ -121,6 +149,7 @@ Read [`docs/`](docs/) before changing behavior. Order:
|
||||
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)
|
||||
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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -3,14 +3,17 @@ cmake_minimum_required(VERSION 3.25)
|
||||
project(voicecat
|
||||
VERSION 0.0.1
|
||||
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 ───────────────────────────────────────────────────────────────────
|
||||
# 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_TOOLS "Build the vccli headless test client" 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_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 ───────────────────────────────────────────────────────────────────
|
||||
add_subdirectory(core)
|
||||
|
||||
@@ -38,9 +55,7 @@ endif()
|
||||
|
||||
if(VOICECAT_BUILD_TOOLS)
|
||||
add_subdirectory(tools/vccli)
|
||||
if(VOICECAT_USE_VCPKG_DEPS)
|
||||
add_subdirectory(tools/voicecat-admin)
|
||||
endif()
|
||||
add_subdirectory(tools/voicecat-admin)
|
||||
endif()
|
||||
|
||||
if(VOICECAT_BUILD_TESTS)
|
||||
@@ -49,5 +64,4 @@ if(VOICECAT_BUILD_TESTS)
|
||||
endif()
|
||||
|
||||
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})")
|
||||
|
||||
@@ -3,75 +3,132 @@
|
||||
"cmakeMinimumRequired": { "major": 3, "minor": 25, "patch": 0 },
|
||||
"configurePresets": [
|
||||
{
|
||||
"name": "dev",
|
||||
"displayName": "Dev (skeleton, no third-party deps)",
|
||||
"description": "Builds the stub skeleton with just a compiler. Works out of the box; no vcpkg required.",
|
||||
"name": "vcpkg-common",
|
||||
"hidden": true,
|
||||
"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",
|
||||
"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",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "Debug",
|
||||
"VOICECAT_USE_VCPKG_DEPS": "OFF"
|
||||
"VOICECAT_BUILD_TOOLS": "ON",
|
||||
"VOICECAT_BUILD_TESTS": "ON"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "vcpkg-base",
|
||||
"hidden": true,
|
||||
"description": "Shared base for presets that link real deps via vcpkg. Requires VCPKG_ROOT in the environment.",
|
||||
"generator": "Ninja",
|
||||
"toolchainFile": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake",
|
||||
"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",
|
||||
"name": "release",
|
||||
"displayName": "Release (optimized, tests on, symbols kept)",
|
||||
"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).",
|
||||
"inherits": "vcpkg-common",
|
||||
"binaryDir": "${sourceDir}/build/release",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "Debug",
|
||||
"VOICECAT_USE_VCPKG_DEPS": "ON",
|
||||
"CMAKE_BUILD_TYPE": "Release",
|
||||
"VOICECAT_BUILD_TOOLS": "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"
|
||||
"VOICECAT_BUILD_TESTS": "ON"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "server-release",
|
||||
"inherits": "vcpkg-base",
|
||||
"displayName": "Server (release, real deps)",
|
||||
"displayName": "Server Release (optimized, stripped, no tests)",
|
||||
"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",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "Release",
|
||||
"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": [
|
||||
{ "name": "dev", "configurePreset": "dev" },
|
||||
{ "name": "m1-dev", "configurePreset": "m1-dev" },
|
||||
{ "name": "m2-dev", "configurePreset": "m2-dev" },
|
||||
{ "name": "server-release", "configurePreset": "server-release" }
|
||||
{ "name": "release", "configurePreset": "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": [
|
||||
{ "name": "dev", "configurePreset": "dev", "output": { "outputOnFailure": true } },
|
||||
{ "name": "m1-dev", "configurePreset": "m1-dev", "output": { "outputOnFailure": true } },
|
||||
{ "name": "m2-dev", "configurePreset": "m2-dev", "output": { "outputOnFailure": true } }
|
||||
{ "name": "dev", "configurePreset": "dev", "output": { "outputOnFailure": true } },
|
||||
{ "name": "release", "configurePreset": "release", "output": { "outputOnFailure": true } }
|
||||
]
|
||||
}
|
||||
|
||||
98
Dockerfile
Normal file
98
Dockerfile
Normal file
@@ -0,0 +1,98 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Stage 1 — Build
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
FROM ubuntu:24.04 AS builder
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
cmake \
|
||||
ninja-build \
|
||||
git \
|
||||
curl \
|
||||
zip \
|
||||
unzip \
|
||||
tar \
|
||||
pkg-config \
|
||||
ca-certificates \
|
||||
autoconf \
|
||||
autoconf-archive \
|
||||
automake \
|
||||
libtool \
|
||||
nasm \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Fetch vcpkg at the exact commit pinned in vcpkg.json builtin-baseline.
|
||||
# vcpkg resolves baselines via `git show <sha>:versions/baseline.json`, so it
|
||||
# needs a real .git repo — not a tarball. A single shallow fetch is fast (~30 MB)
|
||||
# and gives vcpkg exactly what it needs.
|
||||
ARG VCPKG_COMMIT=d46283cf33cf5de7bd88e12156ce03882be1f179
|
||||
RUN git init /vcpkg \
|
||||
&& git -C /vcpkg remote add origin https://github.com/microsoft/vcpkg.git \
|
||||
&& git -C /vcpkg fetch --depth=1 origin "${VCPKG_COMMIT}" \
|
||||
&& git -C /vcpkg checkout FETCH_HEAD \
|
||||
&& /vcpkg/bootstrap-vcpkg.sh -disableMetrics
|
||||
ENV VCPKG_ROOT=/vcpkg
|
||||
ENV VCPKG_DISABLE_METRICS=1
|
||||
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
|
||||
ARG TARGETARCH
|
||||
# Three cache mounts:
|
||||
# downloads — source tarballs (~200 MB); safe to share across arches
|
||||
# vcpkg-cache — vcpkg binary cache (pre-built .zip archives per package ABI);
|
||||
# restores packages in seconds on subsequent builds instead of
|
||||
# recompiling. Scoped by arch so amd64/arm64 don't collide.
|
||||
# buildtrees — NOT cached; deleted at end of layer so neither the Docker
|
||||
# image nor the BuildKit cache accumulates several GB of
|
||||
# intermediate build artifacts.
|
||||
ENV VCPKG_BINARY_SOURCES="clear;files,/vcpkg-cache,readwrite"
|
||||
RUN --mount=type=cache,target=/vcpkg/downloads \
|
||||
--mount=type=cache,target=/vcpkg-cache,id=vc-bin-${TARGETARCH} \
|
||||
cmake --preset server-release \
|
||||
&& cmake --build --preset server-release \
|
||||
&& rm -rf /vcpkg/buildtrees
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Stage 2 — Export (binary-only, used by scripts/build-linux-binaries.sh)
|
||||
# docker buildx build --target export --output type=local,dest=./dist/linux-amd64 .
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
FROM scratch AS export
|
||||
COPY --from=builder /src/build/server-release/bin/voicecat-server /voicecat-server
|
||||
COPY --from=builder /src/build/server-release/bin/voicecat-admin /voicecat-admin
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Stage 3 — Runtime (default stage — must be last)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
FROM ubuntu:24.04 AS runtime
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# ca-certificates is useful if the server ever makes outbound TLS calls; also
|
||||
# satisfies any mbedTLS system-CA lookup at runtime.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN groupadd -r voicecat && useradd -r -g voicecat -s /sbin/nologin voicecat
|
||||
|
||||
COPY --from=builder /src/build/server-release/bin/voicecat-server /usr/local/bin/voicecat-server
|
||||
COPY --from=builder /src/build/server-release/bin/voicecat-admin /usr/local/bin/voicecat-admin
|
||||
|
||||
RUN mkdir -p /data && chown voicecat:voicecat /data
|
||||
|
||||
USER voicecat
|
||||
|
||||
# Persistent state: Ed25519 identity key, self-signed TLS cert, SQLite database.
|
||||
VOLUME ["/data"]
|
||||
|
||||
# Control (TLS 1.3) and media (ChaCha20-Poly1305) share one port number on TCP+UDP.
|
||||
EXPOSE 8384/tcp
|
||||
EXPOSE 8384/udp
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/voicecat-server"]
|
||||
CMD ["--data-dir", "/data"]
|
||||
1424
PROGRESS.md
1424
PROGRESS.md
File diff suppressed because it is too large
Load Diff
38
README.md
38
README.md
@@ -5,10 +5,11 @@ channel-based voice, channel + private text, one server you run yourself. Plain
|
||||
(control) and **UDP** (media), no WebRTC. Encrypted by default. A shared **C++ core**
|
||||
(`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
|
||||
> M0 **skeleton** — it compiles and links, but every subsystem is a stub. See
|
||||
> [`AGENTS.md`](AGENTS.md) to start building, and [`docs/roadmap.md`](docs/roadmap.md) for the
|
||||
> milestones.
|
||||
> **Status:** Design complete in [`docs/`](docs/). M1–M5 are implemented — real TLS control
|
||||
> plane, encrypted UDP voice (Opus), multi-stream, TOFU identity pinning, channel tree,
|
||||
> permissions, moderation, disconnect/keepalive/reaper. Windows WinForms C# client shipped
|
||||
> (M4). macOS/iOS Swift client is next. See [`PROGRESS.md`](PROGRESS.md) and
|
||||
> [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
## 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` →
|
||||
`roadmap`.
|
||||
|
||||
## Build the skeleton (no dependencies needed yet)
|
||||
## Build
|
||||
|
||||
The M0 skeleton builds with just a C++20 compiler + CMake + Ninja — **no vcpkg, no
|
||||
third-party libraries**, because every subsystem is currently a stub.
|
||||
The default development preset is **`dev`** — it builds everything (server + tools + tests)
|
||||
with real vcpkg deps. It works on Windows, Linux, and macOS (vcpkg triplet auto-resolved).
|
||||
|
||||
```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 --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
|
||||
# one-time: git clone https://github.com/microsoft/vcpkg && ./vcpkg/bootstrap-vcpkg.sh
|
||||
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
|
||||
cmake --preset skeleton && cmake --build --preset skeleton && ctest --preset skeleton
|
||||
```
|
||||
|
||||
See [`docs/building.md`](docs/building.md) for the full preset matrix (including `release`,
|
||||
`server-release`, `windows-client`, and Apple platform scaffolding).
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
|
||||
BIN
assets/sounds/channel_join.wav
Normal file
BIN
assets/sounds/channel_join.wav
Normal file
Binary file not shown.
BIN
assets/sounds/channel_leave.wav
Normal file
BIN
assets/sounds/channel_leave.wav
Normal file
Binary file not shown.
BIN
assets/sounds/channel_recv.wav
Normal file
BIN
assets/sounds/channel_recv.wav
Normal file
Binary file not shown.
BIN
assets/sounds/channel_sent.wav
Normal file
BIN
assets/sounds/channel_sent.wav
Normal file
Binary file not shown.
BIN
assets/sounds/connection_lost.wav
Normal file
BIN
assets/sounds/connection_lost.wav
Normal file
Binary file not shown.
BIN
assets/sounds/login.wav
Normal file
BIN
assets/sounds/login.wav
Normal file
Binary file not shown.
BIN
assets/sounds/logout.wav
Normal file
BIN
assets/sounds/logout.wav
Normal file
Binary file not shown.
BIN
assets/sounds/pm_recv.wav
Normal file
BIN
assets/sounds/pm_recv.wav
Normal file
Binary file not shown.
BIN
assets/sounds/pm_sent.wav
Normal file
BIN
assets/sounds/pm_sent.wav
Normal file
Binary file not shown.
BIN
assets/sounds/ptt.wav
Normal file
BIN
assets/sounds/ptt.wav
Normal file
Binary file not shown.
BIN
assets/sounds/va_start.wav
Normal file
BIN
assets/sounds/va_start.wav
Normal file
Binary file not shown.
BIN
assets/sounds/va_stop.wav
Normal file
BIN
assets/sounds/va_stop.wav
Normal file
Binary file not shown.
BIN
assets/sounds/voice_off.wav
Normal file
BIN
assets/sounds/voice_off.wav
Normal file
Binary file not shown.
BIN
assets/sounds/voice_on.wav
Normal file
BIN
assets/sounds/voice_on.wav
Normal file
Binary file not shown.
68
clients/apple/Package.swift
Normal file
68
clients/apple/Package.swift
Normal file
@@ -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]
|
||||
)
|
||||
@@ -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
|
||||
`libvoicecat` through the C ABI ([`core/include/voicecat.h`](../../core/include/voicecat.h)).
|
||||
Built in **M4** (see [`docs/roadmap.md`](../../docs/roadmap.md)). One shared **Swift core**
|
||||
(`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
|
||||
[`docs/tech-stack.md`](../../docs/tech-stack.md) §2):
|
||||
## What's here now
|
||||
|
||||
- A Swift Package wrapping the core as an **XCFramework** (macOS + iOS device + simulator).
|
||||
- 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).
|
||||
### `VoiceCatCore` Swift Package — ✓ complete (2026-06-18)
|
||||
|
||||
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.
|
||||
|
||||
37
clients/apple/Sources/VoiceCatCore/Callbacks.swift
Normal file
37
clients/apple/Sources/VoiceCatCore/Callbacks.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
30
clients/apple/Sources/VoiceCatCore/Config.swift
Normal file
30
clients/apple/Sources/VoiceCatCore/Config.swift
Normal file
@@ -0,0 +1,30 @@
|
||||
// VoiceCatConfig — Swift-idiomatic mirror of `vc_config` (voicecat.h). Passed to
|
||||
// VoiceCatClient.init. The native CString storage for the string fields is held for the
|
||||
// client's entire lifetime inside VoiceCatClient — see VoiceCatClient.swift's doc comment
|
||||
// on why (the core stores raw pointers from vc_config by value, it does not copy the data).
|
||||
|
||||
import VoiceCatC
|
||||
|
||||
/// Configuration for a `VoiceCatClient`. Mirrors `vc_config`.
|
||||
public struct VoiceCatConfig: Sendable {
|
||||
/// E.g. "VoiceCat-macOS". Forwarded in `ClientHello.client_name`.
|
||||
public let clientName: String
|
||||
/// E.g. "0.0.1". Forwarded in `ClientHello.client_version`.
|
||||
public let clientVersion: String
|
||||
public let logLevel: VoiceCatLogLevel
|
||||
/// Path to the TOFU pin file (see `confirmServerIdentity` / docs/security.md §1.1).
|
||||
/// nil = built-in relative default (only suitable for tests).
|
||||
public let tofuStorePath: String?
|
||||
|
||||
public init(
|
||||
clientName: String,
|
||||
clientVersion: String,
|
||||
logLevel: VoiceCatLogLevel = .info,
|
||||
tofuStorePath: String? = nil
|
||||
) {
|
||||
self.clientName = clientName
|
||||
self.clientVersion = clientVersion
|
||||
self.logLevel = logLevel
|
||||
self.tofuStorePath = tofuStorePath
|
||||
}
|
||||
}
|
||||
157
clients/apple/Sources/VoiceCatCore/Enums.swift
Normal file
157
clients/apple/Sources/VoiceCatCore/Enums.swift
Normal file
@@ -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) }
|
||||
}
|
||||
61
clients/apple/Sources/VoiceCatCore/Event.swift
Normal file
61
clients/apple/Sources/VoiceCatCore/Event.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
// VoiceCatEvent — a Swift value type that is safe to hold/queue past the native callback's
|
||||
// return. This is the Swift analog of the C# client's `VoiceCatEvent` record.
|
||||
//
|
||||
// CRITICAL (voicecat.h's vc_event doc comment): the native `vc_event.text` pointer is owned
|
||||
// by the core and valid ONLY for the duration of the `on_event` callback. `from(_:)` copies
|
||||
// it to a Swift `String` immediately — never hold the raw `vc_event` across the callback
|
||||
// boundary, or `text` will be a dangling pointer by the time it's read. This is the #1
|
||||
// lifetime rule carried over from the Windows client (NativeCallbacks.cs / VoiceCatEvent.cs).
|
||||
|
||||
import VoiceCatC
|
||||
|
||||
/// A Swift-safe copy of a `vc_event`. Produced inside the `on_event` callback (see
|
||||
/// Callbacks.swift) — all pointer fields are converted to value types before the callback
|
||||
/// returns.
|
||||
public struct VoiceCatEvent: Sendable, Equatable {
|
||||
public let type: VoiceCatEventType
|
||||
public let connectionState: VoiceCatConnectionState
|
||||
public let result: VoiceCatResult
|
||||
public let userId: UInt32
|
||||
public let channelId: UInt32
|
||||
public let streamId: UInt32
|
||||
public let textScope: VoiceCatTextScope
|
||||
/// Generic small payload, meaning per event type. For `.serverIdentity` this is the
|
||||
/// `VoiceCatTofuStatus`; for `.genericResult` the server error code; for `.talkState`
|
||||
/// talking(0/1).
|
||||
public let u32a: UInt32
|
||||
/// Copied from the core's `vc_event.text` inside the callback. nil if the core passed NULL.
|
||||
public let text: String?
|
||||
public let timestampUnixMs: UInt64
|
||||
|
||||
/// Convenience: the TOFU status, valid when `type == .serverIdentity` (maps `u32a`).
|
||||
public var tofuStatus: VoiceCatTofuStatus? {
|
||||
type == .serverIdentity ? VoiceCatTofuStatus(rawValue: u32a) : nil
|
||||
}
|
||||
|
||||
/// Copy a native `vc_event` into a safe Swift value. MUST be called inside the callback
|
||||
/// while `ev.text` is still valid — `String(cString:)` copies the bytes here.
|
||||
@inline(__always)
|
||||
public static func from(_ ev: vc_event) -> VoiceCatEvent {
|
||||
let text: String?
|
||||
if let raw = ev.text {
|
||||
text = String(cString: raw) // copies — safe to hold past callback return
|
||||
} else {
|
||||
text = nil
|
||||
}
|
||||
// ev.result is int32_t (not vc_result) per voicecat.h — bridge via bitPattern.
|
||||
// ev.u32a is uint32_t — matches VoiceCatTofuStatus's UInt32 raw value directly.
|
||||
return VoiceCatEvent(
|
||||
type: VoiceCatEventType(ev.type),
|
||||
connectionState: VoiceCatConnectionState(ev.connection_state),
|
||||
result: VoiceCatResult(rawValue: UInt32(bitPattern: ev.result)) ?? .internalError,
|
||||
userId: ev.user_id,
|
||||
channelId: ev.channel_id,
|
||||
streamId: ev.stream_id,
|
||||
textScope: VoiceCatTextScope(ev.text_scope),
|
||||
u32a: ev.u32a,
|
||||
text: text,
|
||||
timestampUnixMs: ev.timestamp_unix_ms
|
||||
)
|
||||
}
|
||||
}
|
||||
105
clients/apple/Sources/VoiceCatCore/Feedback/EventFeedback.swift
Normal file
105
clients/apple/Sources/VoiceCatCore/Feedback/EventFeedback.swift
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
43
clients/apple/Sources/VoiceCatCore/Feedback/SoundEvent.swift
Normal file
43
clients/apple/Sources/VoiceCatCore/Feedback/SoundEvent.swift
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
109
clients/apple/Sources/VoiceCatCore/Marshaling.swift
Normal file
109
clients/apple/Sources/VoiceCatCore/Marshaling.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
250
clients/apple/Sources/VoiceCatCore/Models.swift
Normal file
250
clients/apple/Sources/VoiceCatCore/Models.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
BIN
clients/apple/Sources/VoiceCatCore/Sounds/channel_join.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/channel_join.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/channel_leave.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/channel_leave.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/channel_recv.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/channel_recv.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/channel_sent.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/channel_sent.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/connection_lost.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/connection_lost.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/login.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/login.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/logout.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/logout.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/pm_recv.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/pm_recv.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/pm_sent.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/pm_sent.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/ptt.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/ptt.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/va_start.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/va_start.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/va_stop.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/va_stop.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/voice_off.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/voice_off.wav
Normal file
Binary file not shown.
BIN
clients/apple/Sources/VoiceCatCore/Sounds/voice_on.wav
Normal file
BIN
clients/apple/Sources/VoiceCatCore/Sounds/voice_on.wav
Normal file
Binary file not shown.
594
clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift
Normal file
594
clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
68
clients/apple/Tests/VoiceCatCoreTests/ExternalPcmTests.swift
Normal file
68
clients/apple/Tests/VoiceCatCoreTests/ExternalPcmTests.swift
Normal file
@@ -0,0 +1,68 @@
|
||||
// ExternalPcmTests — Swift wrapper smoke tests for vc_stream_feed_pcm / vc_set_pcm_sink.
|
||||
//
|
||||
// These tests verify that the Swift API surface compiles, is callable, and returns expected
|
||||
// results at the C-ABI boundary — without requiring a live server or audio hardware.
|
||||
// Full end-to-end relay / decode verification is covered by tests/test_external_pcm.cpp
|
||||
// (C++ ctest), which runs headlessly on all platforms.
|
||||
|
||||
import XCTest
|
||||
@testable import VoiceCatCore
|
||||
|
||||
final class ExternalPcmTests: XCTestCase {
|
||||
|
||||
// MARK: - feedPcm: API surface smoke
|
||||
|
||||
/// Calling feedPcm without a connected client or active stream must return .invalidArg
|
||||
/// (not crash). Proves the Swift→C bridge compiles and handles the error path.
|
||||
func testFeedPcm_noActiveStream_returnsInvalidArg() {
|
||||
let client = VoiceCatClient(config: VoiceCatConfig(
|
||||
clientName: "ext-pcm-test",
|
||||
clientVersion: "0.1",
|
||||
logLevel: .off
|
||||
))
|
||||
let sine = [Int16](repeating: 0, count: 960)
|
||||
// Stream 0 doesn't exist — the core must return invalidArg, not crash.
|
||||
let result = client.feedPcm(streamId: 0, pcm: sine, samplesPerChannel: 960, channels: 1)
|
||||
XCTAssertEqual(result, .invalidArg)
|
||||
}
|
||||
|
||||
/// Calling feedPcm with channels=3 (invalid) must return .invalidArg.
|
||||
func testFeedPcm_invalidChannels_returnsInvalidArg() {
|
||||
let client = VoiceCatClient(config: VoiceCatConfig(
|
||||
clientName: "ext-pcm-test",
|
||||
clientVersion: "0.1",
|
||||
logLevel: .off
|
||||
))
|
||||
let pcm = [Int16](repeating: 0, count: 960 * 3)
|
||||
let result = client.feedPcm(streamId: 0, pcm: pcm, samplesPerChannel: 960, channels: 3)
|
||||
XCTAssertEqual(result, .invalidArg)
|
||||
}
|
||||
|
||||
// MARK: - setPcmSink: API surface smoke
|
||||
|
||||
/// setPcmSink(nil) on a freshly-created client must succeed (nil = disable, which is the
|
||||
/// default state — a no-op that must still return .ok).
|
||||
func testSetPcmSink_nil_returnsOk() {
|
||||
let client = VoiceCatClient(config: VoiceCatConfig(
|
||||
clientName: "ext-pcm-test",
|
||||
clientVersion: "0.1",
|
||||
logLevel: .off
|
||||
))
|
||||
let result = client.setPcmSink(nil, user: nil)
|
||||
XCTAssertEqual(result, .ok)
|
||||
}
|
||||
|
||||
/// Calling setPcmSink with a @convention(c) function and then immediately disabling it
|
||||
/// with nil must both succeed. Verifies the C-ABI function-pointer round-trip.
|
||||
func testSetPcmSink_enableThenDisable_bothSucceed() {
|
||||
let client = VoiceCatClient(config: VoiceCatConfig(
|
||||
clientName: "ext-pcm-test",
|
||||
clientVersion: "0.1",
|
||||
logLevel: .off
|
||||
))
|
||||
|
||||
let mySink: VoiceCatPcmSinkCallback = { _, _, _, _, _, _, _ in }
|
||||
XCTAssertEqual(client.setPcmSink(mySink, user: nil), .ok)
|
||||
XCTAssertEqual(client.setPcmSink(nil, user: nil), .ok)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
// VoiceCatClientSmokeTests — exercises the full connect → TOFU → auth → channels →
|
||||
// moderation flow purely through the Swift wrapper layer (VoiceCatClient), against a real
|
||||
// `voicecat-server` (the same binary the C++ ctest suite uses, built by `cmake --preset dev`).
|
||||
// This is the Swift analog of clients/windows/VoiceCat.Interop.Tests/VoiceCatClientSmokeTests.cs.
|
||||
//
|
||||
// Why this exists (same rationale as the C# tests): the C++ ctest suite proves the protocol
|
||||
// works at the C++ level, but Swift-specific interop bugs — @convention(c) callback lifetime,
|
||||
// Unmanaged pointer resolution, CString memory management, enum raw-value bridging, struct
|
||||
// field layout — can only be caught by exercising the exact Swift→C boundary. These tests
|
||||
// catch the same class of bugs the C# P/Invoke tests catch, for Swift.
|
||||
//
|
||||
// Prerequisites: `cmake --preset dev && cmake --build --preset dev` (builds voicecat-server
|
||||
// and voicecat-admin into build/dev/bin/), AND `scripts/build-xcframework.sh` (builds the
|
||||
// VoiceCatCore.xcframework that the Swift Package links).
|
||||
|
||||
import XCTest
|
||||
import Foundation
|
||||
@testable import VoiceCatCore
|
||||
|
||||
/// Manages a real `voicecat-server` process for the test suite's lifetime. Starts the server
|
||||
/// on an ephemeral port (--port 0), parses the bound port from stdout, and provisions a known
|
||||
/// admin account via `voicecat-admin`. Killed + cleaned up in deinit.
|
||||
private final class ServerHarness {
|
||||
let port: UInt16
|
||||
private let process: Process
|
||||
let tempDir: String
|
||||
|
||||
init() throws {
|
||||
let repoRoot = Self.findRepoRoot()
|
||||
let serverURL = URL(fileURLWithPath: repoRoot)
|
||||
.appendingPathComponent("build/dev/bin/voicecat-server")
|
||||
|
||||
guard FileManager.default.isExecutableFile(atPath: serverURL.path) else {
|
||||
throw NSError(domain: "VoiceCatTest", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "voicecat-server not found at \(serverURL.path) — "
|
||||
+ "build the dev preset first: cmake --preset dev && cmake --build --preset dev",
|
||||
])
|
||||
}
|
||||
|
||||
let tempDir = NSTemporaryDirectory() + "vc_swift_smoke_" + UUID().uuidString
|
||||
try FileManager.default.createDirectory(atPath: tempDir, withIntermediateDirectories: true)
|
||||
self.tempDir = tempDir
|
||||
|
||||
let p = Process()
|
||||
p.executableURL = serverURL
|
||||
p.arguments = ["--port", "0", "--data-dir", tempDir, "--name", "SwiftSmokeTest"]
|
||||
|
||||
// Pipe stdout to read the bound port; stderr to /dev/null.
|
||||
let stdoutPipe = Pipe()
|
||||
p.standardOutput = stdoutPipe
|
||||
p.standardError = FileHandle(forWritingAtPath: "/dev/null")
|
||||
try p.run()
|
||||
self.process = p
|
||||
|
||||
// Parse "[voicecat-server] ... — TCP :<port> UDP :<port>" from stdout. The server
|
||||
// prints several lines before the port line (version, first-run admin box, etc.), so
|
||||
// we keep reading until we find a line matching "TCP :<port>". Read with a 10s timeout
|
||||
// so a crashed/hung server can't hang the test forever.
|
||||
guard let port = Self.readPortWithTimeout(stdoutPipe, timeout: 10) else {
|
||||
p.terminate()
|
||||
throw NSError(domain: "VoiceCatTest", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "voicecat-server did not report a bound TCP port within 10s",
|
||||
])
|
||||
}
|
||||
self.port = port
|
||||
|
||||
// Provision a known admin account for moderation/admin tests.
|
||||
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()
|
||||
}
|
||||
}
|
||||
178
clients/apple/iOS/Shared/BroadcastAudioRing.swift
Normal file
178
clients/apple/iOS/Shared/BroadcastAudioRing.swift
Normal file
@@ -0,0 +1,178 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
|
||||
// Darwin notification names the extension posts and the host observes, so the host can react to
|
||||
// broadcast start/stop promptly instead of only polling the ring's active flag. Shared (compiled
|
||||
// into both targets) so the names can't drift.
|
||||
enum BroadcastNotification {
|
||||
static let started = "cat.voice.VoiceCat.broadcast.started"
|
||||
static let finished = "cat.voice.VoiceCat.broadcast.finished"
|
||||
}
|
||||
|
||||
// BroadcastAudioRing — cross-process single-producer/single-consumer int16 PCM ring over an
|
||||
// mmap'd file in the shared App Group container. Compiled into BOTH the host app and the
|
||||
// ReplayKit broadcast upload extension (docs/voice.md §9, iOS detail).
|
||||
//
|
||||
// producer = the broadcast extension's RPBroadcastSampleHandler (captured system audio)
|
||||
// consumer = the host app's BroadcastAudioPump (drains and feeds the core via feedPcm)
|
||||
//
|
||||
// Why a hand-rolled ring instead of Swift's `Atomic`: the storage lives in shared memory mapped
|
||||
// into two processes, so the synchronization words must sit in that mapping — Swift's managed
|
||||
// atomics can't. For strict SPSC, aligned 64-bit monotonic indices with full memory barriers
|
||||
// (`OSMemoryBarrier`) give correct acquire/release ordering. The extension does NOT link
|
||||
// libvoicecat — it only writes PCM here; all encode/crypto/UDP happens in the host.
|
||||
//
|
||||
// Lifecycle: the host opens the ring at connect (and thus initializes the header first); the
|
||||
// extension opens it later when a broadcast starts. On a rising edge of `isActive` the host
|
||||
// calls `drainStale()` to discard any pre-roll, then drains in whole 20 ms frames.
|
||||
final class BroadcastAudioRing {
|
||||
|
||||
static let appGroupId = "group.cat.voice.VoiceCat"
|
||||
|
||||
enum RingError: Error { case noContainer, openFailed, mapFailed }
|
||||
|
||||
// Header layout (byte offsets into the mmap). Indices are 8-byte aligned; mmap is
|
||||
// page-aligned so offsets 24/32 satisfy that.
|
||||
private static let magic: UInt32 = 0x5643_4252 // "VCBR"
|
||||
private static let version: UInt32 = 1
|
||||
private static let headerBytes = 64
|
||||
/// 1 second of 48 kHz stereo int16 — ample slack for ~10 ms host drains.
|
||||
static let capacitySamples = 48_000 * 2
|
||||
|
||||
private static let offMagic = 0
|
||||
private static let offVersion = 4
|
||||
private static let offChannels = 8
|
||||
private static let offSampleRate = 12
|
||||
private static let offActive = 16
|
||||
private static let offWrite = 24
|
||||
private static let offRead = 32
|
||||
|
||||
private let fd: Int32
|
||||
private let mapBase: UnsafeMutableRawPointer
|
||||
private let mapSize: Int
|
||||
private let data: UnsafeMutablePointer<Int16>
|
||||
private let capacity: Int
|
||||
|
||||
init() throws {
|
||||
guard let container = FileManager.default.containerURL(
|
||||
forSecurityApplicationGroupIdentifier: Self.appGroupId) else {
|
||||
throw RingError.noContainer
|
||||
}
|
||||
let dir = container.appendingPathComponent("voicecat", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
let path = dir.appendingPathComponent("broadcast_audio.ring").path
|
||||
|
||||
capacity = Self.capacitySamples
|
||||
mapSize = Self.headerBytes + capacity * MemoryLayout<Int16>.size
|
||||
|
||||
let f = open(path, O_RDWR | O_CREAT, 0o644)
|
||||
guard f >= 0 else { throw RingError.openFailed }
|
||||
if ftruncate(f, off_t(mapSize)) != 0 { close(f); throw RingError.openFailed }
|
||||
|
||||
let p = mmap(nil, mapSize, PROT_READ | PROT_WRITE, MAP_SHARED, f, 0)
|
||||
guard let p, p != MAP_FAILED else { close(f); throw RingError.mapFailed }
|
||||
|
||||
fd = f
|
||||
mapBase = p
|
||||
data = (p + Self.headerBytes).assumingMemoryBound(to: Int16.self)
|
||||
|
||||
// First opener initializes the header (host opens first, before any broadcast).
|
||||
if load32(Self.offMagic) != Self.magic {
|
||||
store32(Self.offWrite, 0); store32(Self.offWrite + 4, 0)
|
||||
store32(Self.offRead, 0); store32(Self.offRead + 4, 0)
|
||||
store32(Self.offChannels, 0)
|
||||
store32(Self.offSampleRate, 0)
|
||||
store32(Self.offActive, 0)
|
||||
store32(Self.offVersion, Self.version)
|
||||
OSMemoryBarrier()
|
||||
store32(Self.offMagic, Self.magic)
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
munmap(mapBase, mapSize)
|
||||
close(fd)
|
||||
}
|
||||
|
||||
// MARK: - Header accessors
|
||||
|
||||
var isActive: Bool { OSMemoryBarrier(); return load32(Self.offActive) != 0 }
|
||||
var channels: UInt32 { load32(Self.offChannels) }
|
||||
var sampleRate: UInt32 { load32(Self.offSampleRate) }
|
||||
|
||||
/// Producer: advertise the canonical capture format and toggle the active flag. Called from
|
||||
/// `broadcastStarted`/`broadcastFinished`.
|
||||
func setActive(_ active: Bool, channels: UInt32 = 0, sampleRate: UInt32 = 0) {
|
||||
if active {
|
||||
store32(Self.offChannels, channels)
|
||||
store32(Self.offSampleRate, sampleRate)
|
||||
}
|
||||
OSMemoryBarrier()
|
||||
store32(Self.offActive, active ? 1 : 0)
|
||||
}
|
||||
|
||||
// MARK: - Producer (extension)
|
||||
|
||||
/// Append interleaved int16 samples. Drops the whole chunk if it doesn't fit — skipping a
|
||||
/// chunk is better than tearing a frame. Single producer only.
|
||||
func push(_ samples: UnsafeBufferPointer<Int16>) {
|
||||
let n = samples.count
|
||||
guard n > 0, n <= capacity, let src = samples.baseAddress else { return }
|
||||
let w = load64(Self.offWrite) // producer owns the write index
|
||||
let r = loadAcquire64(Self.offRead)
|
||||
if capacity - Int(w &- r) < n { return } // full: drop
|
||||
var idx = Int(w % UInt64(capacity))
|
||||
var off = 0
|
||||
var rem = n
|
||||
while rem > 0 {
|
||||
let chunk = min(rem, capacity - idx)
|
||||
(data + idx).update(from: src + off, count: chunk)
|
||||
idx = (idx + chunk) % capacity
|
||||
off += chunk
|
||||
rem -= chunk
|
||||
}
|
||||
storeRelease64(Self.offWrite, w &+ UInt64(n))
|
||||
}
|
||||
|
||||
// MARK: - Consumer (host)
|
||||
|
||||
/// Read up to `out.count` interleaved int16 samples. Returns the number read. Single
|
||||
/// consumer only.
|
||||
func read(into out: UnsafeMutableBufferPointer<Int16>) -> Int {
|
||||
guard let dst = out.baseAddress else { return 0 }
|
||||
let r = load64(Self.offRead) // consumer owns the read index
|
||||
let w = loadAcquire64(Self.offWrite)
|
||||
let available = Int(w &- r)
|
||||
if available <= 0 { return 0 }
|
||||
let n = min(available, out.count)
|
||||
var idx = Int(r % UInt64(capacity))
|
||||
var off = 0
|
||||
var rem = n
|
||||
while rem > 0 {
|
||||
let chunk = min(rem, capacity - idx)
|
||||
(dst + off).update(from: data + idx, count: chunk)
|
||||
idx = (idx + chunk) % capacity
|
||||
off += chunk
|
||||
rem -= chunk
|
||||
}
|
||||
storeRelease64(Self.offRead, r &+ UInt64(n))
|
||||
return n
|
||||
}
|
||||
|
||||
/// Consumer: discard everything currently buffered (catch the read index up to write).
|
||||
func drainStale() { storeRelease64(Self.offRead, loadAcquire64(Self.offWrite)) }
|
||||
|
||||
// MARK: - Memory-ordered accessors
|
||||
|
||||
private func ptr32(_ off: Int) -> UnsafeMutablePointer<UInt32> {
|
||||
(mapBase + off).assumingMemoryBound(to: UInt32.self)
|
||||
}
|
||||
private func ptr64(_ off: Int) -> UnsafeMutablePointer<UInt64> {
|
||||
(mapBase + off).assumingMemoryBound(to: UInt64.self)
|
||||
}
|
||||
private func load32(_ off: Int) -> UInt32 { ptr32(off).pointee }
|
||||
private func store32(_ off: Int, _ v: UInt32) { ptr32(off).pointee = v }
|
||||
private func load64(_ off: Int) -> UInt64 { ptr64(off).pointee }
|
||||
private func loadAcquire64(_ off: Int) -> UInt64 { let v = ptr64(off).pointee; OSMemoryBarrier(); return v }
|
||||
private func storeRelease64(_ off: Int, _ v: UInt64) { OSMemoryBarrier(); ptr64(off).pointee = v }
|
||||
}
|
||||
31
clients/apple/iOS/VoiceCatBroadcast/Info.plist
Normal file
31
clients/apple/iOS/VoiceCatBroadcast/Info.plist
Normal file
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>VoiceCat Screen Audio</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.0.1</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.broadcast-services-upload</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).SampleHandler</string>
|
||||
<key>RPBroadcastProcessMode</key>
|
||||
<string>RPBroadcastProcessModeSampleBuffer</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
88
clients/apple/iOS/VoiceCatBroadcast/SampleHandler.swift
Normal file
88
clients/apple/iOS/VoiceCatBroadcast/SampleHandler.swift
Normal file
@@ -0,0 +1,88 @@
|
||||
import ReplayKit
|
||||
import AVFoundation
|
||||
|
||||
// SampleHandler — the ReplayKit broadcast upload extension entry point (docs/voice.md §9, iOS).
|
||||
//
|
||||
// This runs in a SEPARATE process with a ~50 MB memory cap. It captures system/app audio
|
||||
// (`.audioApp`), drops video and mic buffers, converts each chunk to the core's canonical
|
||||
// format (48 kHz, int16, stereo interleaved) with AVAudioConverter, and writes it into the
|
||||
// App Group shared-memory ring. The host app's BroadcastAudioPump drains the ring and feeds the
|
||||
// already-connected VoiceCatClient — so all Opus/AEAD/UDP work happens in the host, and the
|
||||
// extension stays tiny and well inside the memory budget (no libvoicecat here).
|
||||
class SampleHandler: RPBroadcastSampleHandler {
|
||||
|
||||
private var ring: BroadcastAudioRing?
|
||||
private var converter: AVAudioConverter?
|
||||
private var inputFormat: AVAudioFormat?
|
||||
private let outputFormat = AVAudioFormat(commonFormat: .pcmFormatInt16,
|
||||
sampleRate: 48_000, channels: 2, interleaved: true)!
|
||||
|
||||
override func broadcastStarted(withSetupInfo setupInfo: [String: NSObject]?) {
|
||||
ring = try? BroadcastAudioRing()
|
||||
ring?.setActive(true, channels: 2, sampleRate: 48_000)
|
||||
postDarwin(BroadcastNotification.started)
|
||||
}
|
||||
|
||||
override func broadcastFinished() {
|
||||
ring?.setActive(false)
|
||||
postDarwin(BroadcastNotification.finished)
|
||||
ring = nil
|
||||
}
|
||||
|
||||
override func processSampleBuffer(_ sampleBuffer: CMSampleBuffer,
|
||||
with sampleBufferType: RPSampleBufferType) {
|
||||
// App/system audio only — drop video (the memory hog) and the device mic (the host
|
||||
// already captures and sends the user's voice).
|
||||
guard sampleBufferType == .audioApp, let ring else { return }
|
||||
guard let input = makeInputBuffer(sampleBuffer),
|
||||
let conv = converter(for: input.format) else { return }
|
||||
|
||||
let ratio = outputFormat.sampleRate / input.format.sampleRate
|
||||
let capacity = AVAudioFrameCount(Double(input.frameLength) * ratio) + 1024
|
||||
guard let output = AVAudioPCMBuffer(pcmFormat: outputFormat, frameCapacity: capacity) else { return }
|
||||
|
||||
var supplied = false
|
||||
var error: NSError?
|
||||
let status = conv.convert(to: output, error: &error) { _, outStatus in
|
||||
if supplied { outStatus.pointee = .noDataNow; return nil }
|
||||
supplied = true
|
||||
outStatus.pointee = .haveData
|
||||
return input
|
||||
}
|
||||
guard status != .error, output.frameLength > 0,
|
||||
let mData = output.audioBufferList.pointee.mBuffers.mData else { return }
|
||||
|
||||
// Interleaved int16 → one buffer of frameLength * channels samples.
|
||||
let count = Int(output.frameLength) * Int(outputFormat.channelCount)
|
||||
ring.push(UnsafeBufferPointer(start: mData.assumingMemoryBound(to: Int16.self), count: count))
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Wrap the ReplayKit CMSampleBuffer's PCM in an AVAudioPCMBuffer matching its native format.
|
||||
private func makeInputBuffer(_ sb: CMSampleBuffer) -> AVAudioPCMBuffer? {
|
||||
guard let fmtDesc = CMSampleBufferGetFormatDescription(sb),
|
||||
var asbd = CMAudioFormatDescriptionGetStreamBasicDescription(fmtDesc)?.pointee,
|
||||
let fmt = AVAudioFormat(streamDescription: &asbd) else { return nil }
|
||||
let frames = AVAudioFrameCount(CMSampleBufferGetNumSamples(sb))
|
||||
guard frames > 0, let buf = AVAudioPCMBuffer(pcmFormat: fmt, frameCapacity: frames) else { return nil }
|
||||
buf.frameLength = frames
|
||||
let status = CMSampleBufferCopyPCMDataIntoAudioBufferList(
|
||||
sb, at: 0, frameCount: Int32(frames), into: buf.mutableAudioBufferList)
|
||||
return status == noErr ? buf : nil
|
||||
}
|
||||
|
||||
/// Reuse the converter while the input format is stable; rebuild if ReplayKit changes it.
|
||||
private func converter(for inFmt: AVAudioFormat) -> AVAudioConverter? {
|
||||
if let converter, inputFormat == inFmt { return converter }
|
||||
inputFormat = inFmt
|
||||
converter = AVAudioConverter(from: inFmt, to: outputFormat)
|
||||
return converter
|
||||
}
|
||||
|
||||
private func postDarwin(_ name: String) {
|
||||
CFNotificationCenterPostNotification(
|
||||
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||
CFNotificationName(name as CFString), nil, nil, true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.me.iamtalon.voicecat</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
616
clients/apple/iOS/VoiceCatiOS.xcodeproj/project.pbxproj
Normal file
616
clients/apple/iOS/VoiceCatiOS.xcodeproj/project.pbxproj
Normal file
@@ -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>
|
||||
402
clients/apple/iOS/VoiceCatiOS/AppState.swift
Normal file
402
clients/apple/iOS/VoiceCatiOS/AppState.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
180
clients/apple/iOS/VoiceCatiOS/AudioSessionManager.swift
Normal file
180
clients/apple/iOS/VoiceCatiOS/AudioSessionManager.swift
Normal file
@@ -0,0 +1,180 @@
|
||||
import AVFoundation
|
||||
import os
|
||||
import VoiceCatCore
|
||||
|
||||
private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "AudioSessionManager")
|
||||
|
||||
@MainActor
|
||||
final class AudioSessionManager {
|
||||
static let shared = AudioSessionManager()
|
||||
|
||||
/// 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")
|
||||
}
|
||||
159
clients/apple/iOS/VoiceCatiOS/BroadcastAudioPump.swift
Normal file
159
clients/apple/iOS/VoiceCatiOS/BroadcastAudioPump.swift
Normal file
@@ -0,0 +1,159 @@
|
||||
import Foundation
|
||||
|
||||
// BroadcastAudioPump — host side of iOS screen-audio sharing (docs/voice.md §9, iOS detail).
|
||||
//
|
||||
// Drains the App Group shared-memory ring written by the broadcast upload extension and hands
|
||||
// whole 20 ms frames to a feed closure (which calls VoiceCatClient.feedPcm). The host app owns
|
||||
// the SCREEN_AUDIO stream, so screen audio appears as a second stream of the SAME user — exactly
|
||||
// like the Windows/macOS desktop-audio share, and a single session (no separate connection).
|
||||
//
|
||||
// It reacts to the extension's Darwin notifications for prompt start/stop, and the drain timer
|
||||
// also watches the ring's active flag as a safety net if a notification is missed. The ring
|
||||
// always carries stereo; we downmix to mono when the stream's effective config is mono.
|
||||
//
|
||||
// Not @MainActor: the drain runs on a background queue (feedPcm is thread-safe). The start/stop
|
||||
// callbacks are dispatched to main so they can drive @MainActor SessionState.
|
||||
final class BroadcastAudioPump {
|
||||
|
||||
/// The host should start the SCREEN_AUDIO stream (a broadcast became active).
|
||||
var onBroadcastStarted: (() -> Void)?
|
||||
/// The host should stop the SCREEN_AUDIO stream (the broadcast ended).
|
||||
var onBroadcastFinished: (() -> Void)?
|
||||
|
||||
private static let frameSamplesPerChannel = 960 // 20 ms @ 48 kHz
|
||||
|
||||
private let queue = DispatchQueue(label: "cat.voice.broadcast.pump")
|
||||
private var ring: BroadcastAudioRing?
|
||||
private var timer: DispatchSourceTimer?
|
||||
private var feed: ((UnsafePointer<Int16>, Int, UInt32) -> Void)?
|
||||
private var streamChannels = 1
|
||||
private var ringChannels = 2
|
||||
private var pending: [Int16] = [] // interleaved at ringChannels width
|
||||
private var scratch = [Int16](repeating: 0, count: 8192)
|
||||
private var started = false
|
||||
|
||||
// MARK: - Lifecycle (host connect/disconnect)
|
||||
|
||||
func start() {
|
||||
guard !started else { return }
|
||||
ring = try? BroadcastAudioRing()
|
||||
started = true
|
||||
registerDarwin()
|
||||
// Host reconnected while a broadcast is still running — pick it up.
|
||||
if ring?.isActive == true { onBroadcastStarted?() }
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard started else { return }
|
||||
started = false
|
||||
unregisterDarwin()
|
||||
endFeeding()
|
||||
ring = nil
|
||||
}
|
||||
|
||||
// MARK: - Feeding (driven by the host once the stream is live)
|
||||
|
||||
/// Begin draining the ring into `feed`. Called after the SCREEN_AUDIO stream's
|
||||
/// `.streamStarted` event, when its effective channel count is known.
|
||||
func beginFeeding(streamChannels: UInt32,
|
||||
feed: @escaping (UnsafePointer<Int16>, Int, UInt32) -> Void) {
|
||||
queue.async {
|
||||
self.streamChannels = max(1, min(2, Int(streamChannels)))
|
||||
self.ringChannels = max(1, Int(self.ring?.channels ?? 2))
|
||||
self.feed = feed
|
||||
self.pending.removeAll(keepingCapacity: true)
|
||||
self.ring?.drainStale() // discard pre-roll buffered before we were ready
|
||||
self.startTimer()
|
||||
}
|
||||
}
|
||||
|
||||
func endFeeding() {
|
||||
queue.async {
|
||||
self.timer?.cancel()
|
||||
self.timer = nil
|
||||
self.feed = nil
|
||||
self.pending.removeAll(keepingCapacity: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Drain
|
||||
|
||||
private func startTimer() {
|
||||
let t = DispatchSource.makeTimerSource(queue: queue)
|
||||
t.schedule(deadline: .now(), repeating: .milliseconds(10), leeway: .milliseconds(2))
|
||||
t.setEventHandler { [weak self] in self?.drain() }
|
||||
timer = t
|
||||
t.resume()
|
||||
}
|
||||
|
||||
private func drain() {
|
||||
guard let ring, let feed else { return }
|
||||
// Safety net: broadcast ended but we missed the Darwin note.
|
||||
if !ring.isActive {
|
||||
DispatchQueue.main.async { [weak self] in self?.onBroadcastFinished?() }
|
||||
return
|
||||
}
|
||||
while true {
|
||||
let got = scratch.withUnsafeMutableBufferPointer { ring.read(into: $0) }
|
||||
if got == 0 { break }
|
||||
pending.append(contentsOf: scratch[0..<got])
|
||||
if got < scratch.count { break }
|
||||
}
|
||||
emitFrames(feed)
|
||||
}
|
||||
|
||||
private func emitFrames(_ feed: (UnsafePointer<Int16>, Int, UInt32) -> Void) {
|
||||
let n = Self.frameSamplesPerChannel
|
||||
let rc = ringChannels
|
||||
let sc = streamChannels
|
||||
let inFrame = n * rc
|
||||
while pending.count >= inFrame {
|
||||
if sc == rc {
|
||||
pending.withUnsafeBufferPointer { feed($0.baseAddress!, n, UInt32(sc)) }
|
||||
} else if sc == 1 && rc == 2 {
|
||||
var mono = [Int16](repeating: 0, count: n)
|
||||
pending.withUnsafeBufferPointer { buf in
|
||||
let p = buf.baseAddress!
|
||||
for i in 0..<n { mono[i] = Int16((Int(p[i * 2]) + Int(p[i * 2 + 1])) / 2) }
|
||||
}
|
||||
mono.withUnsafeBufferPointer { feed($0.baseAddress!, n, 1) }
|
||||
} else if sc == 2 && rc == 1 {
|
||||
var stereo = [Int16](repeating: 0, count: n * 2)
|
||||
pending.withUnsafeBufferPointer { buf in
|
||||
let p = buf.baseAddress!
|
||||
for i in 0..<n { stereo[i * 2] = p[i]; stereo[i * 2 + 1] = p[i] }
|
||||
}
|
||||
stereo.withUnsafeBufferPointer { feed($0.baseAddress!, n, 2) }
|
||||
}
|
||||
pending.removeFirst(inFrame)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Darwin notifications
|
||||
|
||||
private func registerDarwin() {
|
||||
let observer = Unmanaged.passUnretained(self).toOpaque()
|
||||
let center = CFNotificationCenterGetDarwinNotifyCenter()
|
||||
let callback: CFNotificationCallback = { _, observer, name, _, _ in
|
||||
guard let observer, let name else { return }
|
||||
let pump = Unmanaged<BroadcastAudioPump>.fromOpaque(observer).takeUnretainedValue()
|
||||
let raw = name.rawValue as String
|
||||
DispatchQueue.main.async {
|
||||
if raw == BroadcastNotification.started { pump.onBroadcastStarted?() }
|
||||
else if raw == BroadcastNotification.finished { pump.onBroadcastFinished?() }
|
||||
}
|
||||
}
|
||||
CFNotificationCenterAddObserver(center, observer, callback,
|
||||
BroadcastNotification.started as CFString, nil, .deliverImmediately)
|
||||
CFNotificationCenterAddObserver(center, observer, callback,
|
||||
BroadcastNotification.finished as CFString, nil, .deliverImmediately)
|
||||
}
|
||||
|
||||
private func unregisterDarwin() {
|
||||
CFNotificationCenterRemoveEveryObserver(
|
||||
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||
Unmanaged.passUnretained(self).toOpaque())
|
||||
}
|
||||
|
||||
deinit { if started { unregisterDarwin() } }
|
||||
}
|
||||
650
clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift
Normal file
650
clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
475
clients/apple/iOS/VoiceCatiOS/IOSVoiceProcessingEngine.swift
Normal file
475
clients/apple/iOS/VoiceCatiOS/IOSVoiceProcessingEngine.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
47
clients/apple/iOS/VoiceCatiOS/Info.plist
Normal file
47
clients/apple/iOS/VoiceCatiOS/Info.plist
Normal file
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>VoiceCat</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.0.1</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © 2026 VoiceCat contributors. All rights reserved.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>VoiceCat needs microphone access to transmit your voice in channels.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>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>
|
||||
29
clients/apple/iOS/VoiceCatiOS/SavedServer.swift
Normal file
29
clients/apple/iOS/VoiceCatiOS/SavedServer.swift
Normal file
@@ -0,0 +1,29 @@
|
||||
import Foundation
|
||||
|
||||
struct SavedServer: Codable, Identifiable, Equatable {
|
||||
let id: UUID
|
||||
var host: String
|
||||
var port: UInt16
|
||||
var authMode: AuthMode
|
||||
var savedUsername: String
|
||||
/// Free-form display name used when connecting as a guest. Distinct from the account
|
||||
/// `savedUsername`. Empty falls back to a default. Optional for backward-compatible decoding.
|
||||
var nickname: String?
|
||||
var keychainTag: String
|
||||
|
||||
enum AuthMode: String, Codable { case guest, password }
|
||||
|
||||
init(id: UUID = UUID(), host: String, port: UInt16,
|
||||
authMode: AuthMode = .guest, savedUsername: String = "",
|
||||
nickname: String? = nil, keychainTag: String = "") {
|
||||
self.id = id
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.authMode = authMode
|
||||
self.savedUsername = savedUsername
|
||||
self.nickname = nickname
|
||||
self.keychainTag = keychainTag.isEmpty ? id.uuidString : keychainTag
|
||||
}
|
||||
|
||||
var displayString: String { "\(host):\(port)" }
|
||||
}
|
||||
92
clients/apple/iOS/VoiceCatiOS/ServerListStore.swift
Normal file
92
clients/apple/iOS/VoiceCatiOS/ServerListStore.swift
Normal file
@@ -0,0 +1,92 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
final class ServerListStore {
|
||||
static let shared = ServerListStore()
|
||||
|
||||
private let groupId = "group.cat.voice.VoiceCat"
|
||||
private let keychainService = "cat.voice.VoiceCatiOS"
|
||||
|
||||
// MARK: - App Group Container
|
||||
|
||||
var appGroupContainer: URL {
|
||||
guard let url = FileManager.default.containerURL(
|
||||
forSecurityApplicationGroupIdentifier: groupId)
|
||||
else {
|
||||
// Fall back to app-only support dir if App Groups are unavailable (e.g. simulator
|
||||
// without entitlements). TOFU pins won't be shared with the broadcast extension,
|
||||
// but connect + auth still work.
|
||||
return FileManager.default.urls(for: .applicationSupportDirectory,
|
||||
in: .userDomainMask).first!
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
private var voicecatDir: URL {
|
||||
let dir = appGroupContainer.appendingPathComponent("voicecat", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: dir,
|
||||
withIntermediateDirectories: true)
|
||||
return dir
|
||||
}
|
||||
|
||||
var tofuStorePath: String { voicecatDir.appendingPathComponent("tofu_pins.txt").path }
|
||||
|
||||
private var serversURL: URL { voicecatDir.appendingPathComponent("servers.json") }
|
||||
|
||||
// MARK: - Server list persistence
|
||||
|
||||
func load() -> [SavedServer] {
|
||||
guard let data = try? Data(contentsOf: serversURL),
|
||||
let list = try? JSONDecoder().decode([SavedServer].self, from: data)
|
||||
else { return [] }
|
||||
return list
|
||||
}
|
||||
|
||||
func save(_ servers: [SavedServer]) {
|
||||
guard let data = try? JSONEncoder().encode(servers) else { return }
|
||||
try? data.write(to: serversURL, options: .atomic)
|
||||
}
|
||||
|
||||
// MARK: - Keychain
|
||||
|
||||
func savePassword(_ password: String, tag: String) {
|
||||
let data = Data(password.utf8)
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: keychainService,
|
||||
kSecAttrAccount: tag,
|
||||
kSecAttrAccessGroup: groupId,
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
var add = query
|
||||
add[kSecValueData] = data
|
||||
SecItemAdd(add as CFDictionary, nil)
|
||||
}
|
||||
|
||||
func loadPassword(tag: String) -> String? {
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: keychainService,
|
||||
kSecAttrAccount: tag,
|
||||
kSecAttrAccessGroup: groupId,
|
||||
kSecReturnData: true,
|
||||
kSecMatchLimit: kSecMatchLimitOne,
|
||||
]
|
||||
var result: AnyObject?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
||||
let data = result as? Data
|
||||
else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
func deletePassword(tag: String) {
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: keychainService,
|
||||
kSecAttrAccount: tag,
|
||||
kSecAttrAccessGroup: groupId,
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
|
||||
}
|
||||
553
clients/apple/iOS/VoiceCatiOS/SessionState.swift
Normal file
553
clients/apple/iOS/VoiceCatiOS/SessionState.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
153
clients/apple/iOS/VoiceCatiOS/Views/AccountsView.swift
Normal file
153
clients/apple/iOS/VoiceCatiOS/Views/AccountsView.swift
Normal file
@@ -0,0 +1,153 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
struct AccountsView: View {
|
||||
@Bindable var session: SessionState
|
||||
@State private var showCreateAccount = false
|
||||
@State private var newUsername = ""
|
||||
@State private var newPassword = ""
|
||||
@State private var accountToDelete: Account?
|
||||
@State private var showResetPassword = false
|
||||
@State private var resetForAccount: Account?
|
||||
@State private var resetPassword = ""
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(session.accounts, id: \.username) { account in
|
||||
AccountRowView(account: account)
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) {
|
||||
accountToDelete = account
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
Button {
|
||||
resetForAccount = account
|
||||
showResetPassword = true
|
||||
} label: {
|
||||
Label("Reset PW", systemImage: "key")
|
||||
}
|
||||
.tint(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Accounts")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
showCreateAccount = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.accessibilityLabel("Create account")
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
session.fetchAccountList()
|
||||
}
|
||||
.onAppear {
|
||||
session.fetchAccountList()
|
||||
}
|
||||
.confirmationDialog("Delete account?", isPresented: Binding(
|
||||
get: { accountToDelete != nil },
|
||||
set: { if !$0 { accountToDelete = nil } }
|
||||
)) {
|
||||
if let a = accountToDelete {
|
||||
Button("Delete \(a.username)", role: .destructive) {
|
||||
session.deleteAccount(username: a.username)
|
||||
accountToDelete = nil
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) { accountToDelete = nil }
|
||||
}
|
||||
.sheet(isPresented: $showCreateAccount) {
|
||||
CreateAccountSheet(session: session)
|
||||
}
|
||||
.alert("Reset Password", isPresented: $showResetPassword) {
|
||||
SecureField("New password", text: $resetPassword)
|
||||
.accessibilityLabel("New password for account")
|
||||
Button("Reset") {
|
||||
if let a = resetForAccount, !resetPassword.isEmpty {
|
||||
session.resetPassword(username: a.username, newPassword: resetPassword)
|
||||
}
|
||||
resetPassword = ""
|
||||
resetForAccount = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
resetPassword = ""
|
||||
resetForAccount = nil
|
||||
}
|
||||
} message: {
|
||||
Text("Enter a new password for \(resetForAccount?.username ?? "").")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AccountRowView: View {
|
||||
let account: Account
|
||||
|
||||
private var joinedDate: String {
|
||||
let date = Date(timeIntervalSince1970: Double(account.createdAtUnixMs) / 1000)
|
||||
return date.formatted(.dateTime.year().month().day())
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack {
|
||||
Text(account.username)
|
||||
.fontWeight(.medium)
|
||||
if account.isAdmin {
|
||||
Text("admin")
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||
.background(.orange.opacity(0.2), in: Capsule())
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
Text("Created \(joinedDate)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(account.username)\(account.isAdmin ? ", administrator" : ""), created \(joinedDate)")
|
||||
}
|
||||
}
|
||||
|
||||
private struct CreateAccountSheet: View {
|
||||
@Bindable var session: SessionState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField("Username", text: $username)
|
||||
.textContentType(.username)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.accessibilityLabel("Username")
|
||||
SecureField("Password", text: $password)
|
||||
.textContentType(.newPassword)
|
||||
.accessibilityLabel("Password")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Create Account")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Create") {
|
||||
session.createAccount(username: username, password: password)
|
||||
dismiss()
|
||||
}
|
||||
.disabled(username.isEmpty || password.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
107
clients/apple/iOS/VoiceCatiOS/Views/AddServerView.swift
Normal file
107
clients/apple/iOS/VoiceCatiOS/Views/AddServerView.swift
Normal file
@@ -0,0 +1,107 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AddServerView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let editing: SavedServer?
|
||||
|
||||
@State private var host = ""
|
||||
@State private var port = "7878"
|
||||
@State private var authMode = SavedServer.AuthMode.guest
|
||||
@State private var nickname = ""
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
@State private var savePassword = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Server") {
|
||||
TextField("Hostname or IP", text: $host)
|
||||
.textContentType(.URL)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.accessibilityLabel("Server hostname or IP address")
|
||||
TextField("Port", text: $port)
|
||||
.keyboardType(.numberPad)
|
||||
.accessibilityLabel("Port number")
|
||||
}
|
||||
|
||||
Section("Authentication") {
|
||||
Picker("Mode", selection: $authMode) {
|
||||
Text("Guest").tag(SavedServer.AuthMode.guest)
|
||||
Text("Account").tag(SavedServer.AuthMode.password)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.accessibilityLabel("Authentication mode")
|
||||
|
||||
if authMode == .guest {
|
||||
TextField("Nickname (optional)", text: $nickname)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.accessibilityLabel("Guest nickname, optional display name")
|
||||
}
|
||||
|
||||
if authMode == .password {
|
||||
TextField("Username", text: $username)
|
||||
.textContentType(.username)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.accessibilityLabel("Username")
|
||||
SecureField("Password (optional)", text: $password)
|
||||
.textContentType(.password)
|
||||
.accessibilityLabel("Password, optional, leave blank to enter at connect time")
|
||||
Toggle("Save password in Keychain", isOn: $savePassword)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(editing == nil ? "Add Server" : "Edit Server")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") { save() }
|
||||
.disabled(host.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
|| UInt16(port) == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if let s = editing {
|
||||
host = s.host
|
||||
port = "\(s.port)"
|
||||
authMode = s.authMode
|
||||
username = s.savedUsername
|
||||
nickname = s.nickname ?? ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
let trimmedHost = host.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmedHost.isEmpty, let portNum = UInt16(port) else { return }
|
||||
|
||||
let pw = (savePassword && authMode == .password && !password.isEmpty) ? password : nil
|
||||
let trimmedNick = nickname.trimmingCharacters(in: .whitespaces)
|
||||
let nick: String? = (authMode == .guest && !trimmedNick.isEmpty) ? trimmedNick : nil
|
||||
|
||||
if var s = editing {
|
||||
s.host = trimmedHost
|
||||
s.port = portNum
|
||||
s.authMode = authMode
|
||||
s.savedUsername = authMode == .password ? username : ""
|
||||
s.nickname = nick
|
||||
appState.updateServer(s, password: pw)
|
||||
} else {
|
||||
let s = SavedServer(host: trimmedHost, port: portNum,
|
||||
authMode: authMode,
|
||||
savedUsername: authMode == .password ? username : "",
|
||||
nickname: nick)
|
||||
appState.addServer(s, password: pw)
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
58
clients/apple/iOS/VoiceCatiOS/Views/BanUserView.swift
Normal file
58
clients/apple/iOS/VoiceCatiOS/Views/BanUserView.swift
Normal file
@@ -0,0 +1,58 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
struct BanUserView: View {
|
||||
let user: User
|
||||
@Bindable var session: SessionState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var reason = ""
|
||||
@State private var permanent = true
|
||||
@State private var duration: Double = 60 // minutes
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Ban \(user.nickname)") {
|
||||
TextField("Reason (optional)", text: $reason)
|
||||
.accessibilityLabel("Ban reason, optional")
|
||||
Toggle("Permanent", isOn: $permanent)
|
||||
.accessibilityLabel("Permanent ban")
|
||||
if !permanent {
|
||||
HStack {
|
||||
Text("Duration")
|
||||
Slider(value: $duration, in: 1...10080, step: 1)
|
||||
.accessibilityLabel("Ban duration in minutes")
|
||||
Text(formattedDuration)
|
||||
.monospacedDigit()
|
||||
.frame(width: 60, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Ban User")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Ban", role: .destructive) {
|
||||
let expiresMs: UInt64 = permanent ? 0
|
||||
: UInt64(Date().timeIntervalSince1970 * 1000) + UInt64(duration * 60 * 1000)
|
||||
session.banUser(user.id, reason: reason, expiresUnixMs: expiresMs)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var formattedDuration: String {
|
||||
let mins = Int(duration)
|
||||
if mins < 60 { return "\(mins)m" }
|
||||
let hours = mins / 60
|
||||
if hours < 24 { return "\(hours)h" }
|
||||
return "\(hours / 24)d"
|
||||
}
|
||||
}
|
||||
105
clients/apple/iOS/VoiceCatiOS/Views/ChannelBrowserView.swift
Normal file
105
clients/apple/iOS/VoiceCatiOS/Views/ChannelBrowserView.swift
Normal file
@@ -0,0 +1,105 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
/// iPhone channels tab: a drill-down browser. The root list shows only top-level channels;
|
||||
/// tapping (or VoiceOver-activating) a channel pushes `ChannelDetailView`, which shows the
|
||||
/// people in it and any sub-channels. Joining is an explicit action inside the detail view.
|
||||
struct ChannelBrowserView: View {
|
||||
@Bindable var session: SessionState
|
||||
@State private var showCreateChannel = false
|
||||
@State private var editChannel: Channel?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(rootChannels) { ch in
|
||||
NavigationLink {
|
||||
ChannelDetailView(channel: ch, session: session)
|
||||
} label: {
|
||||
ChannelRow(channel: ch, session: session)
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
if session.permissions.isAdmin {
|
||||
Button(role: .destructive) {
|
||||
session.deleteChannel(ch.id)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .leading) {
|
||||
if session.permissions.isAdmin {
|
||||
Button {
|
||||
editChannel = ch
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Channels")
|
||||
.toolbar {
|
||||
if session.permissions.canCreateTempChannel || session.permissions.isAdmin {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
showCreateChannel = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.accessibilityLabel("Create channel")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showCreateChannel) {
|
||||
ChannelEditView(channelId: nil, session: session)
|
||||
}
|
||||
.sheet(item: $editChannel) { ch in
|
||||
ChannelEditView(channelId: ch.id, session: session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var rootChannels: [Channel] {
|
||||
session.channels
|
||||
.filter { $0.parentId == 0 }
|
||||
.sorted { $0.name < $1.name }
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared channel row used by the iPhone browser and detail views. Mirrors the look of the
|
||||
/// iPad `ChannelTreeView` row but keyed on a `Channel` rather than a tree `ChannelNode`.
|
||||
struct ChannelRow: View {
|
||||
let channel: Channel
|
||||
let session: SessionState
|
||||
|
||||
var body: some View {
|
||||
let isCurrent = session.currentChannelId == channel.id
|
||||
let usersHere = session.users.filter { $0.channelId == channel.id }
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: channel.passwordProtected ? "lock.fill" : "number")
|
||||
.foregroundStyle(isCurrent ? .blue : .secondary)
|
||||
.imageScale(.small)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(channel.name)
|
||||
.fontWeight(isCurrent ? .semibold : .regular)
|
||||
if !channel.topic.isEmpty {
|
||||
Text(channel.topic)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if !usersHere.isEmpty {
|
||||
Text("\(usersHere.count)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(channel.name)\(isCurrent ? ", current" : "")\(channel.passwordProtected ? ", password protected" : "")\(!usersHere.isEmpty ? ", \(usersHere.count) users" : "")")
|
||||
}
|
||||
}
|
||||
88
clients/apple/iOS/VoiceCatiOS/Views/ChannelDetailView.swift
Normal file
88
clients/apple/iOS/VoiceCatiOS/Views/ChannelDetailView.swift
Normal file
@@ -0,0 +1,88 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
/// The drilled-into view for a single channel: a Join control, the people currently in the
|
||||
/// channel, and any sub-channels (each drilling deeper via a nested `ChannelDetailView`).
|
||||
struct ChannelDetailView: View {
|
||||
let channel: Channel
|
||||
@Bindable var session: SessionState
|
||||
|
||||
@State private var showPasswordPrompt = false
|
||||
@State private var password = ""
|
||||
|
||||
private var isCurrent: Bool { session.currentChannelId == channel.id }
|
||||
private var people: [User] { session.users.filter { $0.channelId == channel.id } }
|
||||
private var subchannels: [Channel] {
|
||||
session.channels.filter { $0.parentId == channel.id }.sorted { $0.name < $1.name }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
if isCurrent {
|
||||
Label("You're here", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.accessibilityLabel("You are in this channel")
|
||||
} else {
|
||||
Button {
|
||||
join()
|
||||
} label: {
|
||||
Label("Join Channel", systemImage: "arrow.right.circle.fill")
|
||||
}
|
||||
.accessibilityLabel("Join \(channel.name)")
|
||||
}
|
||||
}
|
||||
|
||||
Section("People") {
|
||||
if people.isEmpty {
|
||||
Text("No one here yet.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(people) { user in
|
||||
UserRow(user: user, session: session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !subchannels.isEmpty {
|
||||
Section("Channels") {
|
||||
ForEach(subchannels) { sub in
|
||||
NavigationLink {
|
||||
ChannelDetailView(channel: sub, session: session)
|
||||
} label: {
|
||||
ChannelRow(channel: sub, session: session)
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
if session.permissions.isAdmin {
|
||||
Button(role: .destructive) {
|
||||
session.deleteChannel(sub.id)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(channel.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.alert("Channel Password", isPresented: $showPasswordPrompt) {
|
||||
SecureField("Password", text: $password)
|
||||
.accessibilityLabel("Channel password")
|
||||
Button("Join") {
|
||||
session.joinChannel(channel.id, password: password)
|
||||
password = ""
|
||||
}
|
||||
Button("Cancel", role: .cancel) { password = "" }
|
||||
}
|
||||
}
|
||||
|
||||
private func join() {
|
||||
if channel.passwordProtected {
|
||||
showPasswordPrompt = true
|
||||
} else {
|
||||
session.joinChannel(channel.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
173
clients/apple/iOS/VoiceCatiOS/Views/ChannelEditView.swift
Normal file
173
clients/apple/iOS/VoiceCatiOS/Views/ChannelEditView.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
145
clients/apple/iOS/VoiceCatiOS/Views/ChannelTreeView.swift
Normal file
145
clients/apple/iOS/VoiceCatiOS/Views/ChannelTreeView.swift
Normal file
@@ -0,0 +1,145 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
// ChannelNode wraps Channel for OutlineGroup; childrenOrNil must be nil (not empty [])
|
||||
// for leaf channels so OutlineGroup doesn't render expand buttons.
|
||||
struct ChannelNode: Identifiable {
|
||||
let channel: Channel
|
||||
let children: [ChannelNode]?
|
||||
var id: UInt32 { channel.id }
|
||||
}
|
||||
|
||||
struct ChannelTreeView: View {
|
||||
@Bindable var session: SessionState
|
||||
@State private var showCreateChannel = false
|
||||
@State private var editChannel: Channel?
|
||||
@State private var channelPassword = ""
|
||||
@State private var passwordChannelId: UInt32?
|
||||
|
||||
var body: some View {
|
||||
List(channelTree, children: \.children) { node in
|
||||
ChannelRowView(node: node, session: session)
|
||||
.onTapGesture {
|
||||
if node.channel.passwordProtected {
|
||||
passwordChannelId = node.channel.id
|
||||
} else {
|
||||
session.joinChannel(node.channel.id)
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
if session.permissions.isAdmin {
|
||||
Button(role: .destructive) {
|
||||
session.deleteChannel(node.channel.id)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .leading) {
|
||||
if session.permissions.isAdmin {
|
||||
Button {
|
||||
editChannel = node.channel
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.toolbar {
|
||||
if session.permissions.canCreateTempChannel || session.permissions.isAdmin {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
showCreateChannel = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.accessibilityLabel("Create channel")
|
||||
}
|
||||
}
|
||||
if session.currentChannelId != 0 {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Leave", systemImage: "arrow.left.circle") {
|
||||
session.leaveChannel()
|
||||
}
|
||||
.accessibilityLabel("Leave current channel")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showCreateChannel) {
|
||||
ChannelEditView(channelId: nil, session: session)
|
||||
}
|
||||
.sheet(item: $editChannel) { ch in
|
||||
ChannelEditView(channelId: ch.id, session: session)
|
||||
}
|
||||
.alert("Channel Password", isPresented: Binding(
|
||||
get: { passwordChannelId != nil },
|
||||
set: { if !$0 { passwordChannelId = nil; channelPassword = "" } }
|
||||
)) {
|
||||
SecureField("Password", text: $channelPassword)
|
||||
.accessibilityLabel("Channel password")
|
||||
Button("Join") {
|
||||
if let cid = passwordChannelId {
|
||||
session.joinChannel(cid, password: channelPassword)
|
||||
}
|
||||
passwordChannelId = nil
|
||||
channelPassword = ""
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
passwordChannelId = nil
|
||||
channelPassword = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var channelTree: [ChannelNode] {
|
||||
buildTree(parentId: 0, channels: session.channels)
|
||||
}
|
||||
|
||||
private func buildTree(parentId: UInt32, channels: [Channel]) -> [ChannelNode] {
|
||||
channels
|
||||
.filter { $0.parentId == parentId }
|
||||
.map { ch in
|
||||
let kids = buildTree(parentId: ch.id, channels: channels)
|
||||
return ChannelNode(channel: ch, children: kids.isEmpty ? nil : kids)
|
||||
}
|
||||
.sorted { $0.channel.name < $1.channel.name }
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChannelRowView: View {
|
||||
let node: ChannelNode
|
||||
let session: SessionState
|
||||
|
||||
var body: some View {
|
||||
let ch = node.channel
|
||||
let isCurrent = session.currentChannelId == ch.id
|
||||
let usersHere = session.users.filter { $0.channelId == ch.id }
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: ch.passwordProtected ? "lock.fill" : "number")
|
||||
.foregroundStyle(isCurrent ? .blue : .secondary)
|
||||
.imageScale(.small)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(ch.name)
|
||||
.fontWeight(isCurrent ? .semibold : .regular)
|
||||
if !ch.topic.isEmpty {
|
||||
Text(ch.topic)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if !usersHere.isEmpty {
|
||||
Text("\(usersHere.count)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityLabel("\(usersHere.count) users")
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(ch.name)\(isCurrent ? ", current" : "")\(ch.passwordProtected ? ", password protected" : "")\(!usersHere.isEmpty ? ", \(usersHere.count) users" : "")")
|
||||
}
|
||||
}
|
||||
159
clients/apple/iOS/VoiceCatiOS/Views/ChatView.swift
Normal file
159
clients/apple/iOS/VoiceCatiOS/Views/ChatView.swift
Normal file
@@ -0,0 +1,159 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
/// One row of the combined chat/activity timeline. Mirrors the macOS/Windows clients, which
|
||||
/// collapse chat messages and activity events into a single scrolling log (chat in normal
|
||||
/// text, activity events in gray).
|
||||
private enum TimelineItem: Identifiable {
|
||||
case message(ChatMessage)
|
||||
case activity(ActivityEntry)
|
||||
|
||||
var id: UUID {
|
||||
switch self {
|
||||
case .message(let m): return m.id
|
||||
case .activity(let a): return a.id
|
||||
}
|
||||
}
|
||||
|
||||
var timestamp: Date {
|
||||
switch self {
|
||||
case .message(let m): return m.timestamp
|
||||
case .activity(let a): return a.timestamp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatView: View {
|
||||
@Bindable var session: SessionState
|
||||
@State private var composeText = ""
|
||||
@State private var scope: VoiceCatTextScope = .channel
|
||||
@State private var privateTargetId: UInt32 = 0
|
||||
|
||||
private var timeline: [TimelineItem] {
|
||||
let merged = session.messages.map(TimelineItem.message)
|
||||
+ session.activityLog.map(TimelineItem.activity)
|
||||
return merged.sorted { $0.timestamp < $1.timestamp }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Combined chat + activity timeline
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(timeline) { item in
|
||||
switch item {
|
||||
case .message(let msg):
|
||||
ChatBubble(message: msg)
|
||||
.id(item.id)
|
||||
case .activity(let entry):
|
||||
ActivityRow(entry: entry)
|
||||
.id(item.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.onChange(of: session.messages.count + session.activityLog.count) { _, _ in
|
||||
if let last = timeline.last {
|
||||
proxy.scrollTo(last.id, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Compose bar
|
||||
HStack(spacing: 8) {
|
||||
TextField("Message…", text: $composeText, axis: .vertical)
|
||||
.lineLimit(1...5)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.accessibilityLabel("Message text field")
|
||||
.onSubmit { sendMessage() }
|
||||
|
||||
Button {
|
||||
sendMessage()
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.imageScale(.large)
|
||||
}
|
||||
.disabled(composeText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
|| session.currentChannelId == 0)
|
||||
.accessibilityLabel("Send message")
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.navigationTitle("Chat")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private func sendMessage() {
|
||||
let text = composeText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
session.sendText(text, scope: .channel, 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)")
|
||||
}
|
||||
}
|
||||
79
clients/apple/iOS/VoiceCatiOS/Views/MainView.swift
Normal file
79
clients/apple/iOS/VoiceCatiOS/Views/MainView.swift
Normal file
@@ -0,0 +1,79 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MainView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||
|
||||
var body: some View {
|
||||
if let session = appState.session {
|
||||
if sizeClass == .regular {
|
||||
iPadMainView(session: session)
|
||||
} else {
|
||||
iPhoneMainView(session: session)
|
||||
}
|
||||
} else {
|
||||
ServerListView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iPhone layout: TabView
|
||||
|
||||
private struct iPhoneMainView: View {
|
||||
let session: SessionState
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
ChannelBrowserView(session: session)
|
||||
.voiceControlsBar(session)
|
||||
.tabItem {
|
||||
Label("Channels", systemImage: "list.bullet.indent")
|
||||
}
|
||||
ChatView(session: session)
|
||||
.voiceControlsBar(session)
|
||||
.tabItem {
|
||||
Label("Chat", systemImage: "message")
|
||||
}
|
||||
SettingsView(session: session)
|
||||
.voiceControlsBar(session)
|
||||
.tabItem {
|
||||
Label("Settings", systemImage: "gear")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
/// Pin the shared voice controls just above the tab bar, *inside each tab's content area*.
|
||||
/// Applying this per-tab (rather than to the TabView itself) reserves layout space above
|
||||
/// the tab bar — keeping ChatView's compose box visible and cooperating with keyboard
|
||||
/// avoidance — without the bar covering the tab bar's buttons.
|
||||
func voiceControlsBar(_ session: SessionState) -> some View {
|
||||
safeAreaInset(edge: .bottom) {
|
||||
VoiceControlsView(session: session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iPad layout: NavigationSplitView
|
||||
|
||||
private struct iPadMainView: View {
|
||||
let session: SessionState
|
||||
@State private var columnVisibility = NavigationSplitViewVisibility.all
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
ChannelTreeView(session: session)
|
||||
.navigationTitle("Channels")
|
||||
} content: {
|
||||
UserListView(session: session)
|
||||
.navigationTitle("Users")
|
||||
} detail: {
|
||||
VStack(spacing: 0) {
|
||||
ChatView(session: session)
|
||||
VoiceControlsView(session: session)
|
||||
}
|
||||
.navigationTitle("Chat")
|
||||
}
|
||||
}
|
||||
}
|
||||
48
clients/apple/iOS/VoiceCatiOS/Views/MoveUserView.swift
Normal file
48
clients/apple/iOS/VoiceCatiOS/Views/MoveUserView.swift
Normal file
@@ -0,0 +1,48 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
struct MoveUserView: View {
|
||||
let user: User
|
||||
@Bindable var session: SessionState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var selectedChannelId: UInt32 = 0
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List(session.channels) { channel in
|
||||
HStack {
|
||||
Text(channel.name)
|
||||
Spacer()
|
||||
if channel.id == selectedChannelId {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(.blue)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { selectedChannelId = channel.id }
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(channel.name)\(channel.id == selectedChannelId ? ", selected" : "")")
|
||||
.accessibilityAddTraits(channel.id == selectedChannelId ? .isSelected : [])
|
||||
}
|
||||
.navigationTitle("Move \(user.nickname)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Move") {
|
||||
session.moveUser(user.id, toChannel: selectedChannelId)
|
||||
dismiss()
|
||||
}
|
||||
.disabled(selectedChannelId == 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
selectedChannelId = user.channelId
|
||||
}
|
||||
}
|
||||
}
|
||||
65
clients/apple/iOS/VoiceCatiOS/Views/PasswordPromptView.swift
Normal file
65
clients/apple/iOS/VoiceCatiOS/Views/PasswordPromptView.swift
Normal file
@@ -0,0 +1,65 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PasswordPromptView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
if let server = appState.connectingServer {
|
||||
Text("Connecting to \(server.displayString)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if appState.connectingServer?.authMode == .password {
|
||||
TextField("Username", text: $username)
|
||||
.textContentType(.username)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.accessibilityLabel("Username")
|
||||
}
|
||||
SecureField("Password", text: $password)
|
||||
.textContentType(.password)
|
||||
.accessibilityLabel("Password")
|
||||
}
|
||||
|
||||
if !appState.connectStatus.isEmpty && appState.connectStatus.lowercased().contains("failed") {
|
||||
Section {
|
||||
Text(appState.connectStatus)
|
||||
.foregroundStyle(.red)
|
||||
.accessibilityLabel("Error: \(appState.connectStatus)")
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Sign In")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
appState.cancelConnect()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Connect") {
|
||||
dismiss()
|
||||
let uname = appState.connectingServer?.savedUsername.isEmpty == false
|
||||
? appState.connectingServer!.savedUsername
|
||||
: username
|
||||
appState.authenticateUser(username: uname, password: password)
|
||||
}
|
||||
.disabled(password.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
username = appState.connectingServer?.savedUsername ?? ""
|
||||
}
|
||||
.interactiveDismissDisabled()
|
||||
}
|
||||
}
|
||||
70
clients/apple/iOS/VoiceCatiOS/Views/PerUserTuningView.swift
Normal file
70
clients/apple/iOS/VoiceCatiOS/Views/PerUserTuningView.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
63
clients/apple/iOS/VoiceCatiOS/Views/PermissionsView.swift
Normal file
63
clients/apple/iOS/VoiceCatiOS/Views/PermissionsView.swift
Normal file
@@ -0,0 +1,63 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
struct PermissionsView: View {
|
||||
let user: User
|
||||
@Bindable var session: SessionState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var canCreateTempChannel = false
|
||||
@State private var canKick = false
|
||||
@State private var canBan = false
|
||||
@State private var canMoveUsers = false
|
||||
@State private var canAdminAccounts = false
|
||||
@State private var isAdmin = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Permissions for \(user.nickname)") {
|
||||
Toggle("Create Temp Channels", isOn: $canCreateTempChannel)
|
||||
.accessibilityLabel("Can create temporary channels")
|
||||
Toggle("Kick Users", isOn: $canKick)
|
||||
.accessibilityLabel("Can kick users")
|
||||
Toggle("Ban Users", isOn: $canBan)
|
||||
.accessibilityLabel("Can ban users")
|
||||
Toggle("Move Users", isOn: $canMoveUsers)
|
||||
.accessibilityLabel("Can move users between channels")
|
||||
Toggle("Manage Accounts", isOn: $canAdminAccounts)
|
||||
.accessibilityLabel("Can manage server accounts")
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Administrator", isOn: $isAdmin)
|
||||
.foregroundStyle(isAdmin ? .orange : .primary)
|
||||
.accessibilityLabel("Full administrator access")
|
||||
} footer: {
|
||||
Text("Administrators bypass all permission checks.")
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Permissions")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
let perms = Permissions(
|
||||
canCreateTempChannel: canCreateTempChannel,
|
||||
canKick: canKick,
|
||||
canBan: canBan,
|
||||
canMoveUsers: canMoveUsers,
|
||||
canAdminAccounts: canAdminAccounts,
|
||||
isAdmin: isAdmin)
|
||||
session.setPermissions(user.id, perms: perms)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
75
clients/apple/iOS/VoiceCatiOS/Views/ServerIdentityView.swift
Normal file
75
clients/apple/iOS/VoiceCatiOS/Views/ServerIdentityView.swift
Normal file
@@ -0,0 +1,75 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
struct ServerIdentityView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let identity: PendingIdentity
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if identity.tofuStatus == .mismatch {
|
||||
Label("Server Identity Mismatch", systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.red)
|
||||
.accessibilityLabel("Warning: server identity mismatch")
|
||||
Text("The server's identity has changed since your last connection. This may indicate a man-in-the-middle attack, or that the server was reinstalled. Do NOT accept unless you know why the identity changed.")
|
||||
.foregroundStyle(.primary)
|
||||
} else {
|
||||
Label("New Server Identity", systemImage: "lock.badge.questionmark")
|
||||
.font(.headline)
|
||||
.accessibilityLabel("New server identity")
|
||||
Text("This is the first time you are connecting to this server. Verify the fingerprint below with the server administrator before accepting.")
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Server fingerprint")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(identity.displayText.isEmpty ? "(not available)" : identity.displayText)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.accessibilityLabel("Server fingerprint: \(identity.displayText)")
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
Spacer(minLength: 24)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Button {
|
||||
dismiss()
|
||||
appState.confirmServerIdentity(accept: true)
|
||||
} label: {
|
||||
Text("Accept")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(identity.tofuStatus == .mismatch ? .orange : .blue)
|
||||
.accessibilityLabel("Accept server identity and continue")
|
||||
|
||||
Button(role: .destructive) {
|
||||
dismiss()
|
||||
appState.confirmServerIdentity(accept: false)
|
||||
} label: {
|
||||
Text("Reject — Disconnect")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.accessibilityLabel("Reject server identity and disconnect")
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("Server Identity")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
.interactiveDismissDisabled()
|
||||
}
|
||||
}
|
||||
120
clients/apple/iOS/VoiceCatiOS/Views/ServerListView.swift
Normal file
120
clients/apple/iOS/VoiceCatiOS/Views/ServerListView.swift
Normal file
@@ -0,0 +1,120 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ServerListView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@State private var serverToDelete: SavedServer?
|
||||
|
||||
var body: some View {
|
||||
@Bindable var state = appState
|
||||
NavigationStack {
|
||||
Group {
|
||||
if appState.servers.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Servers",
|
||||
systemImage: "server.rack",
|
||||
description: Text("Tap + to add a server.")
|
||||
)
|
||||
} else {
|
||||
List {
|
||||
ForEach(appState.servers) { server in
|
||||
Button {
|
||||
appState.connectTo(server)
|
||||
} label: {
|
||||
ServerRowView(server: server)
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) {
|
||||
serverToDelete = server
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
Button {
|
||||
appState.editingServer = server
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Servers")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
appState.showAddServer = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.accessibilityLabel("Add server")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $state.showAddServer) {
|
||||
AddServerView(editing: nil)
|
||||
}
|
||||
.sheet(item: $state.editingServer) { server in
|
||||
AddServerView(editing: server)
|
||||
}
|
||||
.sheet(item: $state.pendingIdentity) { identity in
|
||||
ServerIdentityView(identity: identity)
|
||||
}
|
||||
.sheet(isPresented: $state.showPasswordPrompt) {
|
||||
PasswordPromptView()
|
||||
}
|
||||
.overlay {
|
||||
if appState.isConnecting {
|
||||
ConnectingOverlay()
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Delete server?", isPresented: Binding(
|
||||
get: { serverToDelete != nil },
|
||||
set: { if !$0 { serverToDelete = nil } }
|
||||
)) {
|
||||
if let s = serverToDelete {
|
||||
Button("Delete \(s.displayString)", role: .destructive) {
|
||||
appState.removeServer(s)
|
||||
serverToDelete = nil
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) { serverToDelete = nil }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ServerRowView: View {
|
||||
let server: SavedServer
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(server.displayString)
|
||||
.font(.body)
|
||||
Text(server.authMode == .guest
|
||||
? "Guest"
|
||||
: "Account: \(server.savedUsername)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(server.displayString), \(server.authMode == .guest ? "guest" : "account \(server.savedUsername)")")
|
||||
}
|
||||
}
|
||||
|
||||
private struct ConnectingOverlay: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black.opacity(0.3).ignoresSafeArea()
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
Text(appState.connectStatus)
|
||||
.foregroundStyle(.white)
|
||||
Button("Cancel") { appState.cancelConnect() }
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.white)
|
||||
}
|
||||
.padding(24)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
}
|
||||
}
|
||||
341
clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift
Normal file
341
clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift
Normal file
@@ -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) {}
|
||||
}
|
||||
26
clients/apple/iOS/VoiceCatiOS/Views/UserListView.swift
Normal file
26
clients/apple/iOS/VoiceCatiOS/Views/UserListView.swift
Normal file
@@ -0,0 +1,26 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
struct UserListView: View {
|
||||
@Bindable var session: SessionState
|
||||
|
||||
var body: some View {
|
||||
let channelUsers = session.currentChannelId == 0
|
||||
? session.users
|
||||
: session.users.filter { $0.channelId == session.currentChannelId }
|
||||
|
||||
List(channelUsers) { user in
|
||||
UserRow(user: user, session: session)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.overlay {
|
||||
if channelUsers.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Users",
|
||||
systemImage: "person.slash",
|
||||
description: Text(session.currentChannelId == 0 ? "Join a channel to see users." : "No one else here yet.")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
125
clients/apple/iOS/VoiceCatiOS/Views/UserRow.swift
Normal file
125
clients/apple/iOS/VoiceCatiOS/Views/UserRow.swift
Normal file
@@ -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" : "")")
|
||||
}
|
||||
}
|
||||
182
clients/apple/iOS/VoiceCatiOS/Views/VoiceControlsView.swift
Normal file
182
clients/apple/iOS/VoiceCatiOS/Views/VoiceControlsView.swift
Normal file
@@ -0,0 +1,182 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
import ReplayKit
|
||||
|
||||
struct VoiceControlsView: View {
|
||||
@Bindable var session: SessionState
|
||||
@StateObject private var router = IOSAudioRouter.shared
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 20) {
|
||||
// Join/Leave Voice button (mirrors macOS micToggleButton)
|
||||
if session.voiceState.inputMode == .pushToTalk {
|
||||
PTTButton(session: session)
|
||||
} else {
|
||||
Button {
|
||||
if session.voiceState.micActive {
|
||||
session.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)
|
||||
}
|
||||
}
|
||||
10
clients/apple/iOS/VoiceCatiOS/VoiceCatiOS.entitlements
Normal file
10
clients/apple/iOS/VoiceCatiOS/VoiceCatiOS.entitlements
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.me.iamtalon.voicecat</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
18
clients/apple/iOS/VoiceCatiOS/VoiceCatiOSApp.swift
Normal file
18
clients/apple/iOS/VoiceCatiOS/VoiceCatiOSApp.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
@main
|
||||
struct VoiceCatiOSApp: App {
|
||||
@State private var appState = AppState()
|
||||
|
||||
init() {
|
||||
AudioSessionManager.shared.configure()
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
MainView()
|
||||
.environment(appState)
|
||||
}
|
||||
}
|
||||
}
|
||||
464
clients/apple/macOS/VoiceCatMac.xcodeproj/project.pbxproj
Normal file
464
clients/apple/macOS/VoiceCatMac.xcodeproj/project.pbxproj
Normal file
@@ -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>
|
||||
38
clients/apple/macOS/VoiceCatMac/AppDelegate.swift
Normal file
38
clients/apple/macOS/VoiceCatMac/AppDelegate.swift
Normal file
@@ -0,0 +1,38 @@
|
||||
import AppKit
|
||||
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
private var connectWindowController: ConnectWindowController?
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
buildMenuBar()
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
connectWindowController = ConnectWindowController()
|
||||
connectWindowController?.showWindow(nil)
|
||||
NSApp.activate(ignoringOtherApps: false)
|
||||
}
|
||||
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }
|
||||
|
||||
private func buildMenuBar() {
|
||||
let mainMenu = NSMenu()
|
||||
|
||||
let appItem = NSMenuItem()
|
||||
mainMenu.addItem(appItem)
|
||||
let appMenu = NSMenu()
|
||||
appMenu.addItem(NSMenuItem(title: "Quit VoiceCat",
|
||||
action: #selector(NSApplication.terminate(_:)),
|
||||
keyEquivalent: "q"))
|
||||
appItem.submenu = appMenu
|
||||
|
||||
let editItem = NSMenuItem()
|
||||
mainMenu.addItem(editItem)
|
||||
let editMenu = NSMenu(title: "Edit")
|
||||
editMenu.addItem(NSMenuItem(title: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x"))
|
||||
editMenu.addItem(NSMenuItem(title: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c"))
|
||||
editMenu.addItem(NSMenuItem(title: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v"))
|
||||
editMenu.addItem(NSMenuItem(title: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a"))
|
||||
editItem.submenu = editMenu
|
||||
|
||||
NSApp.mainMenu = mainMenu
|
||||
}
|
||||
}
|
||||
229
clients/apple/macOS/VoiceCatMac/Audio/InputDeviceCapture.swift
Normal file
229
clients/apple/macOS/VoiceCatMac/Audio/InputDeviceCapture.swift
Normal file
@@ -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?
|
||||
}
|
||||
}
|
||||
207
clients/apple/macOS/VoiceCatMac/Audio/ScreenAudioCapture.swift
Normal file
207
clients/apple/macOS/VoiceCatMac/Audio/ScreenAudioCapture.swift
Normal file
@@ -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())
|
||||
}
|
||||
}
|
||||
28
clients/apple/macOS/VoiceCatMac/Info.plist
Normal file
28
clients/apple/macOS/VoiceCatMac/Info.plist
Normal file
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>VoiceCat</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.0.1</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>14.0</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © 2026 VoiceCat contributors. All rights reserved.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>VoiceCat uses your microphone to transmit voice audio to other participants in the current channel.</string>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
</dict>
|
||||
</plist>
|
||||
27
clients/apple/macOS/VoiceCatMac/Models/SavedServer.swift
Normal file
27
clients/apple/macOS/VoiceCatMac/Models/SavedServer.swift
Normal file
@@ -0,0 +1,27 @@
|
||||
import Foundation
|
||||
|
||||
enum AuthMode: String, Codable, CaseIterable {
|
||||
case guest
|
||||
case password
|
||||
}
|
||||
|
||||
struct SavedServer: Codable, Identifiable {
|
||||
var id: UUID = UUID()
|
||||
var host: String
|
||||
var port: UInt16
|
||||
var authMode: AuthMode
|
||||
var savedUsername: String?
|
||||
/// Free-form display name used when connecting as a guest. Distinct from the account
|
||||
/// `savedUsername`. Empty/nil falls back to the system full name.
|
||||
var nickname: String?
|
||||
var keychainTag: String?
|
||||
|
||||
var displayString: String {
|
||||
switch authMode {
|
||||
case .guest:
|
||||
return "\(host):\(port) (Guest)"
|
||||
case .password:
|
||||
return "\(savedUsername ?? "")@\(host):\(port)"
|
||||
}
|
||||
}
|
||||
}
|
||||
69
clients/apple/macOS/VoiceCatMac/Models/ServerListStore.swift
Normal file
69
clients/apple/macOS/VoiceCatMac/Models/ServerListStore.swift
Normal file
@@ -0,0 +1,69 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
enum ServerListStore {
|
||||
static var appSupportURL: URL {
|
||||
let url = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
return url.appendingPathComponent("VoiceCat", isDirectory: true)
|
||||
}
|
||||
|
||||
static var serversURL: URL {
|
||||
appSupportURL.appendingPathComponent("servers.json")
|
||||
}
|
||||
|
||||
static var tofuStorePath: String {
|
||||
appSupportURL.appendingPathComponent("tofu_pins.txt").path
|
||||
}
|
||||
|
||||
static func load() -> [SavedServer] {
|
||||
try? FileManager.default.createDirectory(at: appSupportURL, withIntermediateDirectories: true)
|
||||
guard let data = try? Data(contentsOf: serversURL),
|
||||
let list = try? JSONDecoder().decode([SavedServer].self, from: data) else { return [] }
|
||||
return list
|
||||
}
|
||||
|
||||
static func save(_ servers: [SavedServer]) {
|
||||
try? FileManager.default.createDirectory(at: appSupportURL, withIntermediateDirectories: true)
|
||||
if let data = try? JSONEncoder().encode(servers) {
|
||||
try? data.write(to: serversURL, options: .atomic)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Keychain
|
||||
|
||||
static func savePassword(_ password: String, tag: String) {
|
||||
guard let data = password.data(using: .utf8) else { return }
|
||||
deletePassword(tag: tag)
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: "cat.voice.VoiceCatMac",
|
||||
kSecAttrAccount: tag,
|
||||
kSecValueData: data,
|
||||
]
|
||||
SecItemAdd(query as CFDictionary, nil)
|
||||
}
|
||||
|
||||
static func loadPassword(tag: String) -> String? {
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: "cat.voice.VoiceCatMac",
|
||||
kSecAttrAccount: tag,
|
||||
kSecReturnData: true,
|
||||
kSecMatchLimit: kSecMatchLimitOne,
|
||||
]
|
||||
var result: AnyObject?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
||||
let data = result as? Data,
|
||||
let password = String(data: data, encoding: .utf8) else { return nil }
|
||||
return password
|
||||
}
|
||||
|
||||
static func deletePassword(tag: String) {
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: "cat.voice.VoiceCatMac",
|
||||
kSecAttrAccount: tag,
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
209
clients/apple/macOS/VoiceCatMac/Sheets/AccountsSheet.swift
Normal file
209
clients/apple/macOS/VoiceCatMac/Sheets/AccountsSheet.swift
Normal file
@@ -0,0 +1,209 @@
|
||||
import AppKit
|
||||
import VoiceCatCore
|
||||
|
||||
final class AccountsSheet: NSViewController {
|
||||
|
||||
private let client: VoiceCatClient
|
||||
private var accounts: [Account] = []
|
||||
private let tableView = NSTableView()
|
||||
private let statusLabel = NSTextField(labelWithString: "Loading…")
|
||||
|
||||
init(client: VoiceCatClient) {
|
||||
self.client = client
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
override func loadView() {
|
||||
view = NSView(frame: NSRect(x: 0, y: 0, width: 480, height: 340))
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
buildUI()
|
||||
refreshAccounts()
|
||||
}
|
||||
|
||||
private func buildUI() {
|
||||
let titleLabel = NSTextField(labelWithString: "Server Accounts")
|
||||
titleLabel.font = .boldSystemFont(ofSize: 13)
|
||||
|
||||
let userCol = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("username"))
|
||||
userCol.title = "Username"; userCol.width = 160
|
||||
let adminCol = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("admin"))
|
||||
adminCol.title = "Admin"; adminCol.width = 60
|
||||
let createdCol = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("created"))
|
||||
createdCol.title = "Created"; createdCol.width = 120
|
||||
let loginCol = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("login"))
|
||||
loginCol.title = "Last Login"; loginCol.width = 120
|
||||
|
||||
tableView.addTableColumn(userCol)
|
||||
tableView.addTableColumn(adminCol)
|
||||
tableView.addTableColumn(createdCol)
|
||||
tableView.addTableColumn(loginCol)
|
||||
tableView.dataSource = self; tableView.delegate = self
|
||||
tableView.allowsMultipleSelection = false
|
||||
tableView.setAccessibilityLabel("Server accounts")
|
||||
|
||||
let sv = NSScrollView()
|
||||
sv.documentView = tableView; sv.hasVerticalScroller = true
|
||||
sv.borderType = .bezelBorder
|
||||
|
||||
let refreshButton = NSButton(title: "Refresh", target: self, action: #selector(refreshClicked))
|
||||
refreshButton.bezelStyle = .rounded
|
||||
refreshButton.setAccessibilityLabel("Refresh account list")
|
||||
|
||||
let addButton = NSButton(title: "Add…", target: self, action: #selector(addClicked))
|
||||
addButton.bezelStyle = .rounded
|
||||
addButton.setAccessibilityLabel("Add new account")
|
||||
|
||||
let resetPwButton = NSButton(title: "Reset Password…", target: self, action: #selector(resetPwClicked))
|
||||
resetPwButton.bezelStyle = .rounded
|
||||
resetPwButton.setAccessibilityLabel("Reset selected account password")
|
||||
|
||||
let deleteButton = NSButton(title: "Delete…", target: self, action: #selector(deleteClicked))
|
||||
deleteButton.bezelStyle = .rounded
|
||||
deleteButton.setAccessibilityLabel("Delete selected account")
|
||||
|
||||
let doneButton = NSButton(title: "Done", target: self, action: #selector(doneClicked))
|
||||
doneButton.bezelStyle = .rounded; doneButton.keyEquivalent = "\r"
|
||||
doneButton.setAccessibilityLabel("Close accounts sheet")
|
||||
|
||||
statusLabel.textColor = .secondaryLabelColor
|
||||
statusLabel.setAccessibilityLabel("Status")
|
||||
|
||||
let toolbar = NSStackView(views: [refreshButton, addButton, resetPwButton, deleteButton, NSView()])
|
||||
toolbar.orientation = .horizontal; toolbar.spacing = 8
|
||||
|
||||
let bottomRow = NSStackView(views: [statusLabel, NSView(), doneButton])
|
||||
bottomRow.orientation = .horizontal; bottomRow.spacing = 8
|
||||
|
||||
let stack = NSStackView(views: [titleLabel, sv, toolbar, bottomRow])
|
||||
stack.orientation = .vertical; stack.spacing = 10
|
||||
stack.edgeInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(stack)
|
||||
NSLayoutConstraint.activate([
|
||||
stack.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
sv.heightAnchor.constraint(equalToConstant: 200),
|
||||
])
|
||||
|
||||
client.onEvent = { [weak self] event in
|
||||
if event.type == .accountList { self?.pullAccounts() }
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshAccounts() {
|
||||
statusLabel.stringValue = "Loading…"
|
||||
client.requestAccountList()
|
||||
}
|
||||
|
||||
private func pullAccounts() {
|
||||
accounts = client.listAccounts()
|
||||
tableView.reloadData()
|
||||
statusLabel.stringValue = "\(accounts.count) account\(accounts.count == 1 ? "" : "s")"
|
||||
}
|
||||
|
||||
@objc private func refreshClicked() { refreshAccounts() }
|
||||
|
||||
@objc private func addClicked() {
|
||||
let sheet = InputSheet(title: "New Account", prompt: "Username:", defaultValue: "")
|
||||
sheet.onComplete = { [weak self] username in
|
||||
guard let self, let username, !username.isEmpty else { return }
|
||||
let pwSheet = PasswordPromptSheet(prompt: "Password for \(username):")
|
||||
pwSheet.onComplete = { [weak self] password in
|
||||
guard let self, let password, !password.isEmpty else { return }
|
||||
let r = self.client.createAccount(username, password: password)
|
||||
if r == .ok {
|
||||
self.statusLabel.stringValue = "Account '\(username)' created."
|
||||
self.refreshAccounts()
|
||||
} else {
|
||||
self.statusLabel.stringValue = "Failed: \(r.description)"
|
||||
}
|
||||
}
|
||||
self.presentAsSheet(pwSheet)
|
||||
}
|
||||
presentAsSheet(sheet)
|
||||
}
|
||||
|
||||
@objc private func resetPwClicked() {
|
||||
let row = tableView.selectedRow
|
||||
guard row >= 0, row < accounts.count else { return }
|
||||
let account = accounts[row]
|
||||
let sheet = PasswordPromptSheet(prompt: "New password for \(account.username):")
|
||||
sheet.onComplete = { [weak self] password in
|
||||
guard let self, let password, !password.isEmpty else { return }
|
||||
let r = self.client.resetPassword(account.username, newPassword: password)
|
||||
self.statusLabel.stringValue = r == .ok
|
||||
? "Password reset for '\(account.username)'."
|
||||
: "Failed: \(r.description)"
|
||||
}
|
||||
presentAsSheet(sheet)
|
||||
}
|
||||
|
||||
@objc private func deleteClicked() {
|
||||
let row = tableView.selectedRow
|
||||
guard row >= 0, row < accounts.count else { return }
|
||||
let account = accounts[row]
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Delete account '\(account.username)'?"
|
||||
alert.informativeText = "This cannot be undone."
|
||||
alert.addButton(withTitle: "Delete"); alert.addButton(withTitle: "Cancel")
|
||||
alert.alertStyle = .warning
|
||||
guard let window = view.window else { return }
|
||||
alert.beginSheetModal(for: window) { [weak self] response in
|
||||
guard response == .alertFirstButtonReturn, let self else { return }
|
||||
let r = self.client.deleteAccount(account.username)
|
||||
self.statusLabel.stringValue = r == .ok
|
||||
? "Account '\(account.username)' deleted."
|
||||
: "Failed: \(r.description)"
|
||||
if r == .ok { self.refreshAccounts() }
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func doneClicked() { dismiss(nil) }
|
||||
|
||||
private func dateString(_ ms: UInt64) -> String {
|
||||
guard ms > 0 else { return "—" }
|
||||
let date = Date(timeIntervalSince1970: Double(ms) / 1000.0)
|
||||
return DateFormatter.localizedString(from: date, dateStyle: .short, timeStyle: .none)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NSTableViewDataSource / Delegate
|
||||
|
||||
extension AccountsSheet: NSTableViewDataSource, NSTableViewDelegate {
|
||||
func numberOfRows(in tableView: NSTableView) -> Int { accounts.count }
|
||||
|
||||
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
|
||||
let acc = accounts[row]
|
||||
let id = tableColumn?.identifier ?? NSUserInterfaceItemIdentifier("cell")
|
||||
let cell = tableView.makeView(withIdentifier: id, owner: nil) as? NSTableCellView
|
||||
?? makeCell(id)
|
||||
switch tableColumn?.identifier.rawValue {
|
||||
case "username": cell.textField?.stringValue = acc.username
|
||||
case "admin": cell.textField?.stringValue = acc.isAdmin ? "Yes" : ""
|
||||
case "created": cell.textField?.stringValue = dateString(acc.createdAtUnixMs)
|
||||
case "login": cell.textField?.stringValue = dateString(acc.lastLoginUnixMs)
|
||||
default: break
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
private func makeCell(_ id: NSUserInterfaceItemIdentifier) -> NSTableCellView {
|
||||
let cell = NSTableCellView(); cell.identifier = id
|
||||
let tf = NSTextField(labelWithString: "")
|
||||
tf.translatesAutoresizingMaskIntoConstraints = false
|
||||
cell.addSubview(tf); cell.textField = tf
|
||||
NSLayoutConstraint.activate([
|
||||
tf.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 4),
|
||||
tf.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -4),
|
||||
tf.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
|
||||
])
|
||||
return cell
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user