Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
370dd37a22 | ||
|
|
523eafd4d7 | ||
|
|
6c00fae1cf | ||
|
|
11a2541042 | ||
|
|
fe3da41e41 | ||
|
|
602eedc029 | ||
|
|
020218a7a1 | ||
|
|
1e774035d1 | ||
|
|
acc1450904 | ||
|
|
72ded4e011 | ||
|
|
c04aaf4a51 | ||
|
|
c4b8732bd7 | ||
|
|
7968f90f7d | ||
|
|
508fa8b408 | ||
|
|
00e3fa7ad5 | ||
|
|
ef19f4e7ce | ||
|
|
7ab50a4eb5 | ||
|
|
0c8efa38f1 | ||
|
|
af823da543 | ||
|
|
b944bd89d7 | ||
|
|
475f6e0603 | ||
|
|
008128defc | ||
|
|
dd6fa6121e | ||
|
|
8dbe767d4f | ||
|
|
f509c370b3 | ||
|
|
2948c029d0 | ||
|
|
40883c40c8 | ||
|
|
1efeaac19d | ||
|
|
6d2405f67a | ||
|
|
01a4a9ed28 | ||
|
|
93f4608250 | ||
|
|
e14dd73570 | ||
|
|
2ab8d5aae2 | ||
|
|
78ecafcc2c | ||
|
|
8c9eba15c3 | ||
|
|
292617f8e8 | ||
|
|
484522cbb6 | ||
|
|
ecdd68eff2 | ||
|
|
b2937da726 | ||
|
|
b20e6b663a | ||
|
|
13c7648a65 | ||
|
|
328776622f | ||
|
|
7dbf461262 | ||
|
|
3b13a7edb4 | ||
|
|
89bbfa1e18 | ||
|
|
0a6ef55937 | ||
|
|
aa796d7395 | ||
|
|
08d7ace25d | ||
|
|
ddd977796f | ||
|
|
2b285491d0 | ||
|
|
413f247378 | ||
|
|
3f9ea4f7b8 | ||
|
|
2f6d45fb04 | ||
|
|
5c3dd70bba | ||
|
|
ef14c22300 |
+46
-31
@@ -1,34 +1,49 @@
|
|||||||
# Environment variables set for all cargo invocations in this workspace.
|
# audiopus_sys calls cmake::build(opus_path), so downstream Cargo env cannot
|
||||||
# CMAKE_POLICY_VERSION_MINIMUM is required for audiopus_sys's bundled
|
# call cmake-rs Config::define() to override CMake's MSVC Debug CRT defaults.
|
||||||
# Opus CMake build to succeed on CMake 4.x (which removed compatibility
|
# Instead, point cmake-rs at a small wrapper that injects -D cache/policy
|
||||||
# with cmake_minimum_required < 3.5). audiopus_sys v0.2.2 bundles
|
# variables during configure while passing cmake --build / --version / -E /
|
||||||
# Opus 1.3.1 whose CMakeLists.txt uses a very old minimum version.
|
# --install / --open through unchanged. This keeps Opus Debug builds on
|
||||||
|
# Rust's release dynamic CRT (/MD) instead of CMake's default debug CRT
|
||||||
|
# (/MDd), which otherwise pulls in unresolved __imp__CrtDbgReportW symbols
|
||||||
|
# at test link.
|
||||||
|
#
|
||||||
|
# IMPORTANT: Cargo's `[target.<triple>]` config sections only forward a
|
||||||
|
# fixed allowlist of keys (linker, runner, rustflags, rustdocflags, ar)
|
||||||
|
# to build scripts. Arbitrary keys such as `CMAKE` placed under
|
||||||
|
# `[target.<triple>]` are silently ignored and never reach the
|
||||||
|
# audiopus_sys build script. cmake-rs (via cc-style env resolution)
|
||||||
|
# looks up CMAKE in this order:
|
||||||
|
# 1. CMAKE_<target-triple-with-dashes>
|
||||||
|
# 2. CMAKE_<target_triple_with_underscores>
|
||||||
|
# 3. TARGET_CMAKE (or HOST_CMAKE when host == target)
|
||||||
|
# 4. CMAKE
|
||||||
|
# We therefore scope the wrapper to Windows MSVC targets by setting the
|
||||||
|
# target-suffixed variant in the global [env] section. Non-Windows
|
||||||
|
# hosts (macOS, Linux, iOS, Android) never see CMAKE set and invoke
|
||||||
|
# `cmake` directly.
|
||||||
|
#
|
||||||
|
# NOTE: CMAKE_POLICY_DEFAULT_CMP0091 and CMAKE_MSVC_RUNTIME_LIBRARY cannot
|
||||||
|
# be set via the process environment because CMake does NOT auto-import
|
||||||
|
# them into its cache; they must be passed as `-D` definitions, which the
|
||||||
|
# wrapper does.
|
||||||
|
#
|
||||||
|
# iOS deployment target (DEC-003: iOS 13.0 minimum) is NOT set here. It is
|
||||||
|
# enforced in two places that own the iOS build:
|
||||||
|
# 1. tools/build-ios.sh — sets IPHONEOS_DEPLOYMENT_TARGET for the cargo
|
||||||
|
# invocation and bypasses audiopus_sys's CMake build via
|
||||||
|
# LIBOPUS_STATIC=1 / LIBOPUS_NO_PKG=1 / LIBOPUS_LIB_DIR.
|
||||||
|
# 2. apps/chanora_flutter/ios/Runner.xcodeproj — sets the Xcode
|
||||||
|
# IPHONEOS_DEPLOYMENT_TARGET build setting for the final link.
|
||||||
|
# Setting it globally here would make native macOS `cargo check` runs try
|
||||||
|
# to link iPhone objects against the macOS SDK.
|
||||||
|
|
||||||
[env]
|
[env]
|
||||||
CMAKE_POLICY_VERSION_MINIMUM = "3.5"
|
CMAKE_POLICY_VERSION_MINIMUM = "3.5"
|
||||||
|
|
||||||
# iOS builds must set IPHONEOS_DEPLOYMENT_TARGET in the invoking script
|
# Scope the cmake wrapper to Windows MSVC targets only via the
|
||||||
# or Xcode build phase. Do not set it globally here: native macOS cargo
|
# target-suffixed env var name that cc/cmake-rs already resolve.
|
||||||
# checks also compile bundled C/C++ dependencies, and a global iOS
|
# Force = true so a developer's pre-existing CMAKE_x86_64-pc-windows-msvc
|
||||||
# deployment target makes clang try to link iPhone objects against the
|
# does not silently bypass the wrapper. Relative = true so the path
|
||||||
# macOS SDK.
|
# resolves from the workspace root regardless of where cargo is invoked.
|
||||||
|
CMAKE_x86_64-pc-windows-msvc = { value = "tools/cmake-msvc-release-crt.cmd", force = true, relative = true }
|
||||||
# iOS target linker flags (DEC-003: minimum deployment target iOS 13.0).
|
CMAKE_aarch64-pc-windows-msvc = { value = "tools/cmake-msvc-release-crt.cmd", force = true, relative = true }
|
||||||
#
|
|
||||||
# These rustflags pass -miphoneos-version-min=13.0 to the linker, ensuring
|
|
||||||
# the final binary targets iOS 13.0+. This is defense-in-depth alongside
|
|
||||||
# the IPHONEOS_DEPLOYMENT_TARGET env var above — the env var affects C
|
|
||||||
# compilation (cc crate, CMake), while these rustflags affect the final
|
|
||||||
# link step.
|
|
||||||
#
|
|
||||||
# NOTE: The canonical iOS build is done via tools/build-ios.sh, which
|
|
||||||
# sets LIBOPUS_STATIC=1, LIBOPUS_NO_PKG=1, and LIBOPUS_LIB_DIR to
|
|
||||||
# bypass audiopus_sys's CMake build entirely.
|
|
||||||
|
|
||||||
[target.aarch64-apple-ios]
|
|
||||||
rustflags = ["-C", "link-arg=-miphoneos-version-min=13.0"]
|
|
||||||
|
|
||||||
[target.aarch64-apple-ios-sim]
|
|
||||||
rustflags = ["-C", "link-arg=-miphonesimulator-version-min=13.0"]
|
|
||||||
|
|
||||||
[target.x86_64-apple-ios]
|
|
||||||
rustflags = ["-C", "link-arg=-miphonesimulator-version-min=13.0"]
|
|
||||||
|
|||||||
+124
-15
@@ -25,15 +25,10 @@ jobs:
|
|||||||
run: cargo check --workspace --locked
|
run: cargo check --workspace --locked
|
||||||
- name: cargo test --workspace
|
- name: cargo test --workspace
|
||||||
env:
|
env:
|
||||||
# Storage tests must not hit the real OS keyring on CI:
|
|
||||||
# there is no D-Bus session available and the call would
|
|
||||||
# block. The runtime code carries the same toggle for
|
|
||||||
# headless / sandboxed environments.
|
|
||||||
CHANORA_DISABLE_KEYRING: "1"
|
CHANORA_DISABLE_KEYRING: "1"
|
||||||
run: cargo test --workspace --locked --no-fail-fast
|
run: cargo test --workspace --locked --no-fail-fast
|
||||||
- name: cargo clippy
|
- name: cargo clippy
|
||||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
supply-chain:
|
supply-chain:
|
||||||
name: cargo deny (licenses + advisories + bans + sources)
|
name: cargo deny (licenses + advisories + bans + sources)
|
||||||
@@ -43,9 +38,6 @@ jobs:
|
|||||||
- uses: EmbarkStudios/cargo-deny-action@v2
|
- uses: EmbarkStudios/cargo-deny-action@v2
|
||||||
with:
|
with:
|
||||||
command: check
|
command: check
|
||||||
# `licenses` enforces the DEC-020 license posture; the
|
|
||||||
# other three are minimal supply-chain hygiene per
|
|
||||||
# `docs/governance/legal-review-readiness.md` §5.
|
|
||||||
arguments: --workspace --all-features
|
arguments: --workspace --all-features
|
||||||
|
|
||||||
license-inventory:
|
license-inventory:
|
||||||
@@ -58,10 +50,6 @@ jobs:
|
|||||||
- name: Install cargo-about
|
- name: Install cargo-about
|
||||||
run: cargo install --locked --features cli cargo-about
|
run: cargo install --locked --features cli cargo-about
|
||||||
- name: Regenerate inventory and compare
|
- name: Regenerate inventory and compare
|
||||||
# Build the inventory in a temp file and diff against the
|
|
||||||
# committed copy. CI fails when the committed inventory is
|
|
||||||
# stale, forcing contributors to run the tool locally
|
|
||||||
# before opening a PR that touches the dependency tree.
|
|
||||||
run: |
|
run: |
|
||||||
cargo about generate --output-file /tmp/license-inventory.md about-md.hbs
|
cargo about generate --output-file /tmp/license-inventory.md about-md.hbs
|
||||||
diff docs/security/license-inventory.md /tmp/license-inventory.md \
|
diff docs/security/license-inventory.md /tmp/license-inventory.md \
|
||||||
@@ -80,9 +68,6 @@ jobs:
|
|||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
- name: Regenerate Flutter license inventory and compare
|
- name: Regenerate Flutter license inventory and compare
|
||||||
env:
|
env:
|
||||||
# Resolved by the wrapper from $HOME/sdks/flutter when
|
|
||||||
# not set; CI's subosito/flutter-action puts flutter on
|
|
||||||
# PATH but exports the SDK root under FLUTTER_ROOT.
|
|
||||||
FLUTTER_ROOT: ${{ env.FLUTTER_ROOT }}
|
FLUTTER_ROOT: ${{ env.FLUTTER_ROOT }}
|
||||||
run: |
|
run: |
|
||||||
./tools/dump_flutter_licenses.sh
|
./tools/dump_flutter_licenses.sh
|
||||||
@@ -136,3 +121,127 @@ jobs:
|
|||||||
if: steps.silero-coreml.outputs.available == 'true'
|
if: steps.silero-coreml.outputs.available == 'true'
|
||||||
working-directory: apps/chanora_flutter
|
working-directory: apps/chanora_flutter
|
||||||
run: flutter build ios --release --no-codesign
|
run: flutter build ios --release --no-codesign
|
||||||
|
- name: xcodebuild archive verification
|
||||||
|
if: steps.silero-coreml.outputs.available == 'true'
|
||||||
|
working-directory: apps/chanora_flutter
|
||||||
|
run: |
|
||||||
|
xcodebuild archive \
|
||||||
|
-workspace ios/Runner.xcworkspace \
|
||||||
|
-scheme Runner \
|
||||||
|
-archive build/Runner.xcarchive \
|
||||||
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
|
| xcpretty || { echo "::error::xcodebuild archive failed — see issue-history-analysis.md §4 'Xcode Archive vs build divergence'"; exit 1; }
|
||||||
|
|
||||||
|
android-build:
|
||||||
|
name: Android build (${{ matrix.target }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- target: aarch64-linux-android
|
||||||
|
abi: arm64-v8a
|
||||||
|
- target: armv7-linux-androideabi
|
||||||
|
abi: armeabi-v7a
|
||||||
|
- target: x86_64-linux-android
|
||||||
|
abi: x86_64
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: ${{ matrix.target }}
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- name: Install cargo-ndk
|
||||||
|
run: cargo install --locked cargo-ndk
|
||||||
|
- name: Setup NDK
|
||||||
|
run: |
|
||||||
|
ANDROID_ROOT="/usr/local/lib/android/sdk"
|
||||||
|
SDKMANAGER="$ANDROID_ROOT/cmdline-tools/latest/bin/sdkmanager"
|
||||||
|
echo "y" | $SDKMANAGER "ndk;27.0.12077973"
|
||||||
|
echo "ANDROID_NDK_HOME=$ANDROID_ROOT/ndk/27.0.12077973" >> "$GITHUB_ENV"
|
||||||
|
- name: cargo ndk build
|
||||||
|
run: cargo ndk -t ${{ matrix.abi }} build --workspace --locked
|
||||||
|
|
||||||
|
windows-build:
|
||||||
|
name: Windows build
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- name: cargo check --workspace
|
||||||
|
run: cargo check --workspace --locked
|
||||||
|
|
||||||
|
macos-build:
|
||||||
|
name: macOS build
|
||||||
|
runs-on: macos-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- name: cargo check --workspace
|
||||||
|
run: cargo check --workspace --locked
|
||||||
|
|
||||||
|
linux-multi-distro:
|
||||||
|
name: Linux build (${{ matrix.distro }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- distro: ubuntu
|
||||||
|
image: ubuntu:24.04
|
||||||
|
install: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y curl build-essential pkg-config \
|
||||||
|
libasound2-dev libpulse-dev libdbus-1-dev libsdl2-dev libopus-dev
|
||||||
|
- distro: fedora
|
||||||
|
image: fedora:latest
|
||||||
|
install: |
|
||||||
|
dnf install -y curl gcc pkg-config \
|
||||||
|
alsa-lib-devel pulseaudio-libs-devel dbus-devel SDL2-devel opus-devel
|
||||||
|
- distro: arch
|
||||||
|
image: archlinux:latest
|
||||||
|
install: |
|
||||||
|
pacman -Syu --noconfirm
|
||||||
|
pacman -S --noconfirm curl base-devel pkg-config \
|
||||||
|
alsa-lib pulseaudio dbus sdl2 opus
|
||||||
|
container:
|
||||||
|
image: ${{ matrix.image }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: ${{ matrix.install }}
|
||||||
|
- name: Install Rust
|
||||||
|
run: |
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||||
|
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||||
|
- name: cargo check --workspace
|
||||||
|
run: cargo check --workspace
|
||||||
|
|
||||||
|
coverage:
|
||||||
|
name: cargo llvm-cov
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: System deps
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libasound2-dev libpulse-dev pkg-config \
|
||||||
|
libdbus-1-dev libsdl2-dev libopus-dev
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: llvm-tools-preview
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- name: Install cargo-llvm-cov
|
||||||
|
run: cargo install --locked cargo-llvm-cov
|
||||||
|
- name: Generate coverage
|
||||||
|
env:
|
||||||
|
CHANORA_DISABLE_KEYRING: "1"
|
||||||
|
run: cargo llvm-cov --workspace --lcov --output-path lcov.info
|
||||||
|
- name: Upload coverage artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: lcov-report
|
||||||
|
path: lcov.info
|
||||||
|
|||||||
Generated
+369
-15
@@ -148,12 +148,111 @@ version = "0.7.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"event-listener",
|
"event-listener 5.4.1",
|
||||||
"event-listener-strategy",
|
"event-listener-strategy",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-channel"
|
||||||
|
version = "1.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35"
|
||||||
|
dependencies = [
|
||||||
|
"concurrent-queue",
|
||||||
|
"event-listener 2.5.3",
|
||||||
|
"futures-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-channel"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
|
||||||
|
dependencies = [
|
||||||
|
"concurrent-queue",
|
||||||
|
"event-listener-strategy",
|
||||||
|
"futures-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-executor"
|
||||||
|
version = "1.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
|
||||||
|
dependencies = [
|
||||||
|
"async-task",
|
||||||
|
"concurrent-queue",
|
||||||
|
"fastrand",
|
||||||
|
"futures-lite",
|
||||||
|
"pin-project-lite",
|
||||||
|
"slab",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-global-executor"
|
||||||
|
version = "2.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c"
|
||||||
|
dependencies = [
|
||||||
|
"async-channel 2.5.0",
|
||||||
|
"async-executor",
|
||||||
|
"async-io",
|
||||||
|
"async-lock",
|
||||||
|
"blocking",
|
||||||
|
"futures-lite",
|
||||||
|
"once_cell",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-io"
|
||||||
|
version = "2.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
"cfg-if",
|
||||||
|
"concurrent-queue",
|
||||||
|
"futures-io",
|
||||||
|
"futures-lite",
|
||||||
|
"parking",
|
||||||
|
"polling",
|
||||||
|
"rustix",
|
||||||
|
"slab",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-lock"
|
||||||
|
version = "3.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
|
||||||
|
dependencies = [
|
||||||
|
"event-listener 5.4.1",
|
||||||
|
"event-listener-strategy",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-process"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
|
||||||
|
dependencies = [
|
||||||
|
"async-channel 2.5.0",
|
||||||
|
"async-io",
|
||||||
|
"async-lock",
|
||||||
|
"async-signal",
|
||||||
|
"async-task",
|
||||||
|
"blocking",
|
||||||
|
"cfg-if",
|
||||||
|
"event-listener 5.4.1",
|
||||||
|
"futures-lite",
|
||||||
|
"rustix",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-recursion"
|
name = "async-recursion"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -165,6 +264,57 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-signal"
|
||||||
|
version = "0.2.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
|
||||||
|
dependencies = [
|
||||||
|
"async-io",
|
||||||
|
"async-lock",
|
||||||
|
"atomic-waker",
|
||||||
|
"cfg-if",
|
||||||
|
"futures-core",
|
||||||
|
"futures-io",
|
||||||
|
"rustix",
|
||||||
|
"signal-hook-registry",
|
||||||
|
"slab",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-std"
|
||||||
|
version = "1.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b"
|
||||||
|
dependencies = [
|
||||||
|
"async-channel 1.9.0",
|
||||||
|
"async-global-executor",
|
||||||
|
"async-io",
|
||||||
|
"async-lock",
|
||||||
|
"async-process",
|
||||||
|
"crossbeam-utils",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"futures-io",
|
||||||
|
"futures-lite",
|
||||||
|
"gloo-timers",
|
||||||
|
"kv-log-macro",
|
||||||
|
"log",
|
||||||
|
"memchr",
|
||||||
|
"once_cell",
|
||||||
|
"pin-project-lite",
|
||||||
|
"pin-utils",
|
||||||
|
"slab",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-task"
|
||||||
|
version = "4.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.89"
|
version = "0.1.89"
|
||||||
@@ -257,6 +407,12 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.21.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
@@ -308,6 +464,19 @@ dependencies = [
|
|||||||
"objc2",
|
"objc2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blocking"
|
||||||
|
version = "1.6.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
|
||||||
|
dependencies = [
|
||||||
|
"async-channel 2.5.0",
|
||||||
|
"async-task",
|
||||||
|
"futures-io",
|
||||||
|
"futures-lite",
|
||||||
|
"piper",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "build-target"
|
name = "build-target"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
@@ -352,6 +521,32 @@ version = "1.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cacache"
|
||||||
|
version = "13.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c5063741c7b2e260bbede781cf4679632dd90e2718e99f7715e46824b65670b"
|
||||||
|
dependencies = [
|
||||||
|
"async-std",
|
||||||
|
"digest 0.10.7",
|
||||||
|
"either",
|
||||||
|
"futures",
|
||||||
|
"hex",
|
||||||
|
"libc",
|
||||||
|
"memmap2",
|
||||||
|
"miette",
|
||||||
|
"reflink-copy",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
"sha1",
|
||||||
|
"sha2",
|
||||||
|
"ssri",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"walkdir",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cast"
|
name = "cast"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -468,6 +663,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"ndk-context",
|
"ndk-context",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -475,11 +671,23 @@ dependencies = [
|
|||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chanora_cache"
|
||||||
|
version = "0.2.0-beta.1"
|
||||||
|
dependencies = [
|
||||||
|
"cacache",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chanora_core"
|
name = "chanora_core"
|
||||||
version = "0.2.0-beta.1"
|
version = "0.2.0-beta.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chanora_audio",
|
"chanora_audio",
|
||||||
|
"chanora_cache",
|
||||||
"chanora_diagnostics",
|
"chanora_diagnostics",
|
||||||
"chanora_prefetch",
|
"chanora_prefetch",
|
||||||
"chanora_protocol",
|
"chanora_protocol",
|
||||||
@@ -515,11 +723,12 @@ name = "chanora_protocol"
|
|||||||
version = "0.2.0-beta.1"
|
version = "0.2.0-beta.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"chanora_resolver",
|
"chanora_resolver",
|
||||||
"futures",
|
"futures",
|
||||||
"reqwest 0.13.4",
|
"reqwest 0.13.4",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -554,7 +763,7 @@ dependencies = [
|
|||||||
name = "chanora_storage"
|
name = "chanora_storage"
|
||||||
version = "0.2.0-beta.1"
|
version = "0.2.0-beta.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"chacha20poly1305",
|
"chacha20poly1305",
|
||||||
"keyring",
|
"keyring",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
@@ -1239,6 +1448,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "event-listener"
|
||||||
|
version = "2.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "event-listener"
|
name = "event-listener"
|
||||||
version = "5.4.1"
|
version = "5.4.1"
|
||||||
@@ -1256,7 +1471,7 @@ version = "0.5.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"event-listener",
|
"event-listener 5.4.1",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1559,6 +1774,18 @@ dependencies = [
|
|||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gloo-timers"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"
|
||||||
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "group"
|
name = "group"
|
||||||
version = "0.13.0"
|
version = "0.13.0"
|
||||||
@@ -1837,7 +2064,7 @@ version = "0.1.20"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -2142,6 +2369,15 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "kv-log-macro"
|
||||||
|
version = "1.0.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -2226,6 +2462,9 @@ name = "log"
|
|||||||
version = "0.4.31"
|
version = "0.4.31"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f"
|
checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f"
|
||||||
|
dependencies = [
|
||||||
|
"value-bag",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lru-slab"
|
name = "lru-slab"
|
||||||
@@ -2274,6 +2513,15 @@ version = "2.8.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memmap2"
|
||||||
|
version = "0.5.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "83faa42c0a078c393f6b29d5db232d8be22776a891f8f56e5284faee4a20b327"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memoffset"
|
name = "memoffset"
|
||||||
version = "0.9.1"
|
version = "0.9.1"
|
||||||
@@ -2283,6 +2531,29 @@ dependencies = [
|
|||||||
"autocfg",
|
"autocfg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "miette"
|
||||||
|
version = "5.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59bb584eaeeab6bd0226ccf3509a69d7936d148cf3d036ad350abe35e8c6856e"
|
||||||
|
dependencies = [
|
||||||
|
"miette-derive",
|
||||||
|
"once_cell",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"unicode-width",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "miette-derive"
|
||||||
|
version = "5.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "49e7bc1560b95a3c4a25d03de42fe76ca718ab92d1a22a55b9b4cf67b3ae635c"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mime"
|
name = "mime"
|
||||||
version = "0.3.17"
|
version = "0.3.17"
|
||||||
@@ -2813,6 +3084,17 @@ version = "0.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "piper"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"fastrand",
|
||||||
|
"futures-io",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pkcs8"
|
name = "pkcs8"
|
||||||
version = "0.10.2"
|
version = "0.10.2"
|
||||||
@@ -2857,6 +3139,20 @@ dependencies = [
|
|||||||
"plotters-backend",
|
"plotters-backend",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "polling"
|
||||||
|
version = "3.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"concurrent-queue",
|
||||||
|
"hermit-abi",
|
||||||
|
"pin-project-lite",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "poly1305"
|
name = "poly1305"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -3183,6 +3479,18 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "reflink-copy"
|
||||||
|
version = "0.1.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13362233b147e57674c37b802d216b7c5e3dcccbed8967c84f0d8d223868ae27"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"rustix",
|
||||||
|
"windows",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex"
|
name = "regex"
|
||||||
version = "1.12.3"
|
version = "1.12.3"
|
||||||
@@ -3218,7 +3526,7 @@ version = "0.12.28"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"http",
|
"http",
|
||||||
@@ -3256,7 +3564,7 @@ version = "0.13.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
|
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
@@ -3672,6 +3980,17 @@ dependencies = [
|
|||||||
"digest 0.10.7",
|
"digest 0.10.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha1"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.2.17",
|
||||||
|
"digest 0.10.7",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
@@ -3851,6 +4170,23 @@ dependencies = [
|
|||||||
"der",
|
"der",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ssri"
|
||||||
|
version = "9.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "da7a2b3c2bc9693bcb40870c4e9b5bf0d79f9cb46273321bf855ec513e919082"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.21.7",
|
||||||
|
"digest 0.10.7",
|
||||||
|
"hex",
|
||||||
|
"miette",
|
||||||
|
"serde",
|
||||||
|
"sha-1",
|
||||||
|
"sha2",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"xxhash-rust",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stable_deref_trait"
|
name = "stable_deref_trait"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
@@ -4349,7 +4685,7 @@ name = "ts-bookkeeping"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"heck",
|
"heck",
|
||||||
"itertools 0.14.0",
|
"itertools 0.14.0",
|
||||||
"num-derive",
|
"num-derive",
|
||||||
@@ -4370,7 +4706,7 @@ version = "0.2.0"
|
|||||||
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"audiopus",
|
"audiopus",
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"futures",
|
"futures",
|
||||||
"git-testament",
|
"git-testament",
|
||||||
"hickory-net",
|
"hickory-net",
|
||||||
@@ -4398,7 +4734,7 @@ version = "0.2.0"
|
|||||||
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"curve25519-dalek-ng",
|
"curve25519-dalek-ng",
|
||||||
"eax",
|
"eax",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -4427,7 +4763,7 @@ name = "tsproto-packets"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"bitflags 2.12.1",
|
"bitflags 2.12.1",
|
||||||
"num-derive",
|
"num-derive",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
@@ -4442,7 +4778,7 @@ name = "tsproto-structs"
|
|||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
source = "git+https://github.com/EdisonJwa/tsclientlib.git?branch=fix%2Fp256-short-coordinate-pad#8b7a3226c692319b714ea1d32fd5ded05911aa40"
|
source = "git+https://github.com/EdisonJwa/tsclientlib.git?branch=fix%2Fp256-short-coordinate-pad#8b7a3226c692319b714ea1d32fd5ded05911aa40"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"csv",
|
"csv",
|
||||||
"heck",
|
"heck",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -4455,7 +4791,7 @@ name = "tsproto-structs"
|
|||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"csv",
|
"csv",
|
||||||
"heck",
|
"heck",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -4468,7 +4804,7 @@ name = "tsproto-types"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/EdisonJwa/tsclientlib.git?branch=fix%2Fp256-short-coordinate-pad#8b7a3226c692319b714ea1d32fd5ded05911aa40"
|
source = "git+https://github.com/EdisonJwa/tsclientlib.git?branch=fix%2Fp256-short-coordinate-pad#8b7a3226c692319b714ea1d32fd5ded05911aa40"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"bitflags 2.12.1",
|
"bitflags 2.12.1",
|
||||||
"curve25519-dalek-ng",
|
"curve25519-dalek-ng",
|
||||||
"elliptic-curve",
|
"elliptic-curve",
|
||||||
@@ -4512,6 +4848,12 @@ version = "1.0.24"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.1.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-xid"
|
name = "unicode-xid"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
@@ -4570,6 +4912,12 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "value-bag"
|
||||||
|
version = "1.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vcpkg"
|
name = "vcpkg"
|
||||||
version = "0.2.15"
|
version = "0.2.15"
|
||||||
@@ -5256,6 +5604,12 @@ version = "0.6.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "xxhash-rust"
|
||||||
|
version = "0.8.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
@@ -5289,7 +5643,7 @@ dependencies = [
|
|||||||
"async-recursion",
|
"async-recursion",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"enumflags2",
|
"enumflags2",
|
||||||
"event-listener",
|
"event-listener 5.4.1",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-lite",
|
"futures-lite",
|
||||||
"hex",
|
"hex",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
# crates/chanora_resolver/ — TeamSpeak address resolution
|
# crates/chanora_resolver/ — TeamSpeak address resolution
|
||||||
# crates/chanora_state/ — snapshot, deltas, reducers
|
# crates/chanora_state/ — snapshot, deltas, reducers
|
||||||
# crates/chanora_audio/ — capture, DSP, Opus, jitter, mixer
|
# crates/chanora_audio/ — capture, DSP, Opus, jitter, mixer
|
||||||
|
# crates/chanora_cache/ — avatar/icon blob cache (cacache-backed)
|
||||||
# crates/chanora_storage/ — bookmarks, settings, identity refs
|
# crates/chanora_storage/ — bookmarks, settings, identity refs
|
||||||
# crates/chanora_diagnostics/ — logs, redaction, export
|
# crates/chanora_diagnostics/ — logs, redaction, export
|
||||||
# crates/chanora_prefetch — server-resolution prefetch cache/policy
|
# crates/chanora_prefetch — server-resolution prefetch cache/policy
|
||||||
@@ -30,6 +31,7 @@ members = [
|
|||||||
"crates/chanora_state",
|
"crates/chanora_state",
|
||||||
"crates/chanora_audio",
|
"crates/chanora_audio",
|
||||||
"crates/chanora_storage",
|
"crates/chanora_storage",
|
||||||
|
"crates/chanora_cache",
|
||||||
"crates/chanora_diagnostics",
|
"crates/chanora_diagnostics",
|
||||||
"crates/chanora_prefetch",
|
"crates/chanora_prefetch",
|
||||||
"crates/chanora_bridge",
|
"crates/chanora_bridge",
|
||||||
@@ -38,6 +40,8 @@ members = [
|
|||||||
|
|
||||||
exclude = [
|
exclude = [
|
||||||
"apps/chanora_flutter",
|
"apps/chanora_flutter",
|
||||||
|
"tools/protocol-probe",
|
||||||
|
"tools/audio-test",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
+190
@@ -0,0 +1,190 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by the Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
Copyright 2024-2026 Chanora Contributors
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024-2026 Chanora Contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
#include? "Pods/Target Support Files/Pods-Chanora/Pods-Chanora.debug.xcconfig"
|
#include? "Pods/Target Support Files/Pods-Chanora/Pods-Chanora.debug.xcconfig"
|
||||||
#include "Generated.xcconfig"
|
#include "Generated.xcconfig"
|
||||||
|
|
||||||
// Mirror Release.xcconfig (see explanation there).
|
// Mirror Release.xcconfig (see explanation there). `-u` is the load-bearing
|
||||||
OTHER_LDFLAGS = $(inherited) -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_create -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_destroy -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_reset -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_process -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_last_error -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_free_string
|
// flag: without it the linker drops Swift @_cdecl symbols (no Swift caller)
|
||||||
|
// before `-exported_symbol` can re-export them, and the verify_silero_exports
|
||||||
|
// build phase fails the build.
|
||||||
|
OTHER_LDFLAGS = $(inherited) -Xlinker -u -Xlinker _chanora_silero_vad_create -Xlinker -u -Xlinker _chanora_silero_vad_destroy -Xlinker -u -Xlinker _chanora_silero_vad_reset -Xlinker -u -Xlinker _chanora_silero_vad_process -Xlinker -u -Xlinker _chanora_silero_vad_last_error -Xlinker -u -Xlinker _chanora_silero_vad_free_string -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_create -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_destroy -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_reset -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_process -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_last_error -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_free_string
|
||||||
STRIP_STYLE = non-global
|
STRIP_STYLE = non-global
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/haptic_kit/ios"
|
:path: ".symlinks/plugins/haptic_kit/ios"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
chanora_bridge: 26252acdf9ca660ce9c132ad25cd5ad5af467b16
|
chanora_bridge: 27a03592058709f6f38701343eb51c3a55b02da0
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89
|
flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89
|
||||||
haptic_kit: b22c4fbb2aa7b0d66f2891f81a9e950ad2de5758
|
haptic_kit: b22c4fbb2aa7b0d66f2891f81a9e950ad2de5758
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ import AVFoundation
|
|||||||
//
|
//
|
||||||
// VoIP configuration is engaged on voice-channel join via the
|
// VoIP configuration is engaged on voice-channel join via the
|
||||||
// `chanora/ios_audio_session` MethodChannel, driven from Dart
|
// `chanora/ios_audio_session` MethodChannel, driven from Dart
|
||||||
// by the BridgeEvent::AudioStarted / AudioStopped lifecycle.
|
// before `voiceJoin` starts VoiceProcessingIO and again as an
|
||||||
|
// idempotent guard on the AudioStarted lifecycle.
|
||||||
do {
|
do {
|
||||||
try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default)
|
try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default)
|
||||||
logAudioSessionState(context: "launch-ambient")
|
logAudioSessionState(context: "launch-ambient")
|
||||||
@@ -79,8 +80,8 @@ import AVFoundation
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Activate the VoIP audio session. Called from Dart via the
|
/// Activate the VoIP audio session. Called from Dart via the
|
||||||
/// `chanora/ios_audio_session` channel when a voice channel join
|
/// `chanora/ios_audio_session` channel before a voice channel join
|
||||||
/// reaches the `BridgeEvent::AudioStarted` stage. Configures
|
/// starts VoiceProcessingIO. Configures
|
||||||
/// .playAndRecord + .voiceChat with .mixWithOthers so other apps
|
/// .playAndRecord + .voiceChat with .mixWithOthers so other apps
|
||||||
/// (Spotify, podcasts) can keep playing alongside the voice
|
/// (Spotify, podcasts) can keep playing alongside the voice
|
||||||
/// channel — matching the Telegram group-call UX. Idempotent:
|
/// channel — matching the Telegram group-call UX. Idempotent:
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
<true/>
|
<false/>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||||
|
|||||||
+13
-2650
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,97 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
|
||||||
|
import '../l10n/generated/app_localizations.dart';
|
||||||
|
|
||||||
|
class LiveDiagnosticsDialog extends StatefulWidget {
|
||||||
|
const LiveDiagnosticsDialog({super.key, required this.diagnosticsTextBuilder});
|
||||||
|
|
||||||
|
final String Function() diagnosticsTextBuilder;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LiveDiagnosticsDialog> createState() => _LiveDiagnosticsDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LiveDiagnosticsDialogState extends State<LiveDiagnosticsDialog> {
|
||||||
|
static const _refreshInterval = Duration(seconds: 1);
|
||||||
|
|
||||||
|
Timer? _refreshTimer;
|
||||||
|
String _text = '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_refresh();
|
||||||
|
_refreshTimer = Timer.periodic(_refreshInterval, (_) => _refresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_refreshTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _refresh() {
|
||||||
|
final next = widget.diagnosticsTextBuilder();
|
||||||
|
if (!mounted || next == _text) return;
|
||||||
|
setState(() => _text = next);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppL10n.of(context);
|
||||||
|
final size = MediaQuery.sizeOf(context);
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: Text(l10n.diagnosticsAction)),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Tooltip(
|
||||||
|
message: l10n.diagnosticsLiveUpdating,
|
||||||
|
child: Icon(
|
||||||
|
Icons.sync,
|
||||||
|
size: 18,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: 720,
|
||||||
|
maxHeight: size.height * 0.65,
|
||||||
|
),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: SelectableText(
|
||||||
|
_text,
|
||||||
|
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await SharePlus.instance.share(ShareParams(text: _text));
|
||||||
|
},
|
||||||
|
child: Text(l10n.shareAction),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await Clipboard.setData(ClipboardData(text: _text));
|
||||||
|
if (!context.mounted) return;
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child: Text(l10n.copyAction),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: Text(l10n.closeAction),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -15,15 +15,22 @@ typedef StorageDirectoryProvider = Future<Directory> Function();
|
|||||||
typedef StorageInitializer = Future<void> Function(String dir);
|
typedef StorageInitializer = Future<void> Function(String dir);
|
||||||
|
|
||||||
Future<void>? _storageInitFuture;
|
Future<void>? _storageInitFuture;
|
||||||
|
Future<void>? _cacheInitFuture;
|
||||||
Future<void>? _vadBootstrapFuture;
|
Future<void>? _vadBootstrapFuture;
|
||||||
StorageDirectoryProvider _storageDirectoryProvider =
|
StorageDirectoryProvider _storageDirectoryProvider =
|
||||||
getApplicationSupportDirectory;
|
getApplicationSupportDirectory;
|
||||||
|
StorageDirectoryProvider _cacheDirectoryProvider = getApplicationCacheDirectory;
|
||||||
StorageInitializer _storageInitializer = _defaultStorageInitializer;
|
StorageInitializer _storageInitializer = _defaultStorageInitializer;
|
||||||
|
StorageInitializer _cacheInitializer = _defaultCacheInitializer;
|
||||||
|
|
||||||
Future<void> _defaultStorageInitializer(String dir) {
|
Future<void> _defaultStorageInitializer(String dir) {
|
||||||
return rust.initStorage(dir: dir);
|
return rust.initStorage(dir: dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _defaultCacheInitializer(String dir) {
|
||||||
|
return rust.initCache(dir: dir);
|
||||||
|
}
|
||||||
|
|
||||||
Future<File> _copyBundledAssetToDocuments({
|
Future<File> _copyBundledAssetToDocuments({
|
||||||
required String assetPath,
|
required String assetPath,
|
||||||
required String fileName,
|
required String fileName,
|
||||||
@@ -121,16 +128,50 @@ Future<void> _wireStorageImpl() async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> wireCache() async {
|
||||||
|
final existing = _cacheInitFuture;
|
||||||
|
if (existing != null) {
|
||||||
|
await existing;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final initFuture = _wireCacheImpl();
|
||||||
|
_cacheInitFuture = initFuture;
|
||||||
|
await initFuture;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _wireCacheImpl() async {
|
||||||
|
var initialized = false;
|
||||||
|
try {
|
||||||
|
final dir = await _cacheDirectoryProvider();
|
||||||
|
await _cacheInitializer(dir.path);
|
||||||
|
initialized = true;
|
||||||
|
} catch (_) {
|
||||||
|
// Best-effort; missing cache just means protocol-owned assets are
|
||||||
|
// re-downloaded this session.
|
||||||
|
} finally {
|
||||||
|
if (!initialized) {
|
||||||
|
_cacheInitFuture = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
void debugResetStorageBootstrap({
|
void debugResetStorageBootstrap({
|
||||||
StorageDirectoryProvider? storageDirectoryProvider,
|
StorageDirectoryProvider? storageDirectoryProvider,
|
||||||
|
StorageDirectoryProvider? cacheDirectoryProvider,
|
||||||
StorageInitializer? storageInitializer,
|
StorageInitializer? storageInitializer,
|
||||||
|
StorageInitializer? cacheInitializer,
|
||||||
}) {
|
}) {
|
||||||
_storageInitFuture = null;
|
_storageInitFuture = null;
|
||||||
|
_cacheInitFuture = null;
|
||||||
_vadBootstrapFuture = null;
|
_vadBootstrapFuture = null;
|
||||||
_storageDirectoryProvider =
|
_storageDirectoryProvider =
|
||||||
storageDirectoryProvider ?? getApplicationSupportDirectory;
|
storageDirectoryProvider ?? getApplicationSupportDirectory;
|
||||||
|
_cacheDirectoryProvider =
|
||||||
|
cacheDirectoryProvider ?? getApplicationCacheDirectory;
|
||||||
_storageInitializer = storageInitializer ?? _defaultStorageInitializer;
|
_storageInitializer = storageInitializer ?? _defaultStorageInitializer;
|
||||||
|
_cacheInitializer = cacheInitializer ?? _defaultCacheInitializer;
|
||||||
}
|
}
|
||||||
|
|
||||||
rust.BridgeNetworkState _mapConnectivity(List<ConnectivityResult> results) {
|
rust.BridgeNetworkState _mapConnectivity(List<ConnectivityResult> results) {
|
||||||
|
|||||||
@@ -148,12 +148,13 @@ void wireMacosAudioLifecycle({
|
|||||||
try {
|
try {
|
||||||
switch (call.method) {
|
switch (call.method) {
|
||||||
case 'handleDefaultDeviceChange':
|
case 'handleDefaultDeviceChange':
|
||||||
// TODO: call rust.macosDefaultDeviceChanged() once exposed
|
// TRACKED(macos-device-change): call rust.macosDefaultDeviceChanged()
|
||||||
// via flutter_rust_bridge; until then the event is captured
|
// once exposed via flutter_rust_bridge; until then the event is
|
||||||
// here for observability.
|
// captured here for observability.
|
||||||
break;
|
break;
|
||||||
case 'handleConfigurationChange':
|
case 'handleConfigurationChange':
|
||||||
// TODO: same — currently captured, no engine action yet.
|
// TRACKED(macos-config-change): currently captured, no engine action
|
||||||
|
// yet — depends on Rust-side device-change API exposure.
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ const iosAudioSessionChannelName = 'chanora/ios_audio_session';
|
|||||||
/// launch and leaves it inactive. The session is only switched to
|
/// launch and leaves it inactive. The session is only switched to
|
||||||
/// `.playAndRecord` + `.voiceChat` (with `.mixWithOthers`) while a
|
/// `.playAndRecord` + `.voiceChat` (with `.mixWithOthers`) while a
|
||||||
/// voice channel is actually active. This controller is the Dart
|
/// voice channel is actually active. This controller is the Dart
|
||||||
/// side of that contract — call [activate] when the Rust engine
|
/// side of that contract — call [activate] before the Rust engine
|
||||||
/// emits `BridgeEvent::AudioStarted` and [deactivate] on
|
/// starts VoiceProcessingIO and [deactivate] on
|
||||||
/// `BridgeEvent::AudioStopped`.
|
/// `BridgeEvent::AudioStopped` or failed joins.
|
||||||
///
|
///
|
||||||
/// On non-iOS platforms both methods are no-ops; the platforms
|
/// On non-iOS platforms both methods are no-ops; the platforms
|
||||||
/// handle their own session lifecycle elsewhere (Android via
|
/// handle their own session lifecycle elsewhere (Android via
|
||||||
|
|||||||
@@ -3,11 +3,19 @@ import 'package:flutter/material.dart';
|
|||||||
import '../l10n/generated/app_localizations.dart';
|
import '../l10n/generated/app_localizations.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// Manages user-trusted link domains to suppress external-link warnings.
|
||||||
|
///
|
||||||
|
/// Trusted domains are persisted in [SharedPreferences] under
|
||||||
|
/// `'trusted_domains'`. Supports wildcard patterns (e.g. `'*.example.com'`)
|
||||||
|
/// that match any subdomain of the base host.
|
||||||
|
///
|
||||||
|
/// This is a singleton; use [LinkTrustService.instance] to obtain it.
|
||||||
class LinkTrustService extends ChangeNotifier {
|
class LinkTrustService extends ChangeNotifier {
|
||||||
static LinkTrustService? _instance;
|
static LinkTrustService? _instance;
|
||||||
final Set<String> _trusted = {};
|
final Set<String> _trusted = {};
|
||||||
bool _loaded = false;
|
bool _loaded = false;
|
||||||
|
|
||||||
|
/// Returns the singleton [LinkTrustService] instance.
|
||||||
static LinkTrustService get instance {
|
static LinkTrustService get instance {
|
||||||
_instance ??= LinkTrustService._();
|
_instance ??= LinkTrustService._();
|
||||||
return _instance!;
|
return _instance!;
|
||||||
@@ -26,6 +34,10 @@ class LinkTrustService extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if [host] matches any trusted domain pattern.
|
||||||
|
///
|
||||||
|
/// Matching is case-insensitive. Wildcard patterns like `'*.example.com'`
|
||||||
|
/// match both `example.com` and any `*.example.com` subdomain.
|
||||||
bool isTrusted(String host) {
|
bool isTrusted(String host) {
|
||||||
host = host.toLowerCase();
|
host = host.toLowerCase();
|
||||||
for (final pattern in _trusted) {
|
for (final pattern in _trusted) {
|
||||||
@@ -34,6 +46,7 @@ class LinkTrustService extends ChangeNotifier {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persists [host] as a trusted domain and notifies listeners.
|
||||||
Future<void> addTrustedDomain(String host) async {
|
Future<void> addTrustedDomain(String host) async {
|
||||||
host = host.toLowerCase();
|
host = host.toLowerCase();
|
||||||
_trusted.add(host);
|
_trusted.add(host);
|
||||||
@@ -51,6 +64,10 @@ class LinkTrustService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shows a dialog asking the user whether to open an external link.
|
||||||
|
///
|
||||||
|
/// Returns `true` if the user chose to open and checked "remember this domain",
|
||||||
|
/// `false` if the user chose to open without remembering, or `null` if cancelled.
|
||||||
Future<bool?> showLinkTrustDialog(BuildContext context, String domain) async {
|
Future<bool?> showLinkTrustDialog(BuildContext context, String domain) async {
|
||||||
bool remember = false;
|
bool remember = false;
|
||||||
return showDialog<bool>(
|
return showDialog<bool>(
|
||||||
|
|||||||
@@ -3,7 +3,20 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
|||||||
|
|
||||||
import '../src/rust/api.dart' as rust;
|
import '../src/rust/api.dart' as rust;
|
||||||
|
|
||||||
|
/// Manages local push notifications for TeamSpeak poke events.
|
||||||
|
///
|
||||||
|
/// Handles platform-specific notification configuration across Android,
|
||||||
|
/// iOS, macOS, Linux, and Windows. Notification sound is intentionally
|
||||||
|
/// delegated to [EventSoundService] (tracked: TODO-event-sounds); this
|
||||||
|
/// service only manages the visual notification surface.
|
||||||
|
///
|
||||||
|
/// Poke strength maps to platform-appropriate urgency levels:
|
||||||
|
/// - [BridgePokeStrength.strong] → high-priority / time-sensitive
|
||||||
|
/// - [BridgePokeStrength.suppressed] → normal priority
|
||||||
|
/// - [BridgePokeStrength.suppressedOverflow] → passive / low priority
|
||||||
class PokeNotificationService {
|
class PokeNotificationService {
|
||||||
|
/// Creates a [PokeNotificationService] with an optional
|
||||||
|
/// [FlutterLocalNotificationsPlugin] for testing.
|
||||||
PokeNotificationService({FlutterLocalNotificationsPlugin? notifications})
|
PokeNotificationService({FlutterLocalNotificationsPlugin? notifications})
|
||||||
: _notifications = notifications ?? FlutterLocalNotificationsPlugin();
|
: _notifications = notifications ?? FlutterLocalNotificationsPlugin();
|
||||||
|
|
||||||
@@ -22,6 +35,9 @@ class PokeNotificationService {
|
|||||||
final FlutterLocalNotificationsPlugin _notifications;
|
final FlutterLocalNotificationsPlugin _notifications;
|
||||||
bool _initialized = false;
|
bool _initialized = false;
|
||||||
|
|
||||||
|
/// Initializes the notification plugin with platform-specific settings.
|
||||||
|
///
|
||||||
|
/// Safe to call multiple times; subsequent calls are no-ops.
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
if (_initialized) return;
|
if (_initialized) return;
|
||||||
await _notifications.initialize(
|
await _notifications.initialize(
|
||||||
@@ -30,22 +46,20 @@ class PokeNotificationService {
|
|||||||
iOS: DarwinInitializationSettings(
|
iOS: DarwinInitializationSettings(
|
||||||
requestAlertPermission: false,
|
requestAlertPermission: false,
|
||||||
requestBadgePermission: false,
|
requestBadgePermission: false,
|
||||||
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
// Sound handled by EventSoundService (tracked: TODO-event-sounds).
|
||||||
requestSoundPermission: false,
|
requestSoundPermission: false,
|
||||||
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
|
||||||
defaultPresentSound: false,
|
defaultPresentSound: false,
|
||||||
),
|
),
|
||||||
macOS: DarwinInitializationSettings(
|
macOS: DarwinInitializationSettings(
|
||||||
requestAlertPermission: false,
|
requestAlertPermission: false,
|
||||||
requestBadgePermission: false,
|
requestBadgePermission: false,
|
||||||
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
// Sound handled by EventSoundService (tracked: TODO-event-sounds).
|
||||||
requestSoundPermission: false,
|
requestSoundPermission: false,
|
||||||
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
|
||||||
defaultPresentSound: false,
|
defaultPresentSound: false,
|
||||||
),
|
),
|
||||||
linux: LinuxInitializationSettings(
|
linux: LinuxInitializationSettings(
|
||||||
defaultActionName: 'Open',
|
defaultActionName: 'Open',
|
||||||
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
// Sound handled by EventSoundService (tracked: TODO-event-sounds).
|
||||||
defaultSuppressSound: true,
|
defaultSuppressSound: true,
|
||||||
),
|
),
|
||||||
windows: WindowsInitializationSettings(
|
windows: WindowsInitializationSettings(
|
||||||
@@ -58,6 +72,10 @@ class PokeNotificationService {
|
|||||||
_initialized = true;
|
_initialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Requests notification permission from the user on the current platform.
|
||||||
|
///
|
||||||
|
/// Returns `true` on platforms where permission is not required (web,
|
||||||
|
/// Linux, Windows) or when the user grants permission.
|
||||||
Future<bool> requestPermission() async {
|
Future<bool> requestPermission() async {
|
||||||
await init();
|
await init();
|
||||||
if (kIsWeb) return true;
|
if (kIsWeb) return true;
|
||||||
@@ -89,6 +107,8 @@ class PokeNotificationService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Displays a poke notification from [senderName] with [strength]-based
|
||||||
|
/// urgency. Silently returns if the user has denied notification permission.
|
||||||
Future<void> show({
|
Future<void> show({
|
||||||
required String senderName,
|
required String senderName,
|
||||||
required String message,
|
required String message,
|
||||||
@@ -127,9 +147,8 @@ class PokeNotificationService {
|
|||||||
channelDescription: 'TeamSpeak poke notifications',
|
channelDescription: 'TeamSpeak poke notifications',
|
||||||
importance: isStrong ? Importance.max : Importance.defaultImportance,
|
importance: isStrong ? Importance.max : Importance.defaultImportance,
|
||||||
priority: isStrong ? Priority.high : Priority.defaultPriority,
|
priority: isStrong ? Priority.high : Priority.defaultPriority,
|
||||||
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
// Sound handled by EventSoundService (tracked: TODO-event-sounds).
|
||||||
playSound: false,
|
playSound: false,
|
||||||
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
|
||||||
silent: true,
|
silent: true,
|
||||||
groupKey: _groupKey,
|
groupKey: _groupKey,
|
||||||
category: AndroidNotificationCategory.message,
|
category: AndroidNotificationCategory.message,
|
||||||
@@ -139,7 +158,7 @@ class PokeNotificationService {
|
|||||||
|
|
||||||
DarwinNotificationDetails _darwinDetails(rust.BridgePokeStrength strength) {
|
DarwinNotificationDetails _darwinDetails(rust.BridgePokeStrength strength) {
|
||||||
return DarwinNotificationDetails(
|
return DarwinNotificationDetails(
|
||||||
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
// Sound handled by EventSoundService (tracked: TODO-event-sounds).
|
||||||
presentSound: false,
|
presentSound: false,
|
||||||
threadIdentifier: _darwinThreadId,
|
threadIdentifier: _darwinThreadId,
|
||||||
interruptionLevel: switch (strength) {
|
interruptionLevel: switch (strength) {
|
||||||
@@ -152,7 +171,7 @@ class PokeNotificationService {
|
|||||||
|
|
||||||
LinuxNotificationDetails _linuxDetails(rust.BridgePokeStrength strength) {
|
LinuxNotificationDetails _linuxDetails(rust.BridgePokeStrength strength) {
|
||||||
return LinuxNotificationDetails(
|
return LinuxNotificationDetails(
|
||||||
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
// Sound handled by EventSoundService (tracked: TODO-event-sounds).
|
||||||
suppressSound: true,
|
suppressSound: true,
|
||||||
urgency: switch (strength) {
|
urgency: switch (strength) {
|
||||||
rust.BridgePokeStrength.strong => LinuxNotificationUrgency.critical,
|
rust.BridgePokeStrength.strong => LinuxNotificationUrgency.critical,
|
||||||
@@ -165,7 +184,7 @@ class PokeNotificationService {
|
|||||||
|
|
||||||
WindowsNotificationDetails _windowsDetails(rust.BridgePokeStrength strength) {
|
WindowsNotificationDetails _windowsDetails(rust.BridgePokeStrength strength) {
|
||||||
return WindowsNotificationDetails(
|
return WindowsNotificationDetails(
|
||||||
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
// Sound handled by EventSoundService (tracked: TODO-event-sounds).
|
||||||
audio: WindowsNotificationAudio.silent(),
|
audio: WindowsNotificationAudio.silent(),
|
||||||
header: _windowsHeader,
|
header: _windowsHeader,
|
||||||
scenario: strength == rust.BridgePokeStrength.strong
|
scenario: strength == rust.BridgePokeStrength.strong
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// Persists user preferences for TeamSpeak poke notifications.
|
||||||
|
///
|
||||||
|
/// Stores two settings:
|
||||||
|
/// - Whether pokes are globally enabled
|
||||||
|
/// - A set of muted sender client IDs
|
||||||
|
///
|
||||||
|
/// Preferences are written to [SharedPreferences] and observable
|
||||||
|
/// through [ValueListenable] so UI widgets can rebuild reactively.
|
||||||
class PokePreferencesService {
|
class PokePreferencesService {
|
||||||
static const _enabledKey = 'pokes.enabled';
|
static const _enabledKey = 'pokes.enabled';
|
||||||
static const _mutedSendersKey = 'pokes.muted_senders';
|
static const _mutedSendersKey = 'pokes.muted_senders';
|
||||||
@@ -10,9 +18,13 @@ class PokePreferencesService {
|
|||||||
const <BigInt>{},
|
const <BigInt>{},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Whether poke notifications are globally enabled.
|
||||||
ValueListenable<bool> get pokesEnabled => _pokesEnabled;
|
ValueListenable<bool> get pokesEnabled => _pokesEnabled;
|
||||||
|
|
||||||
|
/// Set of client IDs whose pokes are muted.
|
||||||
ValueListenable<Set<BigInt>> get mutedSenders => _mutedSenders;
|
ValueListenable<Set<BigInt>> get mutedSenders => _mutedSenders;
|
||||||
|
|
||||||
|
/// Loads persisted preferences from [SharedPreferences].
|
||||||
Future<void> load() async {
|
Future<void> load() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
_pokesEnabled.value = prefs.getBool(_enabledKey) ?? true;
|
_pokesEnabled.value = prefs.getBool(_enabledKey) ?? true;
|
||||||
@@ -21,18 +33,21 @@ class PokePreferencesService {
|
|||||||
.toSet();
|
.toSet();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enables or disables poke notifications globally.
|
||||||
Future<void> setPokesEnabled(bool enabled) async {
|
Future<void> setPokesEnabled(bool enabled) async {
|
||||||
_pokesEnabled.value = enabled;
|
_pokesEnabled.value = enabled;
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setBool(_enabledKey, enabled);
|
await prefs.setBool(_enabledKey, enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adds [senderId] to the muted senders set.
|
||||||
Future<void> muteSender(BigInt senderId) async {
|
Future<void> muteSender(BigInt senderId) async {
|
||||||
if (_mutedSenders.value.contains(senderId)) return;
|
if (_mutedSenders.value.contains(senderId)) return;
|
||||||
_mutedSenders.value = {..._mutedSenders.value, senderId};
|
_mutedSenders.value = {..._mutedSenders.value, senderId};
|
||||||
await _saveMutedSenders();
|
await _saveMutedSenders();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Removes [senderId] from the muted senders set.
|
||||||
Future<void> unmuteSender(BigInt senderId) async {
|
Future<void> unmuteSender(BigInt senderId) async {
|
||||||
if (!_mutedSenders.value.contains(senderId)) return;
|
if (!_mutedSenders.value.contains(senderId)) return;
|
||||||
_mutedSenders.value = _mutedSenders.value
|
_mutedSenders.value = _mutedSenders.value
|
||||||
@@ -41,6 +56,7 @@ class PokePreferencesService {
|
|||||||
await _saveMutedSenders();
|
await _saveMutedSenders();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if [senderId] is in the muted senders set.
|
||||||
bool isMuted(BigInt senderId) => _mutedSenders.value.contains(senderId);
|
bool isMuted(BigInt senderId) => _mutedSenders.value.contains(senderId);
|
||||||
|
|
||||||
Future<void> _saveMutedSenders() async {
|
Future<void> _saveMutedSenders() async {
|
||||||
@@ -51,6 +67,7 @@ class PokePreferencesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Releases [ValueNotifier] resources.
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_pokesEnabled.dispose();
|
_pokesEnabled.dispose();
|
||||||
_mutedSenders.dispose();
|
_mutedSenders.dispose();
|
||||||
|
|||||||
@@ -18,11 +18,19 @@ class UiSettings {
|
|||||||
this.host = '',
|
this.host = '',
|
||||||
this.nickname = '',
|
this.nickname = '',
|
||||||
this.themeMode = UiThemeMode.system,
|
this.themeMode = UiThemeMode.system,
|
||||||
|
this.transmitModeIndex,
|
||||||
|
this.releaseTailMs,
|
||||||
|
this.inputDeviceId,
|
||||||
|
this.outputDeviceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String host;
|
final String host;
|
||||||
final String nickname;
|
final String nickname;
|
||||||
final UiThemeMode themeMode;
|
final UiThemeMode themeMode;
|
||||||
|
final int? transmitModeIndex;
|
||||||
|
final int? releaseTailMs;
|
||||||
|
final String? inputDeviceId;
|
||||||
|
final String? outputDeviceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
class UiPreferencesService {
|
class UiPreferencesService {
|
||||||
@@ -30,6 +38,10 @@ class UiPreferencesService {
|
|||||||
static const _nicknameKey = 'ui.nickname';
|
static const _nicknameKey = 'ui.nickname';
|
||||||
static const _themeModeKey = 'ui.theme_mode';
|
static const _themeModeKey = 'ui.theme_mode';
|
||||||
static const _permissionsExplainedKey = 'perms_explained';
|
static const _permissionsExplainedKey = 'perms_explained';
|
||||||
|
static const _transmitModeIndexKey = 'voice.transmit_mode_index';
|
||||||
|
static const _releaseTailMsKey = 'voice.release_tail_ms';
|
||||||
|
static const _inputDeviceIdKey = 'audio.input_device_id';
|
||||||
|
static const _outputDeviceIdKey = 'audio.output_device_id';
|
||||||
|
|
||||||
const UiPreferencesService();
|
const UiPreferencesService();
|
||||||
|
|
||||||
@@ -39,6 +51,10 @@ class UiPreferencesService {
|
|||||||
host: prefs.getString(_hostKey) ?? '',
|
host: prefs.getString(_hostKey) ?? '',
|
||||||
nickname: prefs.getString(_nicknameKey) ?? '',
|
nickname: prefs.getString(_nicknameKey) ?? '',
|
||||||
themeMode: UiThemeMode.fromStorage(prefs.getString(_themeModeKey)),
|
themeMode: UiThemeMode.fromStorage(prefs.getString(_themeModeKey)),
|
||||||
|
transmitModeIndex: prefs.getInt(_transmitModeIndexKey),
|
||||||
|
releaseTailMs: prefs.getInt(_releaseTailMsKey),
|
||||||
|
inputDeviceId: prefs.getString(_inputDeviceIdKey),
|
||||||
|
outputDeviceId: prefs.getString(_outputDeviceIdKey),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +69,34 @@ class UiPreferencesService {
|
|||||||
await prefs.setString(_themeModeKey, themeMode.name);
|
await prefs.setString(_themeModeKey, themeMode.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> saveTransmitModeIndex(int index) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setInt(_transmitModeIndexKey, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> saveReleaseTailMs(int ms) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setInt(_releaseTailMsKey, ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> saveInputDeviceId(String? id) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (id == null) {
|
||||||
|
await prefs.remove(_inputDeviceIdKey);
|
||||||
|
} else {
|
||||||
|
await prefs.setString(_inputDeviceIdKey, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> saveOutputDeviceId(String? id) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (id == null) {
|
||||||
|
await prefs.remove(_outputDeviceIdKey);
|
||||||
|
} else {
|
||||||
|
await prefs.setString(_outputDeviceIdKey, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> hasExplainedPermissions() async {
|
Future<bool> hasExplainedPermissions() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
return prefs.getBool(_permissionsExplainedKey) ?? false;
|
return prefs.getBool(_permissionsExplainedKey) ?? false;
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
typedef VoiceJoinCallback = Future<void> Function({
|
||||||
|
required BigInt channelId,
|
||||||
|
required String password,
|
||||||
|
});
|
||||||
|
|
||||||
|
typedef IosVoiceSessionActivation = Future<void> Function();
|
||||||
|
typedef IosVoiceSessionDeactivation = Future<void> Function();
|
||||||
|
|
||||||
|
/// Predicate used to recognise `voiceJoin` errors that the caller treats as a
|
||||||
|
/// successful join outcome (e.g. the server replied "already in channel").
|
||||||
|
///
|
||||||
|
/// When this returns `true` for a thrown error, the iOS audio session is kept
|
||||||
|
/// active because the user is still considered joined to the channel. The
|
||||||
|
/// error is still rethrown so the caller can run its success-on-already-joined
|
||||||
|
/// branch and update local state.
|
||||||
|
typedef VoiceJoinSuccessPredicate = bool Function(Object error);
|
||||||
|
|
||||||
|
Future<void> joinVoiceChannelWithIosAudioSession({
|
||||||
|
required BigInt channelId,
|
||||||
|
required String password,
|
||||||
|
required VoiceJoinCallback voiceJoin,
|
||||||
|
required IosVoiceSessionActivation activateIosAudioSession,
|
||||||
|
required IosVoiceSessionDeactivation deactivateIosAudioSession,
|
||||||
|
VoiceJoinSuccessPredicate? isJoinSuccess,
|
||||||
|
}) async {
|
||||||
|
await activateIosAudioSession();
|
||||||
|
try {
|
||||||
|
await voiceJoin(channelId: channelId, password: password);
|
||||||
|
} catch (e) {
|
||||||
|
if (isJoinSuccess != null && isJoinSuccess(e)) {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
await deactivateIosAudioSession();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -226,6 +226,29 @@ String exportDiagnostics() => RustLib.instance.api.crateApiExportDiagnostics();
|
|||||||
Future<void> initStorage({required String dir}) =>
|
Future<void> initStorage({required String dir}) =>
|
||||||
RustLib.instance.api.crateApiInitStorage(dir: dir);
|
RustLib.instance.api.crateApiInitStorage(dir: dir);
|
||||||
|
|
||||||
|
/// Configure the bridge blob cache root.
|
||||||
|
Future<void> initCache({required String dir}) =>
|
||||||
|
RustLib.instance.api.crateApiInitCache(dir: dir);
|
||||||
|
|
||||||
|
/// Resolve avatar bytes through the bridge.
|
||||||
|
Future<Uint8List?> downloadAvatar({
|
||||||
|
required String avatarHash,
|
||||||
|
required String clientUid,
|
||||||
|
}) => RustLib.instance.api.crateApiDownloadAvatar(
|
||||||
|
avatarHash: avatarHash,
|
||||||
|
clientUid: clientUid,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Resolve icon bytes through the bridge.
|
||||||
|
Future<Uint8List?> downloadIcon({required BigInt iconId}) =>
|
||||||
|
RustLib.instance.api.crateApiDownloadIcon(iconId: iconId);
|
||||||
|
|
||||||
|
/// Purge cached protocol-owned assets.
|
||||||
|
Future<void> clearFileCache() => RustLib.instance.api.crateApiClearFileCache();
|
||||||
|
|
||||||
|
/// Report the configured file-cache size.
|
||||||
|
Future<BigInt> fileCacheSize() => RustLib.instance.api.crateApiFileCacheSize();
|
||||||
|
|
||||||
/// List persisted bookmarks.
|
/// List persisted bookmarks.
|
||||||
Future<List<BridgeBookmark>> listBookmarks() =>
|
Future<List<BridgeBookmark>> listBookmarks() =>
|
||||||
RustLib.instance.api.crateApiListBookmarks();
|
RustLib.instance.api.crateApiListBookmarks();
|
||||||
@@ -1339,11 +1362,8 @@ sealed class BridgeEvent with _$BridgeEvent {
|
|||||||
|
|
||||||
/// Bridge iOS voice-processing mode.
|
/// Bridge iOS voice-processing mode.
|
||||||
enum BridgeIosVoiceProcessingMode {
|
enum BridgeIosVoiceProcessingMode {
|
||||||
/// Shipping VPIO path.
|
/// Apple VoiceProcessingIO path.
|
||||||
platformVoiceProcessing,
|
platformVoiceProcessing,
|
||||||
|
|
||||||
/// Experimental Sonora path.
|
|
||||||
sonoraExperimental,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
|
|||||||
String get codegenVersion => '2.12.0';
|
String get codegenVersion => '2.12.0';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get rustContentHash => -20394775;
|
int get rustContentHash => 635684021;
|
||||||
|
|
||||||
static const kDefaultExternalLibraryLoaderConfig =
|
static const kDefaultExternalLibraryLoaderConfig =
|
||||||
ExternalLibraryLoaderConfig(
|
ExternalLibraryLoaderConfig(
|
||||||
@@ -87,6 +87,8 @@ abstract class RustLibApi extends BaseApi {
|
|||||||
|
|
||||||
Future<void> crateApiBridgeInit();
|
Future<void> crateApiBridgeInit();
|
||||||
|
|
||||||
|
Future<void> crateApiClearFileCache();
|
||||||
|
|
||||||
Future<BridgeClientProfile> crateApiClientProfile({required BigInt clientId});
|
Future<BridgeClientProfile> crateApiClientProfile({required BigInt clientId});
|
||||||
|
|
||||||
Future<BridgeSnapshot> crateApiConnect({
|
Future<BridgeSnapshot> crateApiConnect({
|
||||||
@@ -99,12 +101,21 @@ abstract class RustLibApi extends BaseApi {
|
|||||||
|
|
||||||
Future<void> crateApiDisconnect();
|
Future<void> crateApiDisconnect();
|
||||||
|
|
||||||
|
Future<Uint8List?> crateApiDownloadAvatar({
|
||||||
|
required String avatarHash,
|
||||||
|
required String clientUid,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<Uint8List?> crateApiDownloadIcon({required BigInt iconId});
|
||||||
|
|
||||||
Future<void> crateApiEnableAudioDebugWavDump({required bool enabled});
|
Future<void> crateApiEnableAudioDebugWavDump({required bool enabled});
|
||||||
|
|
||||||
Stream<BridgeEvent> crateApiEventsStream();
|
Stream<BridgeEvent> crateApiEventsStream();
|
||||||
|
|
||||||
String crateApiExportDiagnostics();
|
String crateApiExportDiagnostics();
|
||||||
|
|
||||||
|
Future<BigInt> crateApiFileCacheSize();
|
||||||
|
|
||||||
Future<BridgeAudioProcessingConfig> crateApiGetAudioProcessingConfig();
|
Future<BridgeAudioProcessingConfig> crateApiGetAudioProcessingConfig();
|
||||||
|
|
||||||
Future<BridgePttBinding> crateApiGetPttBinding();
|
Future<BridgePttBinding> crateApiGetPttBinding();
|
||||||
@@ -121,6 +132,8 @@ abstract class RustLibApi extends BaseApi {
|
|||||||
|
|
||||||
void crateApiHandleRouteChange({required BridgeAudioRoute route});
|
void crateApiHandleRouteChange({required BridgeAudioRoute route});
|
||||||
|
|
||||||
|
Future<void> crateApiInitCache({required String dir});
|
||||||
|
|
||||||
Future<void> crateApiInitStorage({required String dir});
|
Future<void> crateApiInitStorage({required String dir});
|
||||||
|
|
||||||
Stream<double> crateApiInputLevelStream();
|
Stream<double> crateApiInputLevelStream();
|
||||||
@@ -320,6 +333,33 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
TaskConstMeta get kCrateApiBridgeInitConstMeta =>
|
TaskConstMeta get kCrateApiBridgeInitConstMeta =>
|
||||||
const TaskConstMeta(debugName: "bridge_init", argNames: []);
|
const TaskConstMeta(debugName: "bridge_init", argNames: []);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> crateApiClearFileCache() {
|
||||||
|
return handler.executeNormal(
|
||||||
|
NormalTask(
|
||||||
|
callFfi: (port_) {
|
||||||
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
|
pdeCallFfi(
|
||||||
|
generalizedFrbRustBinding,
|
||||||
|
serializer,
|
||||||
|
funcId: 5,
|
||||||
|
port: port_,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
codec: SseCodec(
|
||||||
|
decodeSuccessData: sse_decode_unit,
|
||||||
|
decodeErrorData: sse_decode_bridge_error,
|
||||||
|
),
|
||||||
|
constMeta: kCrateApiClearFileCacheConstMeta,
|
||||||
|
argValues: [],
|
||||||
|
apiImpl: this,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskConstMeta get kCrateApiClearFileCacheConstMeta =>
|
||||||
|
const TaskConstMeta(debugName: "clear_file_cache", argNames: []);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<BridgeClientProfile> crateApiClientProfile({
|
Future<BridgeClientProfile> crateApiClientProfile({
|
||||||
required BigInt clientId,
|
required BigInt clientId,
|
||||||
@@ -332,7 +372,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 5,
|
funcId: 6,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -366,7 +406,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 6,
|
funcId: 7,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -396,7 +436,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 7,
|
funcId: 8,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -423,7 +463,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 8,
|
funcId: 9,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -441,6 +481,68 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
TaskConstMeta get kCrateApiDisconnectConstMeta =>
|
TaskConstMeta get kCrateApiDisconnectConstMeta =>
|
||||||
const TaskConstMeta(debugName: "disconnect", argNames: []);
|
const TaskConstMeta(debugName: "disconnect", argNames: []);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Uint8List?> crateApiDownloadAvatar({
|
||||||
|
required String avatarHash,
|
||||||
|
required String clientUid,
|
||||||
|
}) {
|
||||||
|
return handler.executeNormal(
|
||||||
|
NormalTask(
|
||||||
|
callFfi: (port_) {
|
||||||
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
|
sse_encode_String(avatarHash, serializer);
|
||||||
|
sse_encode_String(clientUid, serializer);
|
||||||
|
pdeCallFfi(
|
||||||
|
generalizedFrbRustBinding,
|
||||||
|
serializer,
|
||||||
|
funcId: 10,
|
||||||
|
port: port_,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
codec: SseCodec(
|
||||||
|
decodeSuccessData: sse_decode_opt_list_prim_u_8_strict,
|
||||||
|
decodeErrorData: sse_decode_bridge_error,
|
||||||
|
),
|
||||||
|
constMeta: kCrateApiDownloadAvatarConstMeta,
|
||||||
|
argValues: [avatarHash, clientUid],
|
||||||
|
apiImpl: this,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskConstMeta get kCrateApiDownloadAvatarConstMeta => const TaskConstMeta(
|
||||||
|
debugName: "download_avatar",
|
||||||
|
argNames: ["avatarHash", "clientUid"],
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Uint8List?> crateApiDownloadIcon({required BigInt iconId}) {
|
||||||
|
return handler.executeNormal(
|
||||||
|
NormalTask(
|
||||||
|
callFfi: (port_) {
|
||||||
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
|
sse_encode_u_64(iconId, serializer);
|
||||||
|
pdeCallFfi(
|
||||||
|
generalizedFrbRustBinding,
|
||||||
|
serializer,
|
||||||
|
funcId: 11,
|
||||||
|
port: port_,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
codec: SseCodec(
|
||||||
|
decodeSuccessData: sse_decode_opt_list_prim_u_8_strict,
|
||||||
|
decodeErrorData: sse_decode_bridge_error,
|
||||||
|
),
|
||||||
|
constMeta: kCrateApiDownloadIconConstMeta,
|
||||||
|
argValues: [iconId],
|
||||||
|
apiImpl: this,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskConstMeta get kCrateApiDownloadIconConstMeta =>
|
||||||
|
const TaskConstMeta(debugName: "download_icon", argNames: ["iconId"]);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> crateApiEnableAudioDebugWavDump({required bool enabled}) {
|
Future<void> crateApiEnableAudioDebugWavDump({required bool enabled}) {
|
||||||
return handler.executeNormal(
|
return handler.executeNormal(
|
||||||
@@ -451,7 +553,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 9,
|
funcId: 12,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -484,7 +586,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 10,
|
funcId: 13,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -510,7 +612,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
SyncTask(
|
SyncTask(
|
||||||
callFfi: () {
|
callFfi: () {
|
||||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 11)!;
|
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 14)!;
|
||||||
},
|
},
|
||||||
codec: SseCodec(
|
codec: SseCodec(
|
||||||
decodeSuccessData: sse_decode_String,
|
decodeSuccessData: sse_decode_String,
|
||||||
@@ -526,6 +628,33 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
TaskConstMeta get kCrateApiExportDiagnosticsConstMeta =>
|
TaskConstMeta get kCrateApiExportDiagnosticsConstMeta =>
|
||||||
const TaskConstMeta(debugName: "export_diagnostics", argNames: []);
|
const TaskConstMeta(debugName: "export_diagnostics", argNames: []);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<BigInt> crateApiFileCacheSize() {
|
||||||
|
return handler.executeNormal(
|
||||||
|
NormalTask(
|
||||||
|
callFfi: (port_) {
|
||||||
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
|
pdeCallFfi(
|
||||||
|
generalizedFrbRustBinding,
|
||||||
|
serializer,
|
||||||
|
funcId: 15,
|
||||||
|
port: port_,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
codec: SseCodec(
|
||||||
|
decodeSuccessData: sse_decode_u_64,
|
||||||
|
decodeErrorData: sse_decode_bridge_error,
|
||||||
|
),
|
||||||
|
constMeta: kCrateApiFileCacheSizeConstMeta,
|
||||||
|
argValues: [],
|
||||||
|
apiImpl: this,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskConstMeta get kCrateApiFileCacheSizeConstMeta =>
|
||||||
|
const TaskConstMeta(debugName: "file_cache_size", argNames: []);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<BridgeAudioProcessingConfig> crateApiGetAudioProcessingConfig() {
|
Future<BridgeAudioProcessingConfig> crateApiGetAudioProcessingConfig() {
|
||||||
return handler.executeNormal(
|
return handler.executeNormal(
|
||||||
@@ -535,7 +664,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 12,
|
funcId: 16,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -565,7 +694,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 13,
|
funcId: 17,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -592,7 +721,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 14,
|
funcId: 18,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -619,7 +748,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 15,
|
funcId: 19,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -643,7 +772,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
SyncTask(
|
SyncTask(
|
||||||
callFfi: () {
|
callFfi: () {
|
||||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 16)!;
|
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 20)!;
|
||||||
},
|
},
|
||||||
codec: SseCodec(
|
codec: SseCodec(
|
||||||
decodeSuccessData: sse_decode_unit,
|
decodeSuccessData: sse_decode_unit,
|
||||||
@@ -666,7 +795,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
callFfi: () {
|
callFfi: () {
|
||||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
sse_encode_bool(shouldResume, serializer);
|
sse_encode_bool(shouldResume, serializer);
|
||||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 17)!;
|
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 21)!;
|
||||||
},
|
},
|
||||||
codec: SseCodec(
|
codec: SseCodec(
|
||||||
decodeSuccessData: sse_decode_unit,
|
decodeSuccessData: sse_decode_unit,
|
||||||
@@ -692,7 +821,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
callFfi: () {
|
callFfi: () {
|
||||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
sse_encode_String(routeClass, serializer);
|
sse_encode_String(routeClass, serializer);
|
||||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 18)!;
|
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 22)!;
|
||||||
},
|
},
|
||||||
codec: SseCodec(
|
codec: SseCodec(
|
||||||
decodeSuccessData: sse_decode_unit,
|
decodeSuccessData: sse_decode_unit,
|
||||||
@@ -718,7 +847,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
callFfi: () {
|
callFfi: () {
|
||||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
sse_encode_bridge_audio_route(route, serializer);
|
sse_encode_bridge_audio_route(route, serializer);
|
||||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 19)!;
|
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 23)!;
|
||||||
},
|
},
|
||||||
codec: SseCodec(
|
codec: SseCodec(
|
||||||
decodeSuccessData: sse_decode_unit,
|
decodeSuccessData: sse_decode_unit,
|
||||||
@@ -736,6 +865,34 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
argNames: ["route"],
|
argNames: ["route"],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> crateApiInitCache({required String dir}) {
|
||||||
|
return handler.executeNormal(
|
||||||
|
NormalTask(
|
||||||
|
callFfi: (port_) {
|
||||||
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
|
sse_encode_String(dir, serializer);
|
||||||
|
pdeCallFfi(
|
||||||
|
generalizedFrbRustBinding,
|
||||||
|
serializer,
|
||||||
|
funcId: 24,
|
||||||
|
port: port_,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
codec: SseCodec(
|
||||||
|
decodeSuccessData: sse_decode_unit,
|
||||||
|
decodeErrorData: sse_decode_bridge_error,
|
||||||
|
),
|
||||||
|
constMeta: kCrateApiInitCacheConstMeta,
|
||||||
|
argValues: [dir],
|
||||||
|
apiImpl: this,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskConstMeta get kCrateApiInitCacheConstMeta =>
|
||||||
|
const TaskConstMeta(debugName: "init_cache", argNames: ["dir"]);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> crateApiInitStorage({required String dir}) {
|
Future<void> crateApiInitStorage({required String dir}) {
|
||||||
return handler.executeNormal(
|
return handler.executeNormal(
|
||||||
@@ -746,7 +903,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 20,
|
funcId: 25,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -776,7 +933,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 21,
|
funcId: 26,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -805,7 +962,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 22,
|
funcId: 27,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -832,7 +989,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 23,
|
funcId: 28,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -859,7 +1016,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 24,
|
funcId: 29,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -883,7 +1040,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
SyncTask(
|
SyncTask(
|
||||||
callFfi: () {
|
callFfi: () {
|
||||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 25)!;
|
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 30)!;
|
||||||
},
|
},
|
||||||
codec: SseCodec(
|
codec: SseCodec(
|
||||||
decodeSuccessData: sse_decode_String,
|
decodeSuccessData: sse_decode_String,
|
||||||
@@ -913,7 +1070,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 26,
|
funcId: 31,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -943,7 +1100,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 27,
|
funcId: 32,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -970,7 +1127,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 28,
|
funcId: 33,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -995,7 +1152,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
callFfi: () {
|
callFfi: () {
|
||||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
sse_encode_String(state, serializer);
|
sse_encode_String(state, serializer);
|
||||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 29)!;
|
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 34)!;
|
||||||
},
|
},
|
||||||
codec: SseCodec(
|
codec: SseCodec(
|
||||||
decodeSuccessData: sse_decode_unit,
|
decodeSuccessData: sse_decode_unit,
|
||||||
@@ -1028,7 +1185,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 30,
|
funcId: 35,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1055,7 +1212,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
callFfi: () {
|
callFfi: () {
|
||||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
sse_encode_bridge_audio_route(route, serializer);
|
sse_encode_bridge_audio_route(route, serializer);
|
||||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 31)!;
|
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 36)!;
|
||||||
},
|
},
|
||||||
codec: SseCodec(
|
codec: SseCodec(
|
||||||
decodeSuccessData: sse_decode_unit,
|
decodeSuccessData: sse_decode_unit,
|
||||||
@@ -1089,7 +1246,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 32,
|
funcId: 37,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1124,7 +1281,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 33,
|
funcId: 38,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1154,7 +1311,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 34,
|
funcId: 39,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1182,7 +1339,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 35,
|
funcId: 40,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1210,7 +1367,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 36,
|
funcId: 41,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1240,7 +1397,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 37,
|
funcId: 42,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1268,7 +1425,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
callFfi: () {
|
callFfi: () {
|
||||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
sse_encode_bridge_network_state(state, serializer);
|
sse_encode_bridge_network_state(state, serializer);
|
||||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 38)!;
|
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 43)!;
|
||||||
},
|
},
|
||||||
codec: SseCodec(
|
codec: SseCodec(
|
||||||
decodeSuccessData: sse_decode_unit,
|
decodeSuccessData: sse_decode_unit,
|
||||||
@@ -1294,7 +1451,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 39,
|
funcId: 44,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1322,7 +1479,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 40,
|
funcId: 45,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1350,7 +1507,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 41,
|
funcId: 46,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1378,7 +1535,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 42,
|
funcId: 47,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1410,7 +1567,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 43,
|
funcId: 48,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1440,7 +1597,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 44,
|
funcId: 49,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1468,7 +1625,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 45,
|
funcId: 50,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1496,7 +1653,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 46,
|
funcId: 51,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1523,7 +1680,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 47,
|
funcId: 52,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1551,7 +1708,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 48,
|
funcId: 53,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1583,7 +1740,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 49,
|
funcId: 54,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1612,7 +1769,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 50,
|
funcId: 55,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -2290,6 +2447,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
return raw == null ? null : dco_decode_box_autoadd_u_64(raw);
|
return raw == null ? null : dco_decode_box_autoadd_u_64(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
Uint8List? dco_decode_opt_list_prim_u_8_strict(dynamic raw) {
|
||||||
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
|
return raw == null ? null : dco_decode_list_prim_u_8_strict(raw);
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
PermissionStateKind dco_decode_permission_state_kind(dynamic raw) {
|
PermissionStateKind dco_decode_permission_state_kind(dynamic raw) {
|
||||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
@@ -3249,6 +3412,17 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
Uint8List? sse_decode_opt_list_prim_u_8_strict(SseDeserializer deserializer) {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
|
||||||
|
if (sse_decode_bool(deserializer)) {
|
||||||
|
return (sse_decode_list_prim_u_8_strict(deserializer));
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
PermissionStateKind sse_decode_permission_state_kind(
|
PermissionStateKind sse_decode_permission_state_kind(
|
||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
@@ -4096,6 +4270,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_opt_list_prim_u_8_strict(
|
||||||
|
Uint8List? self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
) {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
|
||||||
|
sse_encode_bool(self != null, serializer);
|
||||||
|
if (self != null) {
|
||||||
|
sse_encode_list_prim_u_8_strict(self, serializer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_permission_state_kind(
|
void sse_encode_permission_state_kind(
|
||||||
PermissionStateKind self,
|
PermissionStateKind self,
|
||||||
|
|||||||
@@ -201,6 +201,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
BigInt? dco_decode_opt_box_autoadd_u_64(dynamic raw);
|
BigInt? dco_decode_opt_box_autoadd_u_64(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
Uint8List? dco_decode_opt_list_prim_u_8_strict(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
PermissionStateKind dco_decode_permission_state_kind(dynamic raw);
|
PermissionStateKind dco_decode_permission_state_kind(dynamic raw);
|
||||||
|
|
||||||
@@ -444,6 +447,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
BigInt? sse_decode_opt_box_autoadd_u_64(SseDeserializer deserializer);
|
BigInt? sse_decode_opt_box_autoadd_u_64(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
Uint8List? sse_decode_opt_list_prim_u_8_strict(SseDeserializer deserializer);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
PermissionStateKind sse_decode_permission_state_kind(
|
PermissionStateKind sse_decode_permission_state_kind(
|
||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
@@ -746,6 +752,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer);
|
void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_opt_list_prim_u_8_strict(
|
||||||
|
Uint8List? self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_permission_state_kind(
|
void sse_encode_permission_state_kind(
|
||||||
PermissionStateKind self,
|
PermissionStateKind self,
|
||||||
|
|||||||
@@ -203,6 +203,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
BigInt? dco_decode_opt_box_autoadd_u_64(dynamic raw);
|
BigInt? dco_decode_opt_box_autoadd_u_64(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
Uint8List? dco_decode_opt_list_prim_u_8_strict(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
PermissionStateKind dco_decode_permission_state_kind(dynamic raw);
|
PermissionStateKind dco_decode_permission_state_kind(dynamic raw);
|
||||||
|
|
||||||
@@ -446,6 +449,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
BigInt? sse_decode_opt_box_autoadd_u_64(SseDeserializer deserializer);
|
BigInt? sse_decode_opt_box_autoadd_u_64(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
Uint8List? sse_decode_opt_list_prim_u_8_strict(SseDeserializer deserializer);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
PermissionStateKind sse_decode_permission_state_kind(
|
PermissionStateKind sse_decode_permission_state_kind(
|
||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
@@ -748,6 +754,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer);
|
void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_opt_list_prim_u_8_strict(
|
||||||
|
Uint8List? self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_permission_state_kind(
|
void sse_encode_permission_state_kind(
|
||||||
PermissionStateKind self,
|
PermissionStateKind self,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class AudioDeviceListTile extends StatefulWidget {
|
|||||||
AudioDeviceListLoader? loadDevices,
|
AudioDeviceListLoader? loadDevices,
|
||||||
AudioDeviceSetter? setInputDevice,
|
AudioDeviceSetter? setInputDevice,
|
||||||
AudioDeviceSetter? setOutputDevice,
|
AudioDeviceSetter? setOutputDevice,
|
||||||
|
this.onDeviceChanged,
|
||||||
}) : loadDevices = loadDevices ?? rust.listAudioDevices,
|
}) : loadDevices = loadDevices ?? rust.listAudioDevices,
|
||||||
setInputDevice = setInputDevice ?? rust.setInputDevice,
|
setInputDevice = setInputDevice ?? rust.setInputDevice,
|
||||||
setOutputDevice = setOutputDevice ?? rust.setOutputDevice;
|
setOutputDevice = setOutputDevice ?? rust.setOutputDevice;
|
||||||
@@ -46,6 +47,10 @@ class AudioDeviceListTile extends StatefulWidget {
|
|||||||
/// Selects an output device.
|
/// Selects an output device.
|
||||||
final AudioDeviceSetter setOutputDevice;
|
final AudioDeviceSetter setOutputDevice;
|
||||||
|
|
||||||
|
/// Called after a device selection succeeds. Receives the device id
|
||||||
|
/// (null for system default).
|
||||||
|
final ValueChanged<String?>? onDeviceChanged;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AudioDeviceListTile> createState() => _AudioDeviceListTileState();
|
State<AudioDeviceListTile> createState() => _AudioDeviceListTileState();
|
||||||
}
|
}
|
||||||
@@ -88,6 +93,7 @@ class _AudioDeviceListTileState extends State<AudioDeviceListTile> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_selectedDeviceId = deviceId;
|
_selectedDeviceId = deviceId;
|
||||||
});
|
});
|
||||||
|
widget.onDeviceChanged?.call(deviceId);
|
||||||
|
|
||||||
final selectedName = _selectedDevice?.name ?? 'System default';
|
final selectedName = _selectedDevice?.name ?? 'System default';
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
|||||||
@@ -93,78 +93,51 @@ class AudioProcessingConfigState {
|
|||||||
isLinux: linux,
|
isLinux: linux,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
rust.BridgeAudioBackend processingBackend;
|
||||||
|
rust.BridgeEffectOwner aec;
|
||||||
|
rust.BridgeEffectOwner ns;
|
||||||
|
rust.BridgeEffectOwner agc;
|
||||||
|
|
||||||
if (android) {
|
if (android) {
|
||||||
final owner = preferHardware
|
final owner = preferHardware
|
||||||
? rust.BridgeEffectOwner.platform
|
? rust.BridgeEffectOwner.platform
|
||||||
: rust.BridgeEffectOwner.webrtcApm;
|
: rust.BridgeEffectOwner.webrtcApm;
|
||||||
return rust.BridgeAudioProcessingConfig(
|
processingBackend = preferHardware
|
||||||
route: base.route,
|
? rust.BridgeAudioBackend.platformVoiceProcessing
|
||||||
iosMode: normalizedIosProcessingMode(iosMode),
|
: rust.BridgeAudioBackend.webrtcApm;
|
||||||
processingBackend: preferHardware
|
aec = aecEnabled ? owner : rust.BridgeEffectOwner.off;
|
||||||
? rust.BridgeAudioBackend.platformVoiceProcessing
|
ns = nsEnabled ? owner : rust.BridgeEffectOwner.off;
|
||||||
: rust.BridgeAudioBackend.webrtcApm,
|
agc = agcEnabled ? owner : rust.BridgeEffectOwner.off;
|
||||||
vadBackend: vad,
|
} else if (appleVoiceProcessing) {
|
||||||
aec: aecEnabled ? owner : rust.BridgeEffectOwner.off,
|
processingBackend = rust.BridgeAudioBackend.platformVoiceProcessing;
|
||||||
ns: nsEnabled ? owner : rust.BridgeEffectOwner.off,
|
aec = rust.BridgeEffectOwner.platform;
|
||||||
agc: agcEnabled ? owner : rust.BridgeEffectOwner.off,
|
ns = rust.BridgeEffectOwner.platform;
|
||||||
hpfEnabled: hpfEnabled,
|
agc = rust.BridgeEffectOwner.platform;
|
||||||
limiterEnabled: limiterEnabled,
|
} else if (desktopWebrtcApm) {
|
||||||
vadHangoverMs: base.vadHangoverMs,
|
|
||||||
vadPreRollMs: base.vadPreRollMs,
|
|
||||||
vadMinTxMs: base.vadMinTxMs,
|
|
||||||
debugWavDumpEnabled: debugWavDump,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (appleVoiceProcessing) {
|
|
||||||
return rust.BridgeAudioProcessingConfig(
|
|
||||||
route: base.route,
|
|
||||||
iosMode: normalizedIosProcessingMode(iosMode),
|
|
||||||
processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing,
|
|
||||||
vadBackend: vad,
|
|
||||||
aec: rust.BridgeEffectOwner.platform,
|
|
||||||
ns: rust.BridgeEffectOwner.platform,
|
|
||||||
agc: rust.BridgeEffectOwner.platform,
|
|
||||||
hpfEnabled: hpfEnabled,
|
|
||||||
limiterEnabled: limiterEnabled,
|
|
||||||
vadHangoverMs: base.vadHangoverMs,
|
|
||||||
vadPreRollMs: base.vadPreRollMs,
|
|
||||||
vadMinTxMs: base.vadMinTxMs,
|
|
||||||
debugWavDumpEnabled: debugWavDump,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (desktopWebrtcApm) {
|
|
||||||
final owner = rust.BridgeEffectOwner.webrtcApm;
|
final owner = rust.BridgeEffectOwner.webrtcApm;
|
||||||
return rust.BridgeAudioProcessingConfig(
|
processingBackend = rust.BridgeAudioBackend.webrtcApm;
|
||||||
route: base.route,
|
aec = aecEnabled ? owner : rust.BridgeEffectOwner.off;
|
||||||
iosMode: normalizedIosProcessingMode(iosMode),
|
ns = nsEnabled ? owner : rust.BridgeEffectOwner.off;
|
||||||
processingBackend: rust.BridgeAudioBackend.webrtcApm,
|
agc = agcEnabled ? owner : rust.BridgeEffectOwner.off;
|
||||||
vadBackend: vad,
|
} else {
|
||||||
aec: aecEnabled ? owner : rust.BridgeEffectOwner.off,
|
processingBackend = rust.BridgeAudioBackend.platformVoiceProcessing;
|
||||||
ns: nsEnabled ? owner : rust.BridgeEffectOwner.off,
|
aec = rust.BridgeEffectOwner.platform;
|
||||||
agc: agcEnabled ? owner : rust.BridgeEffectOwner.off,
|
ns = nsEnabled
|
||||||
hpfEnabled: hpfEnabled,
|
? rust.BridgeEffectOwner.platform
|
||||||
limiterEnabled: limiterEnabled,
|
: rust.BridgeEffectOwner.off;
|
||||||
vadHangoverMs: base.vadHangoverMs,
|
agc = agcEnabled
|
||||||
vadPreRollMs: base.vadPreRollMs,
|
? rust.BridgeEffectOwner.platform
|
||||||
vadMinTxMs: base.vadMinTxMs,
|
: rust.BridgeEffectOwner.off;
|
||||||
debugWavDumpEnabled: debugWavDump,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return rust.BridgeAudioProcessingConfig(
|
return rust.BridgeAudioProcessingConfig(
|
||||||
route: base.route,
|
route: base.route,
|
||||||
iosMode: normalizedIosProcessingMode(iosMode),
|
iosMode: normalizedIosProcessingMode(iosMode),
|
||||||
processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing,
|
processingBackend: processingBackend,
|
||||||
vadBackend: vad,
|
vadBackend: vad,
|
||||||
aec: rust.BridgeEffectOwner.platform,
|
aec: aec,
|
||||||
ns: nsEnabled
|
ns: ns,
|
||||||
? rust.BridgeEffectOwner.platform
|
agc: agc,
|
||||||
: rust.BridgeEffectOwner.off,
|
|
||||||
agc: agcEnabled
|
|
||||||
? rust.BridgeEffectOwner.platform
|
|
||||||
: rust.BridgeEffectOwner.off,
|
|
||||||
hpfEnabled: hpfEnabled,
|
hpfEnabled: hpfEnabled,
|
||||||
limiterEnabled: limiterEnabled,
|
limiterEnabled: limiterEnabled,
|
||||||
vadHangoverMs: base.vadHangoverMs,
|
vadHangoverMs: base.vadHangoverMs,
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../l10n/generated/app_localizations.dart';
|
import '../l10n/generated/app_localizations.dart';
|
||||||
|
import '../services/poke_notification_service.dart';
|
||||||
import '../services/poke_preferences_service.dart';
|
import '../services/poke_preferences_service.dart';
|
||||||
import 'voice_settings_controls.dart';
|
import 'voice_settings_controls.dart';
|
||||||
|
|
||||||
class PokeNotificationSettingsDialog extends StatelessWidget {
|
class PokeNotificationSettingsDialog extends StatelessWidget {
|
||||||
const PokeNotificationSettingsDialog({super.key, required this.preferences});
|
const PokeNotificationSettingsDialog({
|
||||||
|
super.key,
|
||||||
|
required this.preferences,
|
||||||
|
required this.notificationService,
|
||||||
|
});
|
||||||
|
|
||||||
final PokePreferencesService preferences;
|
final PokePreferencesService preferences;
|
||||||
|
final PokeNotificationService notificationService;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -31,7 +37,15 @@ class PokeNotificationSettingsDialog extends StatelessWidget {
|
|||||||
title: Text(l10n.pokeSettingsEnableLabel),
|
title: Text(l10n.pokeSettingsEnableLabel),
|
||||||
subtitle: Text(l10n.pokeSettingsEnableDescription),
|
subtitle: Text(l10n.pokeSettingsEnableDescription),
|
||||||
value: enabled,
|
value: enabled,
|
||||||
onChanged: (value) => preferences.setPokesEnabled(value),
|
onChanged: (value) async {
|
||||||
|
await preferences.setPokesEnabled(value);
|
||||||
|
// Request notification permission when enabling pokes
|
||||||
|
// so the OS prompt appears immediately rather than on
|
||||||
|
// the first poke event.
|
||||||
|
if (value) {
|
||||||
|
await notificationService.requestPermission();
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(height: 24),
|
const Divider(height: 24),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../l10n/generated/app_localizations.dart';
|
import '../l10n/generated/app_localizations.dart';
|
||||||
import '../services/channel_spacer.dart';
|
import '../services/channel_spacer.dart';
|
||||||
@@ -10,7 +11,14 @@ import '../src/rust/api.dart' as rust;
|
|||||||
import 'bbcode_text.dart';
|
import 'bbcode_text.dart';
|
||||||
import 'talk_power_warning.dart';
|
import 'talk_power_warning.dart';
|
||||||
|
|
||||||
/// Connected-server snapshot with welcome text, channels, and clients.
|
/// Connected-server snapshot displaying welcome text, channel tree, and clients.
|
||||||
|
///
|
||||||
|
/// Renders the full channel hierarchy from a [BridgeSnapshot] with expandable
|
||||||
|
/// channel nodes, client voice-status indicators, unread-message badges, and
|
||||||
|
/// context menus for client actions (info, chat, poke, volume).
|
||||||
|
///
|
||||||
|
/// Channel join is triggered by tapping an unlocked channel row; password-
|
||||||
|
/// protected channels invoke [onJoinChannelWithPassword] instead.
|
||||||
class SnapshotView extends StatefulWidget {
|
class SnapshotView extends StatefulWidget {
|
||||||
/// Construct a snapshot view.
|
/// Construct a snapshot view.
|
||||||
const SnapshotView({
|
const SnapshotView({
|
||||||
@@ -571,11 +579,40 @@ class _ClientVolumePreference {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ClientVolumePreferences extends ChangeNotifier {
|
class _ClientVolumePreferences extends ChangeNotifier {
|
||||||
_ClientVolumePreferences._();
|
_ClientVolumePreferences._() {
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
static final instance = _ClientVolumePreferences._();
|
static final instance = _ClientVolumePreferences._();
|
||||||
|
|
||||||
|
static const _prefsKey = 'client_volume_prefs';
|
||||||
|
|
||||||
final Map<BigInt, _ClientVolumePreference> _byClientId = {};
|
final Map<BigInt, _ClientVolumePreference> _byClientId = {};
|
||||||
|
bool _loaded = false;
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
if (_loaded) return;
|
||||||
|
_loaded = true;
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final raw = prefs.getStringList(_prefsKey) ?? const [];
|
||||||
|
for (final entry in raw) {
|
||||||
|
final parts = entry.split(':');
|
||||||
|
if (parts.length == 3) {
|
||||||
|
final id = BigInt.tryParse(parts[0]);
|
||||||
|
final volume = double.tryParse(parts[1]);
|
||||||
|
final muted = parts[2] == '1';
|
||||||
|
if (id != null && volume != null) {
|
||||||
|
_byClientId[id] = _ClientVolumePreference(
|
||||||
|
volume: volume,
|
||||||
|
muted: muted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
_ClientVolumePreference preferenceFor(BigInt clientId) {
|
_ClientVolumePreference preferenceFor(BigInt clientId) {
|
||||||
return _byClientId[clientId] ?? const _ClientVolumePreference();
|
return _byClientId[clientId] ?? const _ClientVolumePreference();
|
||||||
@@ -588,6 +625,17 @@ class _ClientVolumePreferences extends ChangeNotifier {
|
|||||||
_byClientId.remove(clientId);
|
_byClientId.remove(clientId);
|
||||||
}
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
_save();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final raw = _byClientId.entries
|
||||||
|
.map((e) => '${e.key}:${e.value.volume}:${e.value.muted ? 1 : 0}')
|
||||||
|
.toList();
|
||||||
|
await prefs.setStringList(_prefsKey, raw);
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1108,7 +1156,7 @@ class _ClientVolumeSheetState extends State<_ClientVolumeSheet> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Context menu for channel tiles. Shows a "Chat" option on right-click or
|
/// Context menu for channel tiles offering "Chat" on right-click or
|
||||||
/// long-press. Primary tap passes through to the child for voice join.
|
/// long-press. Primary tap passes through to the child for voice join.
|
||||||
class _ChannelContextMenu extends StatelessWidget {
|
class _ChannelContextMenu extends StatelessWidget {
|
||||||
const _ChannelContextMenu({
|
const _ChannelContextMenu({
|
||||||
|
|||||||
@@ -26,21 +26,6 @@ import 'voice_settings_controls.dart';
|
|||||||
import 'voice_status_summary.dart';
|
import 'voice_status_summary.dart';
|
||||||
import '../src/rust/api.dart' as rust;
|
import '../src/rust/api.dart' as rust;
|
||||||
|
|
||||||
bool get _isIos {
|
|
||||||
if (kIsWeb) return false;
|
|
||||||
return Platform.isIOS;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get _isMacOS {
|
|
||||||
if (kIsWeb) return false;
|
|
||||||
return Platform.isMacOS;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get _isDesktopSileroVadHost {
|
|
||||||
if (kIsWeb) return false;
|
|
||||||
return Platform.isWindows || Platform.isLinux;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Two-line status chip that summarises the current voice state.
|
/// Two-line status chip that summarises the current voice state.
|
||||||
/// Tap to open the voice details modal.
|
/// Tap to open the voice details modal.
|
||||||
class VoiceStatusChip extends StatelessWidget {
|
class VoiceStatusChip extends StatelessWidget {
|
||||||
@@ -638,12 +623,19 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
|||||||
selected: _mode == rust.BridgeTransmitMode.continuous,
|
selected: _mode == rust.BridgeTransmitMode.continuous,
|
||||||
onTap: () => _setMode(rust.BridgeTransmitMode.continuous),
|
onTap: () => _setMode(rust.BridgeTransmitMode.continuous),
|
||||||
),
|
),
|
||||||
_ModeRow(
|
// Voice-activity transmit is only honoured by the engine on
|
||||||
label: l10n.voiceModeVoiceActivity,
|
// hosts that ship a Chanora-owned VAD pipeline (DEC-030:
|
||||||
icon: Icons.graphic_eq,
|
// Windows + Linux desktop and Android). iOS / macOS rely
|
||||||
selected: _mode == rust.BridgeTransmitMode.voiceActivity,
|
// on Apple VoiceProcessingIO and have no VAD bridge, so
|
||||||
onTap: () => _setMode(rust.BridgeTransmitMode.voiceActivity),
|
// hiding the row prevents the UI from advertising a
|
||||||
),
|
// transmit mode the engine cannot honour.
|
||||||
|
if (voiceActivityTransmitAvailable)
|
||||||
|
_ModeRow(
|
||||||
|
label: l10n.voiceModeVoiceActivity,
|
||||||
|
icon: Icons.graphic_eq,
|
||||||
|
selected: _mode == rust.BridgeTransmitMode.voiceActivity,
|
||||||
|
onTap: () => _setMode(rust.BridgeTransmitMode.voiceActivity),
|
||||||
|
),
|
||||||
|
|
||||||
// 3) Release-tail slider (PTT only).
|
// 3) Release-tail slider (PTT only).
|
||||||
if (isPtt) ...[
|
if (isPtt) ...[
|
||||||
@@ -731,129 +723,11 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
|
|
||||||
// Android HW/SW selector.
|
AudioProcessingPanel(
|
||||||
if (Platform.isAndroid) ...[
|
config: _audioProcessing,
|
||||||
const VoiceSubHeader('Processing backend'),
|
dense: true,
|
||||||
SegmentedButton<bool>(
|
update: (mutation) {
|
||||||
style: voiceSegmentedButtonStyle(theme),
|
setState(mutation);
|
||||||
segments: androidProcessingSegments,
|
|
||||||
selected: {_audioProcessing.preferHardware},
|
|
||||||
onSelectionChanged: (s) {
|
|
||||||
setState(() => _audioProcessing.preferHardware = s.first);
|
|
||||||
_notifyAudioConfig();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
_audioProcessing.preferHardware
|
|
||||||
? 'Hardware mode still keeps per-stage WebRTC fallback, so these controls remain effective.'
|
|
||||||
: 'Software mode applies the full WebRTC APM stage set.',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
|
|
||||||
if (_isIos) ...[
|
|
||||||
Text(
|
|
||||||
'iOS uses Apple VoiceProcessingIO. WebRTC APM controls are '
|
|
||||||
'hidden here; only settings that still affect the shipping '
|
|
||||||
'iOS path are shown.',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
],
|
|
||||||
if (!_isIos &&
|
|
||||||
(!Platform.isAndroid ||
|
|
||||||
androidShowsNsControl(_audioProcessing)))
|
|
||||||
AudioProcessingToggleRow(
|
|
||||||
dense: true,
|
|
||||||
label: 'Noise suppression',
|
|
||||||
subtitle: 'Wiener filter',
|
|
||||||
value: _audioProcessing.nsEnabled,
|
|
||||||
onChanged: (v) {
|
|
||||||
setState(() => _audioProcessing.nsEnabled = v);
|
|
||||||
_notifyAudioConfig();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (!_isIos &&
|
|
||||||
(!Platform.isAndroid ||
|
|
||||||
androidShowsAecControl(_audioProcessing)))
|
|
||||||
AudioProcessingToggleRow(
|
|
||||||
dense: true,
|
|
||||||
label: 'Echo cancellation',
|
|
||||||
subtitle: Platform.isAndroid
|
|
||||||
? (_audioProcessing.preferHardware
|
|
||||||
? 'Prefers device/OS effect; falls back to WebRTC AEC3'
|
|
||||||
: 'WebRTC AEC3 · adaptive filter')
|
|
||||||
: (_isMacOS
|
|
||||||
? 'Managed by platform VPIO'
|
|
||||||
: 'WebRTC AEC3 · adaptive filter'),
|
|
||||||
value: _isMacOS ? true : _audioProcessing.aecEnabled,
|
|
||||||
onChanged: _isMacOS
|
|
||||||
? null
|
|
||||||
: (v) {
|
|
||||||
setState(() => _audioProcessing.aecEnabled = v);
|
|
||||||
_notifyAudioConfig();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (!_isIos &&
|
|
||||||
(!Platform.isAndroid ||
|
|
||||||
androidShowsAgcControl(_audioProcessing)))
|
|
||||||
AudioProcessingToggleRow(
|
|
||||||
dense: true,
|
|
||||||
label: 'Auto gain control',
|
|
||||||
subtitle: 'AGC2 · -18 dBFS target',
|
|
||||||
value: _audioProcessing.agcEnabled,
|
|
||||||
onChanged: (v) {
|
|
||||||
setState(() => _audioProcessing.agcEnabled = v);
|
|
||||||
_notifyAudioConfig();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (!Platform.isAndroid || androidShowsHpfControl(_audioProcessing))
|
|
||||||
AudioProcessingToggleRow(
|
|
||||||
dense: true,
|
|
||||||
label: 'High-pass filter',
|
|
||||||
subtitle: '80 Hz · DC removal',
|
|
||||||
value: _audioProcessing.hpfEnabled,
|
|
||||||
onChanged: (v) {
|
|
||||||
setState(() => _audioProcessing.hpfEnabled = v);
|
|
||||||
_notifyAudioConfig();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (!_isIos &&
|
|
||||||
(!Platform.isAndroid ||
|
|
||||||
androidShowsLimiterControl(_audioProcessing)))
|
|
||||||
AudioProcessingToggleRow(
|
|
||||||
dense: true,
|
|
||||||
label: 'Peak limiter',
|
|
||||||
subtitle: '-1 dBFS soft-knee · 2 ms look-ahead',
|
|
||||||
value: _audioProcessing.limiterEnabled,
|
|
||||||
onChanged: (v) {
|
|
||||||
setState(() => _audioProcessing.limiterEnabled = v);
|
|
||||||
_notifyAudioConfig();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
|
|
||||||
// VAD backend.
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Voice activity detection (VAD)',
|
|
||||||
style: theme.textTheme.labelLarge?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
SegmentedButton<rust.BridgeVadBackend>(
|
|
||||||
style: voiceSegmentedButtonStyle(theme),
|
|
||||||
segments: _isDesktopSileroVadHost
|
|
||||||
? desktopVadBackendSegments
|
|
||||||
: vadBackendSegments,
|
|
||||||
selected: {_audioProcessing.vadBackend},
|
|
||||||
onSelectionChanged: (s) {
|
|
||||||
setState(() => _audioProcessing.vadBackend = s.first);
|
|
||||||
_notifyAudioConfig();
|
_notifyAudioConfig();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,9 +2,17 @@ import 'dart:io' show Platform;
|
|||||||
|
|
||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
|
||||||
/// True when the host is a touch-only mobile platform without a
|
// TODO(refactor): Scattered Platform.isX checks exist across ~6 Dart files.
|
||||||
/// hardware keyboard the user would bind a PTT key on.
|
// Centralize all platform checks here and update call sites to use these
|
||||||
bool get isTouchOnlyPttHost {
|
// getters instead of raw Platform.isAndroid/isIOS/etc.
|
||||||
if (kIsWeb) return false;
|
bool get _notWeb => !kIsWeb;
|
||||||
return Platform.isIOS || Platform.isAndroid;
|
|
||||||
}
|
bool get isTouchOnlyPttHost => _notWeb && (Platform.isIOS || Platform.isAndroid);
|
||||||
|
|
||||||
|
bool get isAndroidHost => _notWeb && Platform.isAndroid;
|
||||||
|
|
||||||
|
bool get isIosHost => _notWeb && Platform.isIOS;
|
||||||
|
|
||||||
|
bool get isMacOsHost => _notWeb && Platform.isMacOS;
|
||||||
|
|
||||||
|
bool get isDesktopSileroVadHost => _notWeb && (Platform.isWindows || Platform.isLinux);
|
||||||
|
|||||||
@@ -7,9 +7,6 @@
|
|||||||
// - VAD backend
|
// - VAD backend
|
||||||
// - platform audio-processing mode selection where available
|
// - platform audio-processing mode selection where available
|
||||||
|
|
||||||
import 'dart:io' show Platform;
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../l10n/generated/app_localizations.dart';
|
import '../l10n/generated/app_localizations.dart';
|
||||||
@@ -22,27 +19,8 @@ import 'voice_platform.dart';
|
|||||||
import 'voice_settings_controls.dart';
|
import 'voice_settings_controls.dart';
|
||||||
import '../src/rust/api.dart' as rust;
|
import '../src/rust/api.dart' as rust;
|
||||||
|
|
||||||
bool get _isAndroid {
|
/// Result returned by [VoiceSettingsDialog] when the user saves or
|
||||||
if (kIsWeb) return false;
|
/// requests a key bind.
|
||||||
return Platform.isAndroid;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get _isIos {
|
|
||||||
if (kIsWeb) return false;
|
|
||||||
return Platform.isIOS;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get _isMacOS {
|
|
||||||
if (kIsWeb) return false;
|
|
||||||
return Platform.isMacOS;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get _isDesktopSileroVadHost {
|
|
||||||
if (kIsWeb) return false;
|
|
||||||
return Platform.isWindows || Platform.isLinux;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Result returned by [VoiceSettingsDialog].
|
|
||||||
class VoiceSettingsResult {
|
class VoiceSettingsResult {
|
||||||
const VoiceSettingsResult({
|
const VoiceSettingsResult({
|
||||||
required this.mode,
|
required this.mode,
|
||||||
@@ -57,7 +35,15 @@ class VoiceSettingsResult {
|
|||||||
final rust.BridgeAudioProcessingConfig audioConfig;
|
final rust.BridgeAudioProcessingConfig audioConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Voice + audio processing settings dialog.
|
/// Dialog for configuring voice transmission and audio processing settings.
|
||||||
|
///
|
||||||
|
/// Surfaces transmit mode selection (continuous, PTT, voice-activity),
|
||||||
|
/// PTT release-tail slider, key-bind request, and a full audio processing
|
||||||
|
/// panel covering noise suppression, echo cancellation, AGC, HPF, and VAD
|
||||||
|
/// backend selection.
|
||||||
|
///
|
||||||
|
/// On mobile hosts, the voice-activity segment is hidden when no
|
||||||
|
/// Chanora-owned VAD pipeline is available (iOS, macOS, web).
|
||||||
class VoiceSettingsDialog extends StatefulWidget {
|
class VoiceSettingsDialog extends StatefulWidget {
|
||||||
const VoiceSettingsDialog({
|
const VoiceSettingsDialog({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -70,6 +56,8 @@ class VoiceSettingsDialog extends StatefulWidget {
|
|||||||
this.talkPower,
|
this.talkPower,
|
||||||
this.neededTalkPower,
|
this.neededTalkPower,
|
||||||
this.talkPowerGranted,
|
this.talkPowerGranted,
|
||||||
|
this.onInputDeviceChanged,
|
||||||
|
this.onOutputDeviceChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
final rust.BridgeTransmitMode initialMode;
|
final rust.BridgeTransmitMode initialMode;
|
||||||
@@ -81,6 +69,8 @@ class VoiceSettingsDialog extends StatefulWidget {
|
|||||||
final int? talkPower;
|
final int? talkPower;
|
||||||
final int? neededTalkPower;
|
final int? neededTalkPower;
|
||||||
final bool? talkPowerGranted;
|
final bool? talkPowerGranted;
|
||||||
|
final ValueChanged<String?>? onInputDeviceChanged;
|
||||||
|
final ValueChanged<String?>? onOutputDeviceChanged;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<VoiceSettingsDialog> createState() => _VoiceSettingsDialogState();
|
State<VoiceSettingsDialog> createState() => _VoiceSettingsDialogState();
|
||||||
@@ -106,7 +96,7 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
|||||||
rust.BridgeAudioProcessingConfig _buildConfig() {
|
rust.BridgeAudioProcessingConfig _buildConfig() {
|
||||||
return _audioProcessing.buildConfig(
|
return _audioProcessing.buildConfig(
|
||||||
base: widget.initialAudioConfig,
|
base: widget.initialAudioConfig,
|
||||||
isAndroid: _isAndroid,
|
isAndroid: isAndroidHost,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +118,12 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
|||||||
VoiceSectionHeader(l10n.voiceModeLabel),
|
VoiceSectionHeader(l10n.voiceModeLabel),
|
||||||
SegmentedButton<rust.BridgeTransmitMode>(
|
SegmentedButton<rust.BridgeTransmitMode>(
|
||||||
style: voiceSegmentedButtonStyle(theme),
|
style: voiceSegmentedButtonStyle(theme),
|
||||||
segments: transmitModeSegments,
|
// DEC-030: hide the voice-activity segment on hosts
|
||||||
|
// that ship no Chanora-owned VAD pipeline (iOS,
|
||||||
|
// macOS, web).
|
||||||
|
segments: transmitModeSegmentsFor(
|
||||||
|
voiceActivityAvailable: voiceActivityTransmitAvailable,
|
||||||
|
),
|
||||||
selected: {_mode},
|
selected: {_mode},
|
||||||
onSelectionChanged: (s) => setState(() => _mode = s.first),
|
onSelectionChanged: (s) => setState(() => _mode = s.first),
|
||||||
),
|
),
|
||||||
@@ -184,92 +179,10 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
|||||||
const Divider(height: 24),
|
const Divider(height: 24),
|
||||||
const VoiceSectionHeader('Audio processing'),
|
const VoiceSectionHeader('Audio processing'),
|
||||||
|
|
||||||
// Android HW/SW selector
|
AudioProcessingPanel(
|
||||||
if (_isAndroid) ...[
|
config: _audioProcessing,
|
||||||
const VoiceSubHeader('Processing backend'),
|
update: (mutation) => setState(mutation),
|
||||||
SegmentedButton<bool>(
|
),
|
||||||
style: voiceSegmentedButtonStyle(theme),
|
|
||||||
segments: androidProcessingSegments,
|
|
||||||
selected: {_audioProcessing.preferHardware},
|
|
||||||
onSelectionChanged: (s) =>
|
|
||||||
setState(() => _audioProcessing.preferHardware = s.first),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
_audioProcessing.preferHardware
|
|
||||||
? 'Android hardware mode still falls back to WebRTC APM per stage when device effects are missing, so these controls remain available.'
|
|
||||||
: 'Android software mode applies the full WebRTC APM control set.',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
],
|
|
||||||
|
|
||||||
// DSP toggles
|
|
||||||
const VoiceSubHeader('DSP stages'),
|
|
||||||
if (_isIos) ...[
|
|
||||||
Text(
|
|
||||||
'iOS uses Apple VoiceProcessingIO. WebRTC APM controls are '
|
|
||||||
'hidden here; only settings that still affect the shipping '
|
|
||||||
'iOS path are shown.',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
],
|
|
||||||
if (!_isIos &&
|
|
||||||
(!_isAndroid || androidShowsNsControl(_audioProcessing)))
|
|
||||||
AudioProcessingToggleRow(
|
|
||||||
label: 'Noise suppression (NS)',
|
|
||||||
subtitle: 'Wiener filter · stationary noise',
|
|
||||||
value: _audioProcessing.nsEnabled,
|
|
||||||
onChanged: (v) =>
|
|
||||||
setState(() => _audioProcessing.nsEnabled = v),
|
|
||||||
),
|
|
||||||
if (!_isIos &&
|
|
||||||
(!_isAndroid || androidShowsAecControl(_audioProcessing)))
|
|
||||||
AudioProcessingToggleRow(
|
|
||||||
label: 'Echo cancellation (AEC3)',
|
|
||||||
subtitle: _isAndroid
|
|
||||||
? (_audioProcessing.preferHardware
|
|
||||||
? 'Prefers device/OS effect; WebRTC AEC3 fallback when binding is unavailable'
|
|
||||||
: 'WebRTC AEC3 · adaptive filter')
|
|
||||||
: (_isMacOS
|
|
||||||
? 'Managed by platform VPIO'
|
|
||||||
: 'WebRTC AEC3 · adaptive filter'),
|
|
||||||
value: _isMacOS ? true : _audioProcessing.aecEnabled,
|
|
||||||
onChanged: _isMacOS
|
|
||||||
? null
|
|
||||||
: (v) => setState(() => _audioProcessing.aecEnabled = v),
|
|
||||||
),
|
|
||||||
if (!_isIos &&
|
|
||||||
(!_isAndroid || androidShowsAgcControl(_audioProcessing)))
|
|
||||||
AudioProcessingToggleRow(
|
|
||||||
label: 'Auto gain control (AGC2)',
|
|
||||||
subtitle: 'RNN VAD-gated · -18 dBFS target',
|
|
||||||
value: _audioProcessing.agcEnabled,
|
|
||||||
onChanged: (v) =>
|
|
||||||
setState(() => _audioProcessing.agcEnabled = v),
|
|
||||||
),
|
|
||||||
if (!_isAndroid || androidShowsHpfControl(_audioProcessing))
|
|
||||||
AudioProcessingToggleRow(
|
|
||||||
label: 'High-pass filter (HPF)',
|
|
||||||
subtitle: '80 Hz Butterworth · DC removal',
|
|
||||||
value: _audioProcessing.hpfEnabled,
|
|
||||||
onChanged: (v) =>
|
|
||||||
setState(() => _audioProcessing.hpfEnabled = v),
|
|
||||||
),
|
|
||||||
if (!_isIos &&
|
|
||||||
(!_isAndroid || androidShowsLimiterControl(_audioProcessing)))
|
|
||||||
AudioProcessingToggleRow(
|
|
||||||
label: 'Peak limiter',
|
|
||||||
subtitle: '-1 dBFS soft-knee · 2 ms look-ahead',
|
|
||||||
value: _audioProcessing.limiterEnabled,
|
|
||||||
onChanged: (v) =>
|
|
||||||
setState(() => _audioProcessing.limiterEnabled = v),
|
|
||||||
),
|
|
||||||
|
|
||||||
if (isTalkPowerBlocked(
|
if (isTalkPowerBlocked(
|
||||||
talkPower: widget.talkPower,
|
talkPower: widget.talkPower,
|
||||||
@@ -284,22 +197,6 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
// ── VAD ────────────────────────────────────────────────
|
|
||||||
const Divider(height: 24),
|
|
||||||
const VoiceSectionHeader('Voice activity detection (VAD)'),
|
|
||||||
|
|
||||||
const VoiceSubHeader('Backend'),
|
|
||||||
SegmentedButton<rust.BridgeVadBackend>(
|
|
||||||
style: voiceSegmentedButtonStyle(theme),
|
|
||||||
segments: _isDesktopSileroVadHost
|
|
||||||
? desktopVadBackendSegments
|
|
||||||
: vadBackendSegments,
|
|
||||||
selected: {_audioProcessing.vadBackend},
|
|
||||||
onSelectionChanged: (s) =>
|
|
||||||
setState(() => _audioProcessing.vadBackend = s.first),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
|
|
||||||
// ── PTT capability badge ────────────────────────────────
|
// ── PTT capability badge ────────────────────────────────
|
||||||
if (_mode == rust.BridgeTransmitMode.ptt &&
|
if (_mode == rust.BridgeTransmitMode.ptt &&
|
||||||
widget.pttLevel.isNotEmpty) ...[
|
widget.pttLevel.isNotEmpty) ...[
|
||||||
@@ -313,23 +210,25 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
|||||||
],
|
],
|
||||||
|
|
||||||
// ── Audio output route picker (mobile only) ─────────────
|
// ── Audio output route picker (mobile only) ─────────────
|
||||||
if (_isAndroid || _isIos) ...[
|
if (isAndroidHost || isIosHost) ...[
|
||||||
const Divider(height: 24),
|
const Divider(height: 24),
|
||||||
const VoiceSectionHeader('Audio output'),
|
const VoiceSectionHeader('Audio output'),
|
||||||
const AudioOutputTile(),
|
const AudioOutputTile(),
|
||||||
],
|
],
|
||||||
|
|
||||||
// ── Audio devices (desktop only, SRS-026) ──────────────
|
// ── Audio devices (desktop only, SRS-026) ──────────────
|
||||||
if (!_isAndroid && !_isIos) ...[
|
if (!isAndroidHost && !isIosHost) ...[
|
||||||
const Divider(height: 24),
|
const Divider(height: 24),
|
||||||
const VoiceSectionHeader('Audio devices'),
|
const VoiceSectionHeader('Audio devices'),
|
||||||
const AudioDeviceListTile(
|
AudioDeviceListTile(
|
||||||
label: 'Input',
|
label: 'Input',
|
||||||
kind: AudioDeviceKind.input,
|
kind: AudioDeviceKind.input,
|
||||||
|
onDeviceChanged: widget.onInputDeviceChanged,
|
||||||
),
|
),
|
||||||
const AudioDeviceListTile(
|
AudioDeviceListTile(
|
||||||
label: 'Output',
|
label: 'Output',
|
||||||
kind: AudioDeviceKind.output,
|
kind: AudioDeviceKind.output,
|
||||||
|
onDeviceChanged: widget.onOutputDeviceChanged,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'audio_processing_config_state.dart';
|
||||||
|
import 'voice_platform.dart';
|
||||||
import '../src/rust/api.dart' as rust;
|
import '../src/rust/api.dart' as rust;
|
||||||
|
|
||||||
/// Shared compact style for voice settings segmented buttons.
|
/// Shared compact style for voice settings segmented buttons.
|
||||||
@@ -10,7 +15,11 @@ ButtonStyle voiceSegmentedButtonStyle(ThemeData theme) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transmit mode selector segments.
|
/// Transmit mode selector segments — full set, all three modes.
|
||||||
|
///
|
||||||
|
/// This list is kept stable for legacy call sites and tests; UI
|
||||||
|
/// surfaces that must respect DEC-030 platform gating should prefer
|
||||||
|
/// [transmitModeSegmentsFor] with [voiceActivityTransmitAvailable].
|
||||||
const transmitModeSegments = [
|
const transmitModeSegments = [
|
||||||
ButtonSegment(
|
ButtonSegment(
|
||||||
value: rust.BridgeTransmitMode.ptt,
|
value: rust.BridgeTransmitMode.ptt,
|
||||||
@@ -29,6 +38,43 @@ const transmitModeSegments = [
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Transmit mode selector segments, optionally dropping the
|
||||||
|
/// voice-activity entry on hosts that do not ship a VAD pipeline.
|
||||||
|
///
|
||||||
|
/// Voice activity transmit is gated by [voiceActivityTransmitAvailable]
|
||||||
|
/// because the underlying VAD pipeline ships only on Windows, Linux, and
|
||||||
|
/// Android per DEC-030. Builds for unsupported platforms (iOS, macOS,
|
||||||
|
/// web) drop the VAD segment entirely so the UI never advertises a
|
||||||
|
/// transmit mode the engine cannot honour.
|
||||||
|
List<ButtonSegment<rust.BridgeTransmitMode>> transmitModeSegmentsFor({
|
||||||
|
required bool voiceActivityAvailable,
|
||||||
|
}) {
|
||||||
|
if (voiceActivityAvailable) return transmitModeSegments;
|
||||||
|
return const [
|
||||||
|
ButtonSegment(
|
||||||
|
value: rust.BridgeTransmitMode.ptt,
|
||||||
|
label: Text('PTT'),
|
||||||
|
icon: Icon(Icons.radio_button_checked, size: 14),
|
||||||
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: rust.BridgeTransmitMode.continuous,
|
||||||
|
label: Text('Always'),
|
||||||
|
icon: Icon(Icons.podcasts, size: 14),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when this host advertises VAD transmit per DEC-030.
|
||||||
|
///
|
||||||
|
/// The desktop Silero ONNX + WebRTC fallback ships on Windows and
|
||||||
|
/// Linux; the Android Oboe + WebRTC path covers Android; iOS uses the
|
||||||
|
/// Apple CoreML Silero VAD pipeline via `vad::apple_coreml`. macOS is
|
||||||
|
/// still gated until its VAD pipeline is confirmed.
|
||||||
|
bool get voiceActivityTransmitAvailable {
|
||||||
|
if (kIsWeb) return false;
|
||||||
|
return Platform.isWindows || Platform.isLinux || Platform.isAndroid || Platform.isIOS;
|
||||||
|
}
|
||||||
|
|
||||||
/// Android hardware/WebRTC selector segments.
|
/// Android hardware/WebRTC selector segments.
|
||||||
const androidProcessingSegments = [
|
const androidProcessingSegments = [
|
||||||
ButtonSegment(
|
ButtonSegment(
|
||||||
@@ -186,3 +232,173 @@ class AudioProcessingToggleRow extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
typedef AudioFieldUpdater = void Function(VoidCallback mutation);
|
||||||
|
|
||||||
|
class AudioProcessingPanel extends StatelessWidget {
|
||||||
|
const AudioProcessingPanel({
|
||||||
|
super.key,
|
||||||
|
required this.config,
|
||||||
|
required this.update,
|
||||||
|
this.dense = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final AudioProcessingConfigState config;
|
||||||
|
final AudioFieldUpdater update;
|
||||||
|
final bool dense;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final isAndroid = isAndroidHost;
|
||||||
|
final isIos = isIosHost;
|
||||||
|
final isMacOS = isMacOsHost;
|
||||||
|
final isDesktopSilero = isDesktopSileroVadHost;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (isAndroid) ...[
|
||||||
|
if (!dense) const VoiceSubHeader('Processing backend'),
|
||||||
|
if (dense) _compactLabel(context, 'Processing backend'),
|
||||||
|
SegmentedButton<bool>(
|
||||||
|
style: voiceSegmentedButtonStyle(theme),
|
||||||
|
segments: androidProcessingSegments,
|
||||||
|
selected: {config.preferHardware},
|
||||||
|
onSelectionChanged: (s) {
|
||||||
|
update(() => config.preferHardware = s.first);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
config.preferHardware
|
||||||
|
? (dense
|
||||||
|
? 'Hardware mode still keeps per-stage WebRTC fallback, so these controls remain effective.'
|
||||||
|
: 'Android hardware mode still falls back to WebRTC APM per stage when device effects are missing, so these controls remain available.')
|
||||||
|
: (dense
|
||||||
|
? 'Software mode applies the full WebRTC APM stage set.'
|
||||||
|
: 'Android software mode applies the full WebRTC APM control set.'),
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!dense) const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
|
||||||
|
if (!dense) const VoiceSubHeader('DSP stages'),
|
||||||
|
|
||||||
|
if (isIos) ...[
|
||||||
|
Text(
|
||||||
|
'iOS uses Apple VoiceProcessingIO. WebRTC APM controls are '
|
||||||
|
'hidden here; only settings that still affect the shipping '
|
||||||
|
'iOS path are shown.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
],
|
||||||
|
|
||||||
|
if (!isIos &&
|
||||||
|
(!isAndroid || androidShowsNsControl(config)))
|
||||||
|
AudioProcessingToggleRow(
|
||||||
|
dense: dense,
|
||||||
|
label: dense ? 'Noise suppression' : 'Noise suppression (NS)',
|
||||||
|
subtitle: dense
|
||||||
|
? 'Wiener filter'
|
||||||
|
: 'Wiener filter · stationary noise',
|
||||||
|
value: config.nsEnabled,
|
||||||
|
onChanged: (v) => update(() => config.nsEnabled = v),
|
||||||
|
),
|
||||||
|
if (!isIos &&
|
||||||
|
(!isAndroid || androidShowsAecControl(config)))
|
||||||
|
AudioProcessingToggleRow(
|
||||||
|
dense: dense,
|
||||||
|
label: dense ? 'Echo cancellation' : 'Echo cancellation (AEC3)',
|
||||||
|
subtitle: _aecSubtitle(isAndroid, isMacOS, config),
|
||||||
|
value: isMacOS ? true : config.aecEnabled,
|
||||||
|
onChanged:
|
||||||
|
isMacOS ? null : (v) => update(() => config.aecEnabled = v),
|
||||||
|
),
|
||||||
|
if (!isIos &&
|
||||||
|
(!isAndroid || androidShowsAgcControl(config)))
|
||||||
|
AudioProcessingToggleRow(
|
||||||
|
dense: dense,
|
||||||
|
label: dense ? 'Auto gain control' : 'Auto gain control (AGC2)',
|
||||||
|
subtitle: dense
|
||||||
|
? 'AGC2 · -18 dBFS target'
|
||||||
|
: 'RNN VAD-gated · -18 dBFS target',
|
||||||
|
value: config.agcEnabled,
|
||||||
|
onChanged: (v) => update(() => config.agcEnabled = v),
|
||||||
|
),
|
||||||
|
if (!isAndroid || androidShowsHpfControl(config))
|
||||||
|
AudioProcessingToggleRow(
|
||||||
|
dense: dense,
|
||||||
|
label: dense ? 'High-pass filter' : 'High-pass filter (HPF)',
|
||||||
|
subtitle: dense
|
||||||
|
? '80 Hz · DC removal'
|
||||||
|
: '80 Hz Butterworth · DC removal',
|
||||||
|
value: config.hpfEnabled,
|
||||||
|
onChanged: (v) => update(() => config.hpfEnabled = v),
|
||||||
|
),
|
||||||
|
if (!isIos &&
|
||||||
|
(!isAndroid || androidShowsLimiterControl(config)))
|
||||||
|
AudioProcessingToggleRow(
|
||||||
|
dense: dense,
|
||||||
|
label: 'Peak limiter',
|
||||||
|
subtitle: '-1 dBFS soft-knee · 2 ms look-ahead',
|
||||||
|
value: config.limiterEnabled,
|
||||||
|
onChanged: (v) => update(() => config.limiterEnabled = v),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (!dense) ...[
|
||||||
|
const Divider(height: 24),
|
||||||
|
const VoiceSectionHeader('Voice activity detection (VAD)'),
|
||||||
|
const VoiceSubHeader('Backend'),
|
||||||
|
] else ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Voice activity detection (VAD)',
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
],
|
||||||
|
SegmentedButton<rust.BridgeVadBackend>(
|
||||||
|
style: voiceSegmentedButtonStyle(theme),
|
||||||
|
segments:
|
||||||
|
isDesktopSilero ? desktopVadBackendSegments : vadBackendSegments,
|
||||||
|
selected: {config.vadBackend},
|
||||||
|
onSelectionChanged: (s) {
|
||||||
|
update(() => config.vadBackend = s.first);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (!dense) const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _aecSubtitle(bool isAndroid, bool isMacOS, AudioProcessingConfigState c) {
|
||||||
|
if (isAndroid) {
|
||||||
|
return c.preferHardware
|
||||||
|
? 'Prefers device/OS effect; falls back to WebRTC AEC3'
|
||||||
|
: 'WebRTC AEC3 · adaptive filter';
|
||||||
|
}
|
||||||
|
if (isMacOS) return 'Managed by platform VPIO';
|
||||||
|
return 'WebRTC AEC3 · adaptive filter';
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _compactLabel(BuildContext context, String text) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 2),
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,18 +5,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: _fe_analyzer_shared
|
name: _fe_analyzer_shared
|
||||||
sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d"
|
sha256: "3b19a47f6ea7c2632760777c78174f47f6aec1e05f0cd611380d4593b8af1dbc"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "93.0.0"
|
version: "96.0.0"
|
||||||
analyzer:
|
analyzer:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: analyzer
|
name: analyzer
|
||||||
sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b
|
sha256: "0c516bc4ad36a1a75759e54d5047cb9d15cded4459df01aa35a0b5ec7db2c2a0"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.0.1"
|
version: "10.2.0"
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -133,10 +133,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: code_assets
|
name: code_assets
|
||||||
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.2.1"
|
||||||
collection:
|
collection:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -197,10 +197,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: dbus
|
name: dbus
|
||||||
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.12"
|
version: "0.7.13"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -361,18 +361,18 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: haptic_kit
|
name: haptic_kit
|
||||||
sha256: "39efffa513c9f8ce3cdded8a4423797f69d71c9281779b83727337f3ee1ed9b8"
|
sha256: "457f825a3413be2651954639bed27bb2987570f75d90c4e8e1cb9be62db2e59d"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.0.1"
|
||||||
hooks:
|
hooks:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: hooks
|
name: hooks
|
||||||
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
|
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.3"
|
version: "2.0.2"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -509,14 +509,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.0"
|
||||||
native_toolchain_c:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: native_toolchain_c
|
|
||||||
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.17.6"
|
|
||||||
nm:
|
nm:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -529,10 +521,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: objective_c
|
name: objective_c
|
||||||
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.3.0"
|
version: "9.4.1"
|
||||||
package_config:
|
package_config:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -705,10 +697,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shared_preferences_android
|
name: shared_preferences_android
|
||||||
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
|
sha256: a2c49fc1fed7140cadd892d765bd47edbe4ac0b9c7e7e3c493dcb58126f99cf0
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.23"
|
version: "2.4.25"
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -862,10 +854,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_android
|
name: url_launcher_android
|
||||||
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
|
sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.30"
|
version: "6.3.32"
|
||||||
url_launcher_ios:
|
url_launcher_ios:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -974,10 +966,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: win32
|
name: win32
|
||||||
sha256: a1fc9eb9248baa05dfc12ed5b66e377b3e23f095eec078e0371622b9033810d9
|
sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.2.0"
|
version: "6.3.0"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -990,10 +982,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: xml
|
name: xml
|
||||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.6.1"
|
version: "7.0.1"
|
||||||
yaml:
|
yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1003,5 +995,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.11.5 <4.0.0"
|
dart: ">=3.12.0 <4.0.0"
|
||||||
flutter: ">=3.38.4"
|
flutter: ">=3.44.0"
|
||||||
|
|||||||
@@ -31,6 +31,18 @@ if [ ! -f "${BINARY}" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Flutter Debug builds split user code into a sibling `<App>.debug.dylib`
|
||||||
|
# alongside a tiny launcher executable; the @_cdecl symbols live in the
|
||||||
|
# dylib. Release/Profile builds put everything in the main executable.
|
||||||
|
# Prefer the dylib when both exist so the check verifies the slice that
|
||||||
|
# actually carries the symbols.
|
||||||
|
EXECUTABLE_DIR=$(dirname "${BINARY}")
|
||||||
|
EXECUTABLE_NAME=$(basename "${BINARY}")
|
||||||
|
DEBUG_DYLIB="${EXECUTABLE_DIR}/${EXECUTABLE_NAME}.debug.dylib"
|
||||||
|
if [ -f "${DEBUG_DYLIB}" ]; then
|
||||||
|
BINARY="${DEBUG_DYLIB}"
|
||||||
|
fi
|
||||||
|
|
||||||
REQUIRED_SYMBOLS="
|
REQUIRED_SYMBOLS="
|
||||||
_chanora_silero_vad_create
|
_chanora_silero_vad_create
|
||||||
_chanora_silero_vad_destroy
|
_chanora_silero_vad_destroy
|
||||||
|
|||||||
@@ -67,4 +67,44 @@ void main() {
|
|||||||
|
|
||||||
expect(settings.themeMode, UiThemeMode.system);
|
expect(settings.themeMode, UiThemeMode.system);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('loads null voice settings when unset', () async {
|
||||||
|
final settings = await service.loadSettings();
|
||||||
|
|
||||||
|
expect(settings.transmitModeIndex, isNull);
|
||||||
|
expect(settings.releaseTailMs, isNull);
|
||||||
|
expect(settings.inputDeviceId, isNull);
|
||||||
|
expect(settings.outputDeviceId, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saves and loads transmit mode index', () async {
|
||||||
|
await service.saveTransmitModeIndex(1);
|
||||||
|
final settings = await service.loadSettings();
|
||||||
|
|
||||||
|
expect(settings.transmitModeIndex, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saves and loads release tail ms', () async {
|
||||||
|
await service.saveReleaseTailMs(300);
|
||||||
|
final settings = await service.loadSettings();
|
||||||
|
|
||||||
|
expect(settings.releaseTailMs, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saves and loads audio device ids', () async {
|
||||||
|
await service.saveInputDeviceId('input-123');
|
||||||
|
await service.saveOutputDeviceId('output-456');
|
||||||
|
final settings = await service.loadSettings();
|
||||||
|
|
||||||
|
expect(settings.inputDeviceId, 'input-123');
|
||||||
|
expect(settings.outputDeviceId, 'output-456');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removes audio device id when set to null', () async {
|
||||||
|
await service.saveInputDeviceId('input-123');
|
||||||
|
await service.saveInputDeviceId(null);
|
||||||
|
final settings = await service.loadSettings();
|
||||||
|
|
||||||
|
expect(settings.inputDeviceId, isNull);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:chanora_flutter/services/voice_join_ordering.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('joinVoiceChannelWithIosAudioSession', () {
|
||||||
|
test('activates the iOS audio session before Rust voiceJoin', () async {
|
||||||
|
final calls = <String>[];
|
||||||
|
|
||||||
|
await joinVoiceChannelWithIosAudioSession(
|
||||||
|
channelId: BigInt.from(42),
|
||||||
|
password: 'secret',
|
||||||
|
activateIosAudioSession: () async {
|
||||||
|
calls.add('activateIosAudioSession');
|
||||||
|
},
|
||||||
|
deactivateIosAudioSession: () async {
|
||||||
|
calls.add('deactivateIosAudioSession');
|
||||||
|
},
|
||||||
|
voiceJoin: ({required channelId, required password}) async {
|
||||||
|
expect(channelId, BigInt.from(42));
|
||||||
|
expect(password, 'secret');
|
||||||
|
calls.add('voiceJoin');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(calls, ['activateIosAudioSession', 'voiceJoin']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deactivates the iOS audio session when Rust voiceJoin fails',
|
||||||
|
() async {
|
||||||
|
final calls = <String>[];
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
joinVoiceChannelWithIosAudioSession(
|
||||||
|
channelId: BigInt.from(42),
|
||||||
|
password: '',
|
||||||
|
activateIosAudioSession: () async {
|
||||||
|
calls.add('activateIosAudioSession');
|
||||||
|
},
|
||||||
|
deactivateIosAudioSession: () async {
|
||||||
|
calls.add('deactivateIosAudioSession');
|
||||||
|
},
|
||||||
|
voiceJoin: ({required channelId, required password}) async {
|
||||||
|
calls.add('voiceJoin');
|
||||||
|
throw StateError('join rejected');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
throwsStateError,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(calls, [
|
||||||
|
'activateIosAudioSession',
|
||||||
|
'voiceJoin',
|
||||||
|
'deactivateIosAudioSession',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'keeps the iOS audio session active when voiceJoin throws but the '
|
||||||
|
'error is recognised as already-in-channel (treated as success); '
|
||||||
|
'still rethrows so the caller runs its success-on-already-joined branch',
|
||||||
|
() async {
|
||||||
|
final calls = <String>[];
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
joinVoiceChannelWithIosAudioSession(
|
||||||
|
channelId: BigInt.from(42),
|
||||||
|
password: '',
|
||||||
|
activateIosAudioSession: () async {
|
||||||
|
calls.add('activateIosAudioSession');
|
||||||
|
},
|
||||||
|
deactivateIosAudioSession: () async {
|
||||||
|
calls.add('deactivateIosAudioSession');
|
||||||
|
},
|
||||||
|
voiceJoin: ({required channelId, required password}) async {
|
||||||
|
calls.add('voiceJoin');
|
||||||
|
throw _FakeAlreadyInChannel();
|
||||||
|
},
|
||||||
|
isJoinSuccess: (error) => error is _FakeAlreadyInChannel,
|
||||||
|
),
|
||||||
|
throwsA(isA<_FakeAlreadyInChannel>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(calls, ['activateIosAudioSession', 'voiceJoin']);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'deactivates the iOS audio session when isJoinSuccess returns false '
|
||||||
|
'for a non-success error',
|
||||||
|
() async {
|
||||||
|
final calls = <String>[];
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
joinVoiceChannelWithIosAudioSession(
|
||||||
|
channelId: BigInt.from(42),
|
||||||
|
password: '',
|
||||||
|
activateIosAudioSession: () async {
|
||||||
|
calls.add('activateIosAudioSession');
|
||||||
|
},
|
||||||
|
deactivateIosAudioSession: () async {
|
||||||
|
calls.add('deactivateIosAudioSession');
|
||||||
|
},
|
||||||
|
voiceJoin: ({required channelId, required password}) async {
|
||||||
|
calls.add('voiceJoin');
|
||||||
|
throw StateError('join rejected');
|
||||||
|
},
|
||||||
|
isJoinSuccess: (error) => error is _FakeAlreadyInChannel,
|
||||||
|
),
|
||||||
|
throwsStateError,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(calls, [
|
||||||
|
'activateIosAudioSession',
|
||||||
|
'voiceJoin',
|
||||||
|
'deactivateIosAudioSession',
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeAlreadyInChannel implements Exception {}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
|
||||||
|
import 'package:chanora_flutter/src/rust/api.dart' as rust;
|
||||||
|
import 'package:chanora_flutter/widgets/connect_widgets.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('ConnectForm', () {
|
||||||
|
late TextEditingController hostCtl;
|
||||||
|
late TextEditingController nickCtl;
|
||||||
|
late TextEditingController passwordCtl;
|
||||||
|
|
||||||
|
Widget buildForm({
|
||||||
|
VoidCallback? onConnect,
|
||||||
|
VoidCallback? onAddBookmark,
|
||||||
|
}) {
|
||||||
|
return MaterialApp(
|
||||||
|
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||||
|
supportedLocales: AppL10n.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: ConnectForm(
|
||||||
|
hostCtl: hostCtl,
|
||||||
|
nickCtl: nickCtl,
|
||||||
|
passwordCtl: passwordCtl,
|
||||||
|
onConnect: onConnect ?? () {},
|
||||||
|
onAddBookmark: onAddBookmark ?? () {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
hostCtl = TextEditingController();
|
||||||
|
nickCtl = TextEditingController();
|
||||||
|
passwordCtl = TextEditingController();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
hostCtl.dispose();
|
||||||
|
nickCtl.dispose();
|
||||||
|
passwordCtl.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders all three text fields', (tester) async {
|
||||||
|
await tester.pumpWidget(buildForm());
|
||||||
|
|
||||||
|
expect(find.byType(TextField), findsNWidgets(3));
|
||||||
|
expect(find.byIcon(Icons.dns_outlined), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.login), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.bookmark_add_outlined), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('connect callback fires on button tap', (tester) async {
|
||||||
|
var connected = false;
|
||||||
|
await tester.pumpWidget(buildForm(
|
||||||
|
onConnect: () => connected = true,
|
||||||
|
));
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.login));
|
||||||
|
expect(connected, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('bookmark callback fires on button tap', (tester) async {
|
||||||
|
var bookmarked = false;
|
||||||
|
await tester.pumpWidget(buildForm(
|
||||||
|
onAddBookmark: () => bookmarked = true,
|
||||||
|
));
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.bookmark_add_outlined));
|
||||||
|
expect(bookmarked, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('host field lowercases and strips whitespace', (tester) async {
|
||||||
|
await tester.pumpWidget(buildForm());
|
||||||
|
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextField, 'host[:port]'),
|
||||||
|
' MyServer.COM ',
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(hostCtl.text, 'myserver.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('password field is obscured', (tester) async {
|
||||||
|
await tester.pumpWidget(buildForm());
|
||||||
|
|
||||||
|
final passwordField = tester.widgetList<TextField>(
|
||||||
|
find.byType(TextField),
|
||||||
|
).last;
|
||||||
|
expect(passwordField.obscureText, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('form uses outlined border decoration', (tester) async {
|
||||||
|
await tester.pumpWidget(buildForm());
|
||||||
|
|
||||||
|
final fields = tester.widgetList<TextField>(find.byType(TextField));
|
||||||
|
for (final field in fields) {
|
||||||
|
final decoration = field.decoration as InputDecoration;
|
||||||
|
expect(decoration.border, isA<OutlineInputBorder>());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('BookmarkList', () {
|
||||||
|
Widget buildBookmarkList({
|
||||||
|
required List<rust.BridgeBookmark> bookmarks,
|
||||||
|
ValueChanged<rust.BridgeBookmark>? onConnect,
|
||||||
|
ValueChanged<rust.BridgeBookmark>? onDelete,
|
||||||
|
}) {
|
||||||
|
return MaterialApp(
|
||||||
|
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||||
|
supportedLocales: AppL10n.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
child: BookmarkList(
|
||||||
|
bookmarks: bookmarks,
|
||||||
|
onConnect: onConnect ?? (_) {},
|
||||||
|
onDelete: onDelete ?? (_) {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const testBookmark = rust.BridgeBookmark(
|
||||||
|
id: 1,
|
||||||
|
displayName: 'My Server',
|
||||||
|
host: 'ts.example.com',
|
||||||
|
nickname: 'TestUser',
|
||||||
|
password: '',
|
||||||
|
);
|
||||||
|
|
||||||
|
const testBookmark2 = rust.BridgeBookmark(
|
||||||
|
id: 2,
|
||||||
|
displayName: 'Work Server',
|
||||||
|
host: 'work.ts.com',
|
||||||
|
nickname: 'WorkNick',
|
||||||
|
password: 'secret',
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('shows empty message when no bookmarks', (tester) async {
|
||||||
|
await tester.pumpWidget(buildBookmarkList(bookmarks: const []));
|
||||||
|
|
||||||
|
expect(find.byType(BookmarkList), findsOneWidget);
|
||||||
|
expect(find.byType(Card), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders bookmark cards with name and host', (tester) async {
|
||||||
|
await tester.pumpWidget(buildBookmarkList(
|
||||||
|
bookmarks: const [testBookmark],
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(find.text('My Server'), findsOneWidget);
|
||||||
|
expect(find.text('ts.example.com — TestUser'), findsOneWidget);
|
||||||
|
expect(find.byType(Card), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders multiple bookmarks', (tester) async {
|
||||||
|
await tester.pumpWidget(buildBookmarkList(
|
||||||
|
bookmarks: const [testBookmark, testBookmark2],
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(find.text('My Server'), findsOneWidget);
|
||||||
|
expect(find.text('Work Server'), findsOneWidget);
|
||||||
|
expect(find.byType(Card), findsNWidgets(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('connect callback fires with correct bookmark', (tester) async {
|
||||||
|
rust.BridgeBookmark? connectedBookmark;
|
||||||
|
await tester.pumpWidget(buildBookmarkList(
|
||||||
|
bookmarks: const [testBookmark],
|
||||||
|
onConnect: (b) => connectedBookmark = b,
|
||||||
|
));
|
||||||
|
|
||||||
|
final connectButtons = find.byIcon(Icons.login);
|
||||||
|
await tester.tap(connectButtons.first);
|
||||||
|
|
||||||
|
expect(connectedBookmark, testBookmark);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('delete callback fires with correct bookmark', (tester) async {
|
||||||
|
rust.BridgeBookmark? deletedBookmark;
|
||||||
|
await tester.pumpWidget(buildBookmarkList(
|
||||||
|
bookmarks: const [testBookmark],
|
||||||
|
onDelete: (b) => deletedBookmark = b,
|
||||||
|
));
|
||||||
|
|
||||||
|
final deleteButtons = find.byIcon(Icons.delete_outline);
|
||||||
|
await tester.tap(deleteButtons.first);
|
||||||
|
|
||||||
|
expect(deletedBookmark, testBookmark);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('each bookmark card has connect and delete buttons', (tester) async {
|
||||||
|
await tester.pumpWidget(buildBookmarkList(
|
||||||
|
bookmarks: const [testBookmark],
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(find.byIcon(Icons.login), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.delete_outline), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
|
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
|
||||||
|
import 'package:chanora_flutter/services/poke_notification_service.dart';
|
||||||
import 'package:chanora_flutter/services/poke_preferences_service.dart';
|
import 'package:chanora_flutter/services/poke_preferences_service.dart';
|
||||||
import 'package:chanora_flutter/widgets/poke_notification_settings.dart';
|
import 'package:chanora_flutter/widgets/poke_notification_settings.dart';
|
||||||
|
|
||||||
@@ -14,11 +15,16 @@ void main() {
|
|||||||
await preferences.muteSender(BigInt.from(42));
|
await preferences.muteSender(BigInt.from(42));
|
||||||
addTearDown(preferences.dispose);
|
addTearDown(preferences.dispose);
|
||||||
|
|
||||||
|
final notificationService = PokeNotificationService();
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MaterialApp(
|
MaterialApp(
|
||||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||||
supportedLocales: AppL10n.supportedLocales,
|
supportedLocales: AppL10n.supportedLocales,
|
||||||
home: PokeNotificationSettingsDialog(preferences: preferences),
|
home: PokeNotificationSettingsDialog(
|
||||||
|
preferences: preferences,
|
||||||
|
notificationService: notificationService,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
|
||||||
|
import 'package:chanora_flutter/src/rust/api.dart' as rust;
|
||||||
|
import 'package:chanora_flutter/widgets/voice_bar.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const defaultStats = rust.BridgeAudioStats(
|
||||||
|
framesSent: 100,
|
||||||
|
framesReceived: 200,
|
||||||
|
pttActive: false,
|
||||||
|
inputLevel: -30.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget buildVoiceBar({
|
||||||
|
bool inChannel = true,
|
||||||
|
rust.BridgeTransmitMode transmitMode = rust.BridgeTransmitMode.ptt,
|
||||||
|
bool hardMute = false,
|
||||||
|
bool outputMuted = false,
|
||||||
|
bool talkPowerBlocked = false,
|
||||||
|
int releaseTailMs = 150,
|
||||||
|
String channelName = 'Test Channel',
|
||||||
|
rust.BridgeAudioStats? audioStats = defaultStats,
|
||||||
|
double? inputLevel,
|
||||||
|
String pttLevel = 'L1WindowsHook',
|
||||||
|
String pttBackendId = 'windows-raw-input',
|
||||||
|
String pttBoundInputClass = 'keyboard',
|
||||||
|
String pttBoundKeyLabel = 'Space',
|
||||||
|
VoidCallback? onConfigure,
|
||||||
|
ValueChanged<bool>? onPttHeldChanged,
|
||||||
|
}) {
|
||||||
|
return MaterialApp(
|
||||||
|
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||||
|
supportedLocales: AppL10n.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
child: VoiceBar(
|
||||||
|
inChannel: inChannel,
|
||||||
|
transmitMode: transmitMode,
|
||||||
|
hardMute: hardMute,
|
||||||
|
outputMuted: outputMuted,
|
||||||
|
talkPowerBlocked: talkPowerBlocked,
|
||||||
|
releaseTailMs: releaseTailMs,
|
||||||
|
channelName: channelName,
|
||||||
|
audioStats: audioStats,
|
||||||
|
inputLevel: inputLevel,
|
||||||
|
pttLevel: pttLevel,
|
||||||
|
pttBackendId: pttBackendId,
|
||||||
|
pttBoundInputClass: pttBoundInputClass,
|
||||||
|
pttBoundKeyLabel: pttBoundKeyLabel,
|
||||||
|
onConfigure: onConfigure ?? () {},
|
||||||
|
onPttHeldChanged: onPttHeldChanged ?? (_) {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('VoiceBar renders in PTT mode with stats', (tester) async {
|
||||||
|
await tester.pumpWidget(buildVoiceBar());
|
||||||
|
|
||||||
|
expect(find.byType(VoiceBar), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.radio_button_checked), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.tune), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('VoiceBar renders in continuous mode', (tester) async {
|
||||||
|
await tester.pumpWidget(buildVoiceBar(
|
||||||
|
transmitMode: rust.BridgeTransmitMode.continuous,
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(find.byType(VoiceBar), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.podcasts), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('VoiceBar shows channel status when in channel', (tester) async {
|
||||||
|
await tester.pumpWidget(buildVoiceBar(inChannel: true));
|
||||||
|
|
||||||
|
expect(find.byType(VoiceBar), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('VoiceBar hides channel status when not in channel', (tester) async {
|
||||||
|
await tester.pumpWidget(buildVoiceBar(
|
||||||
|
inChannel: false,
|
||||||
|
channelName: '',
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(find.byType(VoiceBar), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('VoiceBar shows talk power blocked indicator', (tester) async {
|
||||||
|
await tester.pumpWidget(buildVoiceBar(
|
||||||
|
talkPowerBlocked: true,
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(find.text('Insufficient talk power'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('VoiceBar shows audio stats line when stats available', (tester) async {
|
||||||
|
await tester.pumpWidget(buildVoiceBar());
|
||||||
|
|
||||||
|
expect(find.byType(VoiceBar), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('VoiceBar hides audio stats line when stats null', (tester) async {
|
||||||
|
await tester.pumpWidget(buildVoiceBar(
|
||||||
|
audioStats: null,
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(find.byType(VoiceBar), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('VoiceBar onConfigure callback fires', (tester) async {
|
||||||
|
var configured = false;
|
||||||
|
await tester.pumpWidget(buildVoiceBar(
|
||||||
|
onConfigure: () => configured = true,
|
||||||
|
));
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.tune));
|
||||||
|
expect(configured, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('VoiceBar renders PTT capability badge in PTT mode', (tester) async {
|
||||||
|
await tester.pumpWidget(buildVoiceBar(
|
||||||
|
transmitMode: rust.BridgeTransmitMode.ptt,
|
||||||
|
pttLevel: 'L1WindowsHook',
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(find.byIcon(Icons.public), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('VoiceBar does not render PTT badge in continuous mode', (tester) async {
|
||||||
|
await tester.pumpWidget(buildVoiceBar(
|
||||||
|
transmitMode: rust.BridgeTransmitMode.continuous,
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(find.byIcon(Icons.public), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('VoiceBar renders with active PTT state', (tester) async {
|
||||||
|
const activeStats = rust.BridgeAudioStats(
|
||||||
|
framesSent: 500,
|
||||||
|
framesReceived: 300,
|
||||||
|
pttActive: true,
|
||||||
|
inputLevel: -10.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(buildVoiceBar(audioStats: activeStats));
|
||||||
|
|
||||||
|
expect(find.byType(VoiceBar), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('VoiceBar renders with input level override', (tester) async {
|
||||||
|
await tester.pumpWidget(buildVoiceBar(
|
||||||
|
inputLevel: -20.0,
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(find.byType(VoiceBar), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('VoiceBar renders with null input level', (tester) async {
|
||||||
|
await tester.pumpWidget(buildVoiceBar(
|
||||||
|
audioStats: null,
|
||||||
|
inputLevel: null,
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(find.byType(VoiceBar), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -13,6 +13,24 @@ void main() {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('gated transmit mode segments drop VAD when unsupported', () {
|
||||||
|
expect(
|
||||||
|
transmitModeSegmentsFor(voiceActivityAvailable: false).map((s) => s.value),
|
||||||
|
[rust.BridgeTransmitMode.ptt, rust.BridgeTransmitMode.continuous],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gated transmit mode segments include VAD when supported', () {
|
||||||
|
expect(
|
||||||
|
transmitModeSegmentsFor(voiceActivityAvailable: true).map((s) => s.value),
|
||||||
|
[
|
||||||
|
rust.BridgeTransmitMode.ptt,
|
||||||
|
rust.BridgeTransmitMode.continuous,
|
||||||
|
rust.BridgeTransmitMode.voiceActivity,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('shared Android processing segments expose hardware and WebRTC', () {
|
test('shared Android processing segments expose hardware and WebRTC', () {
|
||||||
expect(androidProcessingSegments.map((s) => s.value), [true, false]);
|
expect(androidProcessingSegments.map((s) => s.value), [true, false]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:chanora_flutter/src/rust/api.dart' as rust;
|
||||||
|
import 'package:chanora_flutter/widgets/voice_settings.dart';
|
||||||
|
|
||||||
|
rust.BridgeAudioProcessingConfig _defaultConfig() {
|
||||||
|
return rust.BridgeAudioProcessingConfig(
|
||||||
|
route: rust.BridgeAudioRoute.unknown,
|
||||||
|
iosMode: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
|
||||||
|
processingBackend: rust.BridgeAudioBackend.webrtcApm,
|
||||||
|
vadBackend: rust.BridgeVadBackend.sileroOnnx,
|
||||||
|
aec: rust.BridgeEffectOwner.webrtcApm,
|
||||||
|
ns: rust.BridgeEffectOwner.webrtcApm,
|
||||||
|
agc: rust.BridgeEffectOwner.webrtcApm,
|
||||||
|
hpfEnabled: true,
|
||||||
|
limiterEnabled: true,
|
||||||
|
vadHangoverMs: 500,
|
||||||
|
vadPreRollMs: 160,
|
||||||
|
vadMinTxMs: 200,
|
||||||
|
debugWavDumpEnabled: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('VoiceSettingsResult', () {
|
||||||
|
test('stores PTT mode result', () {
|
||||||
|
final config = _defaultConfig();
|
||||||
|
final result = VoiceSettingsResult(
|
||||||
|
mode: rust.BridgeTransmitMode.ptt,
|
||||||
|
releaseTailMs: 200,
|
||||||
|
bindKeyRequested: true,
|
||||||
|
audioConfig: config,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.mode, rust.BridgeTransmitMode.ptt);
|
||||||
|
expect(result.releaseTailMs, 200);
|
||||||
|
expect(result.bindKeyRequested, isTrue);
|
||||||
|
expect(result.audioConfig, config);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stores continuous mode result without key binding', () {
|
||||||
|
final config = _defaultConfig();
|
||||||
|
final result = VoiceSettingsResult(
|
||||||
|
mode: rust.BridgeTransmitMode.continuous,
|
||||||
|
releaseTailMs: 0,
|
||||||
|
bindKeyRequested: false,
|
||||||
|
audioConfig: config,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.mode, rust.BridgeTransmitMode.continuous);
|
||||||
|
expect(result.releaseTailMs, 0);
|
||||||
|
expect(result.bindKeyRequested, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stores voice activity mode result', () {
|
||||||
|
final config = _defaultConfig();
|
||||||
|
final result = VoiceSettingsResult(
|
||||||
|
mode: rust.BridgeTransmitMode.voiceActivity,
|
||||||
|
releaseTailMs: 100,
|
||||||
|
bindKeyRequested: false,
|
||||||
|
audioConfig: config,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.mode, rust.BridgeTransmitMode.voiceActivity);
|
||||||
|
expect(result.releaseTailMs, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves audio config fields', () {
|
||||||
|
final config = rust.BridgeAudioProcessingConfig(
|
||||||
|
route: rust.BridgeAudioRoute.bluetoothHfp,
|
||||||
|
iosMode: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
|
||||||
|
processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing,
|
||||||
|
vadBackend: rust.BridgeVadBackend.webrtcVad,
|
||||||
|
aec: rust.BridgeEffectOwner.platform,
|
||||||
|
ns: rust.BridgeEffectOwner.off,
|
||||||
|
agc: rust.BridgeEffectOwner.off,
|
||||||
|
hpfEnabled: false,
|
||||||
|
limiterEnabled: false,
|
||||||
|
vadHangoverMs: 300,
|
||||||
|
vadPreRollMs: 100,
|
||||||
|
vadMinTxMs: 150,
|
||||||
|
debugWavDumpEnabled: true,
|
||||||
|
);
|
||||||
|
final result = VoiceSettingsResult(
|
||||||
|
mode: rust.BridgeTransmitMode.ptt,
|
||||||
|
releaseTailMs: 250,
|
||||||
|
bindKeyRequested: false,
|
||||||
|
audioConfig: config,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.audioConfig.route, rust.BridgeAudioRoute.bluetoothHfp);
|
||||||
|
expect(result.audioConfig.vadBackend, rust.BridgeVadBackend.webrtcVad);
|
||||||
|
expect(result.audioConfig.ns, rust.BridgeEffectOwner.off);
|
||||||
|
expect(result.audioConfig.hpfEnabled, isFalse);
|
||||||
|
expect(result.audioConfig.debugWavDumpEnabled, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('release tail is always a round number', () {
|
||||||
|
final result = VoiceSettingsResult(
|
||||||
|
mode: rust.BridgeTransmitMode.ptt,
|
||||||
|
releaseTailMs: 123,
|
||||||
|
bindKeyRequested: false,
|
||||||
|
audioConfig: _defaultConfig(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.releaseTailMs, 123);
|
||||||
|
expect(result.releaseTailMs, equals(result.releaseTailMs.round()));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bindKeyRequested defaults to false for save action', () {
|
||||||
|
final result = VoiceSettingsResult(
|
||||||
|
mode: rust.BridgeTransmitMode.ptt,
|
||||||
|
releaseTailMs: 150,
|
||||||
|
bindKeyRequested: false,
|
||||||
|
audioConfig: _defaultConfig(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.bindKeyRequested, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bindKeyRequested is true for bind-key action', () {
|
||||||
|
final result = VoiceSettingsResult(
|
||||||
|
mode: rust.BridgeTransmitMode.ptt,
|
||||||
|
releaseTailMs: 150,
|
||||||
|
bindKeyRequested: true,
|
||||||
|
audioConfig: _defaultConfig(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.bindKeyRequested, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('VoiceSettingsDialog state management', () {
|
||||||
|
test('release tail is clamped between 0 and 500', () {
|
||||||
|
expect(999.clamp(0, 500), 500);
|
||||||
|
expect((-10).clamp(0, 500), 0);
|
||||||
|
expect(250.clamp(0, 500), 250);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('transmit mode enum covers all three modes', () {
|
||||||
|
expect(rust.BridgeTransmitMode.values, hasLength(3));
|
||||||
|
expect(
|
||||||
|
rust.BridgeTransmitMode.values,
|
||||||
|
containsAll([
|
||||||
|
rust.BridgeTransmitMode.ptt,
|
||||||
|
rust.BridgeTransmitMode.continuous,
|
||||||
|
rust.BridgeTransmitMode.voiceActivity,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ repository.workspace = true
|
|||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
chanora_cache = { path = "../../crates/chanora_cache" }
|
||||||
chanora_protocol = { path = "../../crates/chanora_protocol" }
|
chanora_protocol = { path = "../../crates/chanora_protocol" }
|
||||||
chanora_state = { path = "../../crates/chanora_state" }
|
chanora_state = { path = "../../crates/chanora_state" }
|
||||||
chanora_audio = { path = "../../crates/chanora_audio" }
|
chanora_audio = { path = "../../crates/chanora_audio" }
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# chanora_core
|
||||||
|
|
||||||
|
Top-level Rust API and orchestration layer for the Chanora client. Composes subsystem crates behind a stable, typed API consumed by `chanora_bridge`. Owns no protocol, audio, or storage logic directly.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Per SAD §7.2, `chanora_core` is the integration point:
|
||||||
|
|
||||||
|
- **`ChanoraSession`** — the primary public type. Owns at most one active server connection (DEC-006). Provides connect, disconnect, snapshot, audio lifecycle, PTT, bookmarks, and diagnostics methods.
|
||||||
|
- **Supervisor** — a per-connection tokio task that monitors connection health via a loss notifier and a watchdog probe, and auto-reconnects with exponential backoff (1 s → 60 s capped). Re-attaches the audio engine if it was running prior to the loss.
|
||||||
|
- **`SessionEvent`** — broadcast enum emitted on connect/lost/reconnecting/disconnected/audio-started/audio-stopped/voice-state/chat/route changes. Subscribers consume via `subscribe_events()`.
|
||||||
|
- **File transfer** — avatar/icon download routed through a cacache-backed blob cache with LRU eviction.
|
||||||
|
- **Channel join state machine** — reducer-based state tracking for voice channel joins, with optimistic commands, snapshot reconciliation, and error projection.
|
||||||
|
- **PTT controller** — platform input backend management, binding persistence, and release-tail timer wiring (SDD-088/094/096).
|
||||||
|
|
||||||
|
## Public API Summary
|
||||||
|
|
||||||
|
### Core types
|
||||||
|
|
||||||
|
| Type | Role |
|
||||||
|
|---|---|
|
||||||
|
| `ChanoraSession` | Top-level session handle; cloneable, thread-safe |
|
||||||
|
| `CoreError` | Unified error enum covering all subsystem errors |
|
||||||
|
| `SessionEvent` | Broadcast lifecycle event enum |
|
||||||
|
| `ConnectConfig` | Typed connection parameters |
|
||||||
|
| `NetworkState` | OS connectivity state enum |
|
||||||
|
|
||||||
|
### Key methods on `ChanoraSession`
|
||||||
|
|
||||||
|
- `new()` — construct an empty session (no I/O)
|
||||||
|
- `init_storage(dir)` — wire identity + bookmark stores
|
||||||
|
- `init_cache(dir)` — wire the blob cache for avatars/icons
|
||||||
|
- `connect(cfg)` → `ServerSnapshot` — dial a server (single-connection invariant)
|
||||||
|
- `disconnect()` — clean teardown including supervisor
|
||||||
|
- `is_connected()` — check connection state
|
||||||
|
- `snapshot()` → `ServerSnapshot` — refresh server state
|
||||||
|
- `client_profile(client_id)` — rich profile for one client
|
||||||
|
- `voice_join(channel_id, password)` / `voice_leave()` — audio lifecycle
|
||||||
|
- `start_audio(cfg)` — initialize audio subsystem
|
||||||
|
- `set_input_device(id)` / `set_output_device(id)` — device selection
|
||||||
|
- `set_output_gain(gain)` / `set_client_volume(client_id, volume)` — volume control
|
||||||
|
- `set_transmit_mode(mode)` / `get_transmit_mode()` — transmit mode
|
||||||
|
- `set_hard_mute(muted)` — hard-mute clamp
|
||||||
|
- `set_release_tail_ms(ms)` / `get_release_tail_ms()` — release-tail config
|
||||||
|
- `set_ptt(active)` / `set_ptt_binding(binding)` / `ptt_descriptor()` — PTT control
|
||||||
|
- `send_text_message(message, target)` — chat
|
||||||
|
- `move_to_channel(id, password)` / `set_self_muted(input, output)` — channel + mute
|
||||||
|
- `subscribe_events()` — broadcast receiver for `SessionEvent`
|
||||||
|
- `drain_protocol_events()` / `protocol_events_snapshot()` — protocol event access
|
||||||
|
- `export_diagnostics()` — redacted diagnostic bundle (includes network stats)
|
||||||
|
- `audio_stats()` — audio subsystem telemetry
|
||||||
|
- `network_diagnostics_summary()` — network statistics
|
||||||
|
- `prefetch_server(host)` — warm server-address resolution
|
||||||
|
- `set_audio_processing_config(cfg)` / `get_audio_processing_config()` — audio DSP config
|
||||||
|
- `set_audio_debug_wav_dump(enabled)` — WAV dump toggle
|
||||||
|
- `set_vad_model_path(path)` — Silero model path
|
||||||
|
- `transmit_selector()` / `release_tail_timer()` — subsystem accessors
|
||||||
|
|
||||||
|
### Re-exports
|
||||||
|
|
||||||
|
Re-exports selected types from `chanora_protocol`, `chanora_audio`, `chanora_storage`, and `chanora_diagnostics` so the bridge only depends on `chanora_core`.
|
||||||
|
|
||||||
|
## Platform notes
|
||||||
|
|
||||||
|
- iOS/macOS-specific methods (`ios_handle_route_change`, `ios_handle_interruption_began`, etc.) are gated behind `cfg(target_os = "ios" | "macos")` inside method bodies.
|
||||||
|
- Android-specific reconnect paths are similarly gated.
|
||||||
|
- The crate itself compiles on all targets; platform-specific code is runtime- or cfg-gated.
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
- Single active connection at runtime (DEC-006)
|
||||||
|
- `tsclientlib` types never cross out of `chanora_protocol` (SAD-067)
|
||||||
|
- Secret material never lands in non-secret storage (DEC-013.2)
|
||||||
|
- Audio engine construction failure preserves the previous engine state
|
||||||
@@ -22,6 +22,17 @@ impl From<PttBackendDescriptor> for PttDescriptorSnapshot {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SessionEvent {
|
||||||
|
/// Construct a `PttCapability` event from an audio backend descriptor.
|
||||||
|
pub fn ptt_capability_from_descriptor(desc: &PttBackendDescriptor) -> Self {
|
||||||
|
Self::PttCapability {
|
||||||
|
level: desc.level.as_str().to_string(),
|
||||||
|
backend_id: desc.backend_id.to_string(),
|
||||||
|
bound_input_class: desc.bound_input_class.unwrap_or("").to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Persisted PTT binding state exposed to callers.
|
/// Persisted PTT binding state exposed to callers.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct PersistedPttBinding {
|
pub struct PersistedPttBinding {
|
||||||
@@ -235,7 +246,7 @@ pub enum SessionEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Bridge-safe mirror of channel-join projection sync state.
|
/// Bridge-safe mirror of channel-join projection sync state.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum VoiceJoinSyncState {
|
pub enum VoiceJoinSyncState {
|
||||||
/// Reducer is ready to accept channel actions.
|
/// Reducer is ready to accept channel actions.
|
||||||
Ready,
|
Ready,
|
||||||
@@ -246,7 +257,7 @@ pub enum VoiceJoinSyncState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Bridge-safe mirror of stable channel-join error codes.
|
/// Bridge-safe mirror of stable channel-join error codes.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum VoiceJoinErrorCode {
|
pub enum VoiceJoinErrorCode {
|
||||||
/// Duplicate same-target join intent was coalesced.
|
/// Duplicate same-target join intent was coalesced.
|
||||||
DuplicateSameTargetCoalesced,
|
DuplicateSameTargetCoalesced,
|
||||||
@@ -285,3 +296,506 @@ pub enum NetworkState {
|
|||||||
/// OS reports no networks available.
|
/// OS reports no networks available.
|
||||||
Offline,
|
Offline,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn network_state_equality() {
|
||||||
|
assert_eq!(NetworkState::Unknown, NetworkState::Unknown);
|
||||||
|
assert_eq!(NetworkState::Online, NetworkState::Online);
|
||||||
|
assert_eq!(NetworkState::Offline, NetworkState::Offline);
|
||||||
|
assert_ne!(NetworkState::Unknown, NetworkState::Online);
|
||||||
|
assert_ne!(NetworkState::Online, NetworkState::Offline);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persisted_ptt_binding_empty() {
|
||||||
|
let binding = PersistedPttBinding::empty();
|
||||||
|
assert_eq!(binding.input_class, "");
|
||||||
|
assert_eq!(binding.key_label, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persisted_ptt_binding_equality() {
|
||||||
|
let a = PersistedPttBinding {
|
||||||
|
input_class: "keyboard".to_string(),
|
||||||
|
key_label: "Space".to_string(),
|
||||||
|
};
|
||||||
|
let b = PersistedPttBinding {
|
||||||
|
input_class: "keyboard".to_string(),
|
||||||
|
key_label: "Space".to_string(),
|
||||||
|
};
|
||||||
|
assert_eq!(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ptt_descriptor_snapshot_fields() {
|
||||||
|
let snap = PttDescriptorSnapshot {
|
||||||
|
level: "L0Focused".to_string(),
|
||||||
|
backend_id: "focused".to_string(),
|
||||||
|
bound_input_class: "keyboard".to_string(),
|
||||||
|
};
|
||||||
|
assert_eq!(snap.level, "L0Focused");
|
||||||
|
assert_eq!(snap.backend_id, "focused");
|
||||||
|
assert_eq!(snap.bound_input_class, "keyboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_connected() {
|
||||||
|
let evt = SessionEvent::Connected {
|
||||||
|
server_name: "Test Server".to_string(),
|
||||||
|
};
|
||||||
|
if let SessionEvent::Connected { server_name } = evt {
|
||||||
|
assert_eq!(server_name, "Test Server");
|
||||||
|
} else {
|
||||||
|
panic!("expected Connected variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_lost() {
|
||||||
|
let evt = SessionEvent::Lost {
|
||||||
|
reason: "timeout".to_string(),
|
||||||
|
};
|
||||||
|
if let SessionEvent::Lost { reason } = evt {
|
||||||
|
assert_eq!(reason, "timeout");
|
||||||
|
} else {
|
||||||
|
panic!("expected Lost variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_reconnecting() {
|
||||||
|
let evt = SessionEvent::Reconnecting {
|
||||||
|
attempt: 3,
|
||||||
|
delay_secs: 30,
|
||||||
|
};
|
||||||
|
if let SessionEvent::Reconnecting {
|
||||||
|
attempt,
|
||||||
|
delay_secs,
|
||||||
|
} = evt
|
||||||
|
{
|
||||||
|
assert_eq!(attempt, 3);
|
||||||
|
assert_eq!(delay_secs, 30);
|
||||||
|
} else {
|
||||||
|
panic!("expected Reconnecting variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_disconnected() {
|
||||||
|
let evt = SessionEvent::Disconnected {
|
||||||
|
reason: "user".to_string(),
|
||||||
|
};
|
||||||
|
if let SessionEvent::Disconnected { reason } = evt {
|
||||||
|
assert_eq!(reason, "user");
|
||||||
|
} else {
|
||||||
|
panic!("expected Disconnected variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_audio_started_stopped() {
|
||||||
|
let _ = SessionEvent::AudioStarted;
|
||||||
|
let _ = SessionEvent::AudioStopped;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_ptt_capability() {
|
||||||
|
let evt = SessionEvent::PttCapability {
|
||||||
|
level: "L1GlobalShortcut".to_string(),
|
||||||
|
backend_id: "global".to_string(),
|
||||||
|
bound_input_class: "keyboard".to_string(),
|
||||||
|
};
|
||||||
|
if let SessionEvent::PttCapability {
|
||||||
|
level,
|
||||||
|
backend_id,
|
||||||
|
bound_input_class,
|
||||||
|
} = evt
|
||||||
|
{
|
||||||
|
assert_eq!(level, "L1GlobalShortcut");
|
||||||
|
assert_eq!(backend_id, "global");
|
||||||
|
assert_eq!(bound_input_class, "keyboard");
|
||||||
|
} else {
|
||||||
|
panic!("expected PttCapability variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_voice_state() {
|
||||||
|
let evt = SessionEvent::VoiceState {
|
||||||
|
in_channel: true,
|
||||||
|
transmit_mode: 1,
|
||||||
|
mute: false,
|
||||||
|
release_tail_ms: 200,
|
||||||
|
current_channel_id: Some(42),
|
||||||
|
pending_target_channel_id: None,
|
||||||
|
can_join: false,
|
||||||
|
can_leave: true,
|
||||||
|
join_sync_state: VoiceJoinSyncState::Ready,
|
||||||
|
join_error_code: None,
|
||||||
|
};
|
||||||
|
if let SessionEvent::VoiceState {
|
||||||
|
in_channel,
|
||||||
|
transmit_mode,
|
||||||
|
mute,
|
||||||
|
release_tail_ms,
|
||||||
|
current_channel_id,
|
||||||
|
pending_target_channel_id,
|
||||||
|
can_join,
|
||||||
|
can_leave,
|
||||||
|
join_sync_state,
|
||||||
|
join_error_code,
|
||||||
|
} = evt
|
||||||
|
{
|
||||||
|
assert!(in_channel);
|
||||||
|
assert_eq!(transmit_mode, 1);
|
||||||
|
assert!(!mute);
|
||||||
|
assert_eq!(release_tail_ms, 200);
|
||||||
|
assert_eq!(current_channel_id, Some(42));
|
||||||
|
assert_eq!(pending_target_channel_id, None);
|
||||||
|
assert!(!can_join);
|
||||||
|
assert!(can_leave);
|
||||||
|
assert_eq!(join_sync_state, VoiceJoinSyncState::Ready);
|
||||||
|
assert!(join_error_code.is_none());
|
||||||
|
} else {
|
||||||
|
panic!("expected VoiceState variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_interruption_state() {
|
||||||
|
let evt = SessionEvent::InterruptionState {
|
||||||
|
began: true,
|
||||||
|
should_resume: false,
|
||||||
|
};
|
||||||
|
if let SessionEvent::InterruptionState {
|
||||||
|
began,
|
||||||
|
should_resume,
|
||||||
|
} = evt
|
||||||
|
{
|
||||||
|
assert!(began);
|
||||||
|
assert!(!should_resume);
|
||||||
|
} else {
|
||||||
|
panic!("expected InterruptionState variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_chat_message() {
|
||||||
|
let evt = SessionEvent::ChatMessage {
|
||||||
|
sender_id: 5,
|
||||||
|
sender_name: "Alice".to_string(),
|
||||||
|
message: "Hello".to_string(),
|
||||||
|
target: chanora_protocol::MessageTarget::Channel,
|
||||||
|
poke_strength: None,
|
||||||
|
};
|
||||||
|
if let SessionEvent::ChatMessage {
|
||||||
|
sender_id,
|
||||||
|
sender_name,
|
||||||
|
message,
|
||||||
|
target,
|
||||||
|
poke_strength,
|
||||||
|
} = evt
|
||||||
|
{
|
||||||
|
assert_eq!(sender_id, 5);
|
||||||
|
assert_eq!(sender_name, "Alice");
|
||||||
|
assert_eq!(message, "Hello");
|
||||||
|
assert_eq!(target, chanora_protocol::MessageTarget::Channel);
|
||||||
|
assert!(poke_strength.is_none());
|
||||||
|
} else {
|
||||||
|
panic!("expected ChatMessage variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_chat_message_with_poke() {
|
||||||
|
let evt = SessionEvent::ChatMessage {
|
||||||
|
sender_id: 3,
|
||||||
|
sender_name: "Bob".to_string(),
|
||||||
|
message: "".to_string(),
|
||||||
|
target: chanora_protocol::MessageTarget::Poke(7),
|
||||||
|
poke_strength: Some(chanora_protocol::PokeStrength::Suppressed),
|
||||||
|
};
|
||||||
|
if let SessionEvent::ChatMessage {
|
||||||
|
target,
|
||||||
|
poke_strength,
|
||||||
|
..
|
||||||
|
} = evt
|
||||||
|
{
|
||||||
|
assert_eq!(target, chanora_protocol::MessageTarget::Poke(7));
|
||||||
|
assert_eq!(poke_strength, Some(chanora_protocol::PokeStrength::Suppressed));
|
||||||
|
} else {
|
||||||
|
panic!("expected ChatMessage variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_server_activity() {
|
||||||
|
let evt = SessionEvent::ServerActivity {
|
||||||
|
message: "User joined channel".to_string(),
|
||||||
|
};
|
||||||
|
if let SessionEvent::ServerActivity { message } = &evt {
|
||||||
|
assert_eq!(message, "User joined channel");
|
||||||
|
} else {
|
||||||
|
panic!("expected ServerActivity variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_audio_route_changed() {
|
||||||
|
let evt = SessionEvent::AudioRouteChanged {
|
||||||
|
route: chanora_audio::AudioRoute::Speaker,
|
||||||
|
};
|
||||||
|
if let SessionEvent::AudioRouteChanged { route } = &evt {
|
||||||
|
assert_eq!(*route, chanora_audio::AudioRoute::Speaker);
|
||||||
|
} else {
|
||||||
|
panic!("expected AudioRouteChanged variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_client_moved() {
|
||||||
|
let evt = SessionEvent::ClientMoved {
|
||||||
|
client_id: 1,
|
||||||
|
new_channel_id: 2,
|
||||||
|
};
|
||||||
|
if let SessionEvent::ClientMoved {
|
||||||
|
client_id,
|
||||||
|
new_channel_id,
|
||||||
|
} = evt
|
||||||
|
{
|
||||||
|
assert_eq!(client_id, 1);
|
||||||
|
assert_eq!(new_channel_id, 2);
|
||||||
|
} else {
|
||||||
|
panic!("expected ClientMoved variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_client_joined() {
|
||||||
|
let evt = SessionEvent::ClientJoined {
|
||||||
|
client_id: 10,
|
||||||
|
channel_id: 3,
|
||||||
|
name: "NewUser".to_string(),
|
||||||
|
input_muted: false,
|
||||||
|
output_muted: true,
|
||||||
|
is_server_query: false,
|
||||||
|
talk_power: 0,
|
||||||
|
talk_power_granted: false,
|
||||||
|
};
|
||||||
|
if let SessionEvent::ClientJoined {
|
||||||
|
client_id,
|
||||||
|
channel_id,
|
||||||
|
name,
|
||||||
|
input_muted,
|
||||||
|
output_muted,
|
||||||
|
is_server_query,
|
||||||
|
talk_power,
|
||||||
|
talk_power_granted,
|
||||||
|
} = evt
|
||||||
|
{
|
||||||
|
assert_eq!(client_id, 10);
|
||||||
|
assert_eq!(channel_id, 3);
|
||||||
|
assert_eq!(name, "NewUser");
|
||||||
|
assert!(!input_muted);
|
||||||
|
assert!(output_muted);
|
||||||
|
assert!(!is_server_query);
|
||||||
|
assert_eq!(talk_power, 0);
|
||||||
|
assert!(!talk_power_granted);
|
||||||
|
} else {
|
||||||
|
panic!("expected ClientJoined variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_client_left() {
|
||||||
|
let evt = SessionEvent::ClientLeft {
|
||||||
|
client_id: 10,
|
||||||
|
name: "Departing".to_string(),
|
||||||
|
};
|
||||||
|
if let SessionEvent::ClientLeft { client_id, name } = evt {
|
||||||
|
assert_eq!(client_id, 10);
|
||||||
|
assert_eq!(name, "Departing");
|
||||||
|
} else {
|
||||||
|
panic!("expected ClientLeft variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_client_updated() {
|
||||||
|
let evt = SessionEvent::ClientUpdated {
|
||||||
|
client_id: 5,
|
||||||
|
input_muted: true,
|
||||||
|
output_muted: false,
|
||||||
|
is_server_query: true,
|
||||||
|
talk_power: 75,
|
||||||
|
talk_power_granted: true,
|
||||||
|
};
|
||||||
|
if let SessionEvent::ClientUpdated {
|
||||||
|
client_id,
|
||||||
|
input_muted,
|
||||||
|
output_muted,
|
||||||
|
is_server_query,
|
||||||
|
talk_power,
|
||||||
|
talk_power_granted,
|
||||||
|
} = evt
|
||||||
|
{
|
||||||
|
assert_eq!(client_id, 5);
|
||||||
|
assert!(input_muted);
|
||||||
|
assert!(!output_muted);
|
||||||
|
assert!(is_server_query);
|
||||||
|
assert_eq!(talk_power, 75);
|
||||||
|
assert!(talk_power_granted);
|
||||||
|
} else {
|
||||||
|
panic!("expected ClientUpdated variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_channel_added() {
|
||||||
|
let evt = SessionEvent::ChannelAdded {
|
||||||
|
id: 7,
|
||||||
|
parent: 1,
|
||||||
|
name: "Sub".to_string(),
|
||||||
|
order: 3,
|
||||||
|
has_password: true,
|
||||||
|
needed_talk_power: Some(50),
|
||||||
|
};
|
||||||
|
if let SessionEvent::ChannelAdded {
|
||||||
|
id,
|
||||||
|
parent,
|
||||||
|
name,
|
||||||
|
order,
|
||||||
|
has_password,
|
||||||
|
needed_talk_power,
|
||||||
|
} = evt
|
||||||
|
{
|
||||||
|
assert_eq!(id, 7);
|
||||||
|
assert_eq!(parent, 1);
|
||||||
|
assert_eq!(name, "Sub");
|
||||||
|
assert_eq!(order, 3);
|
||||||
|
assert!(has_password);
|
||||||
|
assert_eq!(needed_talk_power, Some(50));
|
||||||
|
} else {
|
||||||
|
panic!("expected ChannelAdded variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_channel_removed() {
|
||||||
|
let evt = SessionEvent::ChannelRemoved { id: 7 };
|
||||||
|
if let SessionEvent::ChannelRemoved { id } = evt {
|
||||||
|
assert_eq!(id, 7);
|
||||||
|
} else {
|
||||||
|
panic!("expected ChannelRemoved variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_channel_updated() {
|
||||||
|
let evt = SessionEvent::ChannelUpdated {
|
||||||
|
id: 7,
|
||||||
|
name: "Renamed".to_string(),
|
||||||
|
has_password: false,
|
||||||
|
needed_talk_power: None,
|
||||||
|
};
|
||||||
|
if let SessionEvent::ChannelUpdated {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
has_password,
|
||||||
|
needed_talk_power,
|
||||||
|
} = evt
|
||||||
|
{
|
||||||
|
assert_eq!(id, 7);
|
||||||
|
assert_eq!(name, "Renamed");
|
||||||
|
assert!(!has_password);
|
||||||
|
assert!(needed_talk_power.is_none());
|
||||||
|
} else {
|
||||||
|
panic!("expected ChannelUpdated variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn voice_join_sync_state_variants() {
|
||||||
|
let ready = VoiceJoinSyncState::Ready;
|
||||||
|
let init = VoiceJoinSyncState::SynchronizingInitialSnapshot;
|
||||||
|
let reconnect = VoiceJoinSyncState::SynchronizingReconnect;
|
||||||
|
assert_ne!(
|
||||||
|
std::mem::discriminant(&ready),
|
||||||
|
std::mem::discriminant(&init)
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
std::mem::discriminant(&init),
|
||||||
|
std::mem::discriminant(&reconnect)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn voice_join_error_code_all_variants() {
|
||||||
|
let codes = [
|
||||||
|
VoiceJoinErrorCode::DuplicateSameTargetCoalesced,
|
||||||
|
VoiceJoinErrorCode::JoinAlreadyPendingDifferentTarget,
|
||||||
|
VoiceJoinErrorCode::JoinDenied,
|
||||||
|
VoiceJoinErrorCode::JoinProtocolFailure,
|
||||||
|
VoiceJoinErrorCode::JoinNetworkFailure,
|
||||||
|
VoiceJoinErrorCode::JoinTimeout,
|
||||||
|
VoiceJoinErrorCode::JoinSupersededByLeave,
|
||||||
|
VoiceJoinErrorCode::JoinStaleOutcomeIgnored,
|
||||||
|
VoiceJoinErrorCode::JoinReconciledDifferentChannel,
|
||||||
|
VoiceJoinErrorCode::JoinCommandRejectedBeforeSend,
|
||||||
|
VoiceJoinErrorCode::JoinCannotStartWhileSynchronizing,
|
||||||
|
];
|
||||||
|
for i in 0..codes.len() {
|
||||||
|
for j in 0..codes.len() {
|
||||||
|
if i == j {
|
||||||
|
assert_eq!(
|
||||||
|
std::mem::discriminant(&codes[i]),
|
||||||
|
std::mem::discriminant(&codes[j])
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
assert_ne!(
|
||||||
|
std::mem::discriminant(&codes[i]),
|
||||||
|
std::mem::discriminant(&codes[j])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_clone_preserves_fields() {
|
||||||
|
let evt = SessionEvent::Connected {
|
||||||
|
server_name: "Cloneable".to_string(),
|
||||||
|
};
|
||||||
|
let cloned = evt.clone();
|
||||||
|
if let SessionEvent::Connected { server_name } = cloned {
|
||||||
|
assert_eq!(server_name, "Cloneable");
|
||||||
|
} else {
|
||||||
|
panic!("expected Connected variant after clone");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_voice_state_with_join_error() {
|
||||||
|
let evt = SessionEvent::VoiceState {
|
||||||
|
in_channel: false,
|
||||||
|
transmit_mode: 0,
|
||||||
|
mute: false,
|
||||||
|
release_tail_ms: 200,
|
||||||
|
current_channel_id: None,
|
||||||
|
pending_target_channel_id: None,
|
||||||
|
can_join: true,
|
||||||
|
can_leave: false,
|
||||||
|
join_sync_state: VoiceJoinSyncState::Ready,
|
||||||
|
join_error_code: Some(VoiceJoinErrorCode::JoinDenied),
|
||||||
|
};
|
||||||
|
if let SessionEvent::VoiceState { join_error_code, .. } = evt {
|
||||||
|
assert_eq!(join_error_code, Some(VoiceJoinErrorCode::JoinDenied));
|
||||||
|
} else {
|
||||||
|
panic!("expected VoiceState variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,344 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use chanora_cache::{BlobCache, BlobCacheError, PREFIX_AVATAR, PREFIX_ICON};
|
||||||
|
use chanora_protocol::{ProtocolClient, ProtocolError};
|
||||||
|
use tokio::sync::{Mutex, Semaphore, oneshot};
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
const MAX_CONCURRENT_DOWNLOADS: usize = 2;
|
||||||
|
const NEGATIVE_CACHE_TTL: Duration = Duration::from_secs(5 * 60);
|
||||||
|
|
||||||
|
type InFlightWaiters = Vec<oneshot::Sender<Result<Option<Vec<u8>>, FileTransferError>>>;
|
||||||
|
|
||||||
|
/// Errors raised while resolving protocol-owned file assets.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum FileTransferError {
|
||||||
|
/// No live protocol client is available for a download.
|
||||||
|
#[error("not connected")]
|
||||||
|
NotConnected,
|
||||||
|
/// The protocol layer failed while downloading the asset.
|
||||||
|
#[error("protocol error: {0}")]
|
||||||
|
Protocol(#[from] ProtocolError),
|
||||||
|
/// The blob cache failed while reading or writing the asset.
|
||||||
|
#[error("cache error: {0}")]
|
||||||
|
Cache(#[from] BlobCacheError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Clone for FileTransferError {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
match self {
|
||||||
|
Self::NotConnected => Self::NotConnected,
|
||||||
|
Self::Protocol(error) => Self::Protocol(clone_protocol_error(error)),
|
||||||
|
Self::Cache(error) => Self::Cache(clone_blob_cache_error(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FileTransferService {
|
||||||
|
cache: BlobCache,
|
||||||
|
protocol: Arc<Mutex<Option<ProtocolClient>>>,
|
||||||
|
semaphore: Arc<Semaphore>,
|
||||||
|
in_flight: Arc<Mutex<HashMap<String, InFlightWaiters>>>,
|
||||||
|
negative_cache: Arc<Mutex<HashMap<String, Instant>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileTransferService {
|
||||||
|
pub fn new(cache: BlobCache, protocol: Arc<Mutex<Option<ProtocolClient>>>) -> Self {
|
||||||
|
Self {
|
||||||
|
cache,
|
||||||
|
protocol,
|
||||||
|
semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_DOWNLOADS)),
|
||||||
|
in_flight: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
negative_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_protocol(&self, client: Option<ProtocolClient>) {
|
||||||
|
*self.protocol.lock().await = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_avatar(
|
||||||
|
&self,
|
||||||
|
avatar_hash: &str,
|
||||||
|
client_uid: &str,
|
||||||
|
) -> Result<Option<Vec<u8>>, FileTransferError> {
|
||||||
|
if let Some(bytes) = self.cache.get(PREFIX_AVATAR, avatar_hash).await? {
|
||||||
|
return Ok(Some(bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.is_negative_cache_hit(avatar_hash).await {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rx = {
|
||||||
|
let mut in_flight = self.in_flight.lock().await;
|
||||||
|
if let Some(waiters) = in_flight.get_mut(avatar_hash) {
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
waiters.push(tx);
|
||||||
|
Some(rx)
|
||||||
|
} else {
|
||||||
|
in_flight.insert(avatar_hash.to_string(), Vec::new());
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(rx) = rx {
|
||||||
|
return rx.await.unwrap_or_else(|_| {
|
||||||
|
Err(FileTransferError::Protocol(ProtocolError::Lost(
|
||||||
|
"coalesced avatar download waiter dropped".to_string(),
|
||||||
|
)))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let _permit = self
|
||||||
|
.semaphore
|
||||||
|
.acquire()
|
||||||
|
.await
|
||||||
|
.expect("file transfer semaphore should stay open");
|
||||||
|
|
||||||
|
let result = self.do_download_avatar(avatar_hash, client_uid).await;
|
||||||
|
self.finish_in_flight(avatar_hash, &result).await;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_icon(&self, icon_id: u64) -> Result<Option<Vec<u8>>, FileTransferError> {
|
||||||
|
let icon_key = icon_id.to_string();
|
||||||
|
let negative_key = format!("ic_{icon_id}");
|
||||||
|
let in_flight_key = format!("icon_{icon_id}");
|
||||||
|
|
||||||
|
if let Some(bytes) = self.cache.get(PREFIX_ICON, &icon_key).await? {
|
||||||
|
return Ok(Some(bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.is_negative_cache_hit(&negative_key).await {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rx = {
|
||||||
|
let mut in_flight = self.in_flight.lock().await;
|
||||||
|
if let Some(waiters) = in_flight.get_mut(&in_flight_key) {
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
waiters.push(tx);
|
||||||
|
Some(rx)
|
||||||
|
} else {
|
||||||
|
in_flight.insert(in_flight_key.clone(), Vec::new());
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(rx) = rx {
|
||||||
|
return rx.await.unwrap_or_else(|_| {
|
||||||
|
Err(FileTransferError::Protocol(ProtocolError::Lost(
|
||||||
|
"coalesced icon download waiter dropped".to_string(),
|
||||||
|
)))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let _permit = self
|
||||||
|
.semaphore
|
||||||
|
.acquire()
|
||||||
|
.await
|
||||||
|
.expect("file transfer semaphore should stay open");
|
||||||
|
|
||||||
|
let result = self.do_download_icon(icon_id).await;
|
||||||
|
self.finish_in_flight(&in_flight_key, &result).await;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn clear_cache(&self) -> Result<(), FileTransferError> {
|
||||||
|
self.cache.clear().await?;
|
||||||
|
self.negative_cache.lock().await.clear();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cache_size(&self) -> Result<u64, FileTransferError> {
|
||||||
|
Ok(self.cache.total_size().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn do_download_avatar(
|
||||||
|
&self,
|
||||||
|
avatar_hash: &str,
|
||||||
|
client_uid: &str,
|
||||||
|
) -> Result<Option<Vec<u8>>, FileTransferError> {
|
||||||
|
if let Some(bytes) = self.cache.get(PREFIX_AVATAR, avatar_hash).await? {
|
||||||
|
return Ok(Some(bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.is_negative_cache_hit(avatar_hash).await {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let protocol = self.protocol.lock().await;
|
||||||
|
let client = protocol.as_ref().ok_or(FileTransferError::NotConnected)?;
|
||||||
|
match client.download_avatar(client_uid).await {
|
||||||
|
Ok(bytes) => {
|
||||||
|
self.cache.put(PREFIX_AVATAR, avatar_hash, &bytes).await?;
|
||||||
|
self.negative_cache.lock().await.remove(avatar_hash);
|
||||||
|
Ok(Some(bytes))
|
||||||
|
}
|
||||||
|
Err(ProtocolError::ServerRejected { .. }) => {
|
||||||
|
self.negative_cache
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(avatar_hash.to_string(), Instant::now() + NEGATIVE_CACHE_TTL);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
Err(error) => Err(FileTransferError::Protocol(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn do_download_icon(&self, icon_id: u64) -> Result<Option<Vec<u8>>, FileTransferError> {
|
||||||
|
let icon_key = icon_id.to_string();
|
||||||
|
let negative_key = format!("ic_{icon_id}");
|
||||||
|
|
||||||
|
if let Some(bytes) = self.cache.get(PREFIX_ICON, &icon_key).await? {
|
||||||
|
return Ok(Some(bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.is_negative_cache_hit(&negative_key).await {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let protocol = self.protocol.lock().await;
|
||||||
|
let client = protocol.as_ref().ok_or(FileTransferError::NotConnected)?;
|
||||||
|
match client.download_icon(icon_id).await {
|
||||||
|
Ok(bytes) => {
|
||||||
|
self.cache.put(PREFIX_ICON, &icon_key, &bytes).await?;
|
||||||
|
self.negative_cache.lock().await.remove(&negative_key);
|
||||||
|
Ok(Some(bytes))
|
||||||
|
}
|
||||||
|
Err(ProtocolError::ServerRejected { .. }) => {
|
||||||
|
self.negative_cache
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(negative_key, Instant::now() + NEGATIVE_CACHE_TTL);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
Err(error) => Err(FileTransferError::Protocol(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn finish_in_flight(
|
||||||
|
&self,
|
||||||
|
avatar_hash: &str,
|
||||||
|
result: &Result<Option<Vec<u8>>, FileTransferError>,
|
||||||
|
) {
|
||||||
|
let waiters = self.in_flight.lock().await.remove(avatar_hash).unwrap_or_default();
|
||||||
|
for waiter in waiters {
|
||||||
|
if waiter.send(result.clone()).is_err() {
|
||||||
|
warn!(target: "chanora_core", avatar_hash, "avatar download waiter dropped");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn is_negative_cache_hit(&self, avatar_hash: &str) -> bool {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut negative_cache = self.negative_cache.lock().await;
|
||||||
|
match negative_cache.get(avatar_hash).copied() {
|
||||||
|
Some(expires_at) if expires_at > now => true,
|
||||||
|
Some(_) => {
|
||||||
|
negative_cache.remove(avatar_hash);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clone_protocol_error(error: &ProtocolError) -> ProtocolError {
|
||||||
|
match error {
|
||||||
|
ProtocolError::Invalid(message) => ProtocolError::Invalid(message.clone()),
|
||||||
|
ProtocolError::DnsFailed { host, reason } => ProtocolError::DnsFailed {
|
||||||
|
host: host.clone(),
|
||||||
|
reason: reason.clone(),
|
||||||
|
},
|
||||||
|
ProtocolError::Connect(message) => ProtocolError::Connect(message.clone()),
|
||||||
|
ProtocolError::DisconnectedEarly(message) => {
|
||||||
|
ProtocolError::DisconnectedEarly(message.clone())
|
||||||
|
}
|
||||||
|
ProtocolError::Lost(message) => ProtocolError::Lost(message.clone()),
|
||||||
|
ProtocolError::Identity(message) => ProtocolError::Identity(message.clone()),
|
||||||
|
ProtocolError::Timeout => ProtocolError::Timeout,
|
||||||
|
ProtocolError::ServerRejected { code, message } => ProtocolError::ServerRejected {
|
||||||
|
code: *code,
|
||||||
|
message: message.clone(),
|
||||||
|
},
|
||||||
|
ProtocolError::Backend(message) => ProtocolError::Backend(message.clone()),
|
||||||
|
ProtocolError::FileTransfer(message) => ProtocolError::FileTransfer(message.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clone_blob_cache_error(error: &BlobCacheError) -> BlobCacheError {
|
||||||
|
match error {
|
||||||
|
BlobCacheError::Io(message) => BlobCacheError::Io(message.clone()),
|
||||||
|
BlobCacheError::InvalidKey(message) => BlobCacheError::InvalidKey(message.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn test_cache_dir(name: &str) -> std::path::PathBuf {
|
||||||
|
let mut path = std::env::temp_dir();
|
||||||
|
let nanos = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
path.push(format!("chanora-core-file-transfer-{name}-{nanos}"));
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn returns_cached_avatar_without_connection() {
|
||||||
|
let cache_dir = test_cache_dir("cache-hit");
|
||||||
|
let cache = BlobCache::new(&cache_dir, 1024).unwrap();
|
||||||
|
cache.put(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", b"avatar")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let service = FileTransferService::new(cache, Arc::new(Mutex::new(None)));
|
||||||
|
|
||||||
|
let avatar = service
|
||||||
|
.get_avatar("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "client")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(avatar, Some(b"avatar".to_vec()));
|
||||||
|
let _ = std::fs::remove_dir_all(cache_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn negative_cache_short_circuits_not_connected() {
|
||||||
|
let cache_dir = test_cache_dir("negative-cache");
|
||||||
|
let cache = BlobCache::new(&cache_dir, 1024).unwrap();
|
||||||
|
let service = FileTransferService::new(cache, Arc::new(Mutex::new(None)));
|
||||||
|
service
|
||||||
|
.negative_cache
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6".to_string(), Instant::now() + NEGATIVE_CACHE_TTL);
|
||||||
|
|
||||||
|
let avatar = service
|
||||||
|
.get_avatar("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "client")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(avatar, None);
|
||||||
|
let _ = std::fs::remove_dir_all(cache_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn returns_cached_icon_without_connection() {
|
||||||
|
let cache_dir = test_cache_dir("icon-cache-hit");
|
||||||
|
let cache = BlobCache::new(&cache_dir, 1024).unwrap();
|
||||||
|
cache.put(PREFIX_ICON, "12345", b"icon").await.unwrap();
|
||||||
|
let service = FileTransferService::new(cache, Arc::new(Mutex::new(None)));
|
||||||
|
|
||||||
|
let icon = service.get_icon(12345).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(icon, Some(b"icon".to_vec()));
|
||||||
|
let _ = std::fs::remove_dir_all(cache_dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
+178
-65
@@ -53,6 +53,7 @@ use chanora_state::channel_join::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
mod events;
|
mod events;
|
||||||
|
mod file_transfer;
|
||||||
mod network_diagnostics;
|
mod network_diagnostics;
|
||||||
pub mod ptt;
|
pub mod ptt;
|
||||||
|
|
||||||
@@ -67,14 +68,15 @@ pub use chanora_diagnostics::{
|
|||||||
RedactingLogLayer, Redactor, DEFAULT_LOG_CAPACITY,
|
RedactingLogLayer, Redactor, DEFAULT_LOG_CAPACITY,
|
||||||
};
|
};
|
||||||
pub use chanora_protocol::{
|
pub use chanora_protocol::{
|
||||||
ChannelInfo, ChatMessage, ClientInfo, ClientProfile, ConnectConfig, DisconnectReason,
|
validate_nickname, ChannelInfo, ChatMessage, ClientInfo, ClientProfile, ConnectConfig,
|
||||||
MessageTarget, PokeStrength, ProtocolError, ServerActivity, ServerSnapshot,
|
DisconnectReason, MessageTarget, PokeStrength, ProtocolError, ServerActivity, ServerSnapshot,
|
||||||
};
|
};
|
||||||
pub use chanora_storage::{Bookmark, BookmarkRepository, IdentityFileStore};
|
pub use chanora_storage::{Bookmark, BookmarkRepository, IdentityFileStore};
|
||||||
pub use events::{
|
pub use events::{
|
||||||
NetworkState, PersistedPttBinding, PttDescriptorSnapshot, SessionEvent, VoiceJoinErrorCode,
|
NetworkState, PersistedPttBinding, PttDescriptorSnapshot, SessionEvent, VoiceJoinErrorCode,
|
||||||
VoiceJoinSyncState,
|
VoiceJoinSyncState,
|
||||||
};
|
};
|
||||||
|
pub use file_transfer::FileTransferError;
|
||||||
use network_diagnostics::NetworkDiagnostics;
|
use network_diagnostics::NetworkDiagnostics;
|
||||||
|
|
||||||
/// Errors that can arise during top-level orchestration.
|
/// Errors that can arise during top-level orchestration.
|
||||||
@@ -92,6 +94,12 @@ pub enum CoreError {
|
|||||||
/// Storage error.
|
/// Storage error.
|
||||||
#[error("storage: {0}")]
|
#[error("storage: {0}")]
|
||||||
Storage(#[from] chanora_storage::StorageError),
|
Storage(#[from] chanora_storage::StorageError),
|
||||||
|
/// Blob-cache failure.
|
||||||
|
#[error("cache: {0}")]
|
||||||
|
Cache(#[from] chanora_cache::BlobCacheError),
|
||||||
|
/// File-transfer failure.
|
||||||
|
#[error("file transfer: {0}")]
|
||||||
|
FileTransfer(#[from] FileTransferError),
|
||||||
/// Diagnostics error.
|
/// Diagnostics error.
|
||||||
#[error("diagnostics: {0}")]
|
#[error("diagnostics: {0}")]
|
||||||
Diagnostics(#[from] chanora_diagnostics::DiagnosticsError),
|
Diagnostics(#[from] chanora_diagnostics::DiagnosticsError),
|
||||||
@@ -130,7 +138,6 @@ struct SupervisorInner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct ConnectedState {
|
struct ConnectedState {
|
||||||
protocol: chanora_protocol::ProtocolClient,
|
|
||||||
audio: Option<chanora_audio::AudioEngine>,
|
audio: Option<chanora_audio::AudioEngine>,
|
||||||
/// Active PTT controller (SDD-088). Owns the platform input
|
/// Active PTT controller (SDD-088). Owns the platform input
|
||||||
/// backend, the active binding, and the capability watch
|
/// backend, the active binding, and the capability watch
|
||||||
@@ -197,6 +204,8 @@ pub struct ChanoraSession {
|
|||||||
/// extension). Lives alongside the identity file. Wired by
|
/// extension). Lives alongside the identity file. Wired by
|
||||||
/// [`Self::init_storage`].
|
/// [`Self::init_storage`].
|
||||||
bookmark_store: Arc<Mutex<Option<BookmarkRepository>>>,
|
bookmark_store: Arc<Mutex<Option<BookmarkRepository>>>,
|
||||||
|
protocol: Arc<Mutex<Option<chanora_protocol::ProtocolClient>>>,
|
||||||
|
file_transfer: Arc<Mutex<Option<Arc<file_transfer::FileTransferService>>>>,
|
||||||
/// Invisible server-address prefetch cache. Warmed by Flutter typing
|
/// Invisible server-address prefetch cache. Warmed by Flutter typing
|
||||||
/// but validated by Rust before Connect can reuse it.
|
/// but validated by Rust before Connect can reuse it.
|
||||||
server_prefetch: ServerPrefetcher,
|
server_prefetch: ServerPrefetcher,
|
||||||
@@ -252,6 +261,8 @@ impl ChanoraSession {
|
|||||||
network_tx,
|
network_tx,
|
||||||
identity_store: Arc::new(Mutex::new(None)),
|
identity_store: Arc::new(Mutex::new(None)),
|
||||||
bookmark_store: Arc::new(Mutex::new(None)),
|
bookmark_store: Arc::new(Mutex::new(None)),
|
||||||
|
protocol: Arc::new(Mutex::new(None)),
|
||||||
|
file_transfer: Arc::new(Mutex::new(None)),
|
||||||
server_prefetch: ServerPrefetcher::new(),
|
server_prefetch: ServerPrefetcher::new(),
|
||||||
voice_selector: selector,
|
voice_selector: selector,
|
||||||
release_tail,
|
release_tail,
|
||||||
@@ -273,6 +284,17 @@ impl ChanoraSession {
|
|||||||
ConnectionEpoch(epoch)
|
ConnectionEpoch(epoch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn store_protocol(&self, client: Option<chanora_protocol::ProtocolClient>) {
|
||||||
|
// Always update the shared Arc. The FileTransferService holds
|
||||||
|
// the same Arc, so it sees the new client automatically — no
|
||||||
|
// separate set_protocol call needed.
|
||||||
|
*self.protocol.lock().await = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn take_protocol(&self) -> Option<chanora_protocol::ProtocolClient> {
|
||||||
|
self.protocol.lock().await.take()
|
||||||
|
}
|
||||||
|
|
||||||
/// Wire a directory-backed identity store. Called by the bridge
|
/// Wire a directory-backed identity store. Called by the bridge
|
||||||
/// during `bridge_init` once Flutter has resolved the platform
|
/// during `bridge_init` once Flutter has resolved the platform
|
||||||
/// app-private storage directory. Subsequent [`Self::connect`]
|
/// app-private storage directory. Subsequent [`Self::connect`]
|
||||||
@@ -337,6 +359,65 @@ impl ChanoraSession {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Configure the blob cache root.
|
||||||
|
pub async fn init_cache(&self, dir: &str) -> Result<(), CoreError> {
|
||||||
|
let cache = chanora_cache::BlobCache::new(dir, 100 * 1024 * 1024)?;
|
||||||
|
cache.evict().await?;
|
||||||
|
let service = Arc::new(file_transfer::FileTransferService::new(
|
||||||
|
cache,
|
||||||
|
self.protocol.clone(),
|
||||||
|
));
|
||||||
|
let mut guard = self.file_transfer.lock().await;
|
||||||
|
*guard = Some(service);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve avatar bytes.
|
||||||
|
pub async fn get_avatar(
|
||||||
|
&self,
|
||||||
|
avatar_hash: &str,
|
||||||
|
client_uid: &str,
|
||||||
|
) -> Result<Option<Vec<u8>>, CoreError> {
|
||||||
|
let service = { self.file_transfer.lock().await.clone() };
|
||||||
|
if let Some(service) = service {
|
||||||
|
return Ok(service.get_avatar(avatar_hash, client_uid).await?);
|
||||||
|
}
|
||||||
|
|
||||||
|
let protocol = self.protocol.lock().await;
|
||||||
|
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
|
||||||
|
Ok(Some(client.download_avatar(client_uid).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve icon bytes.
|
||||||
|
pub async fn get_icon(&self, icon_id: u64) -> Result<Option<Vec<u8>>, CoreError> {
|
||||||
|
let service = { self.file_transfer.lock().await.clone() };
|
||||||
|
if let Some(service) = service {
|
||||||
|
return Ok(service.get_icon(icon_id).await?);
|
||||||
|
}
|
||||||
|
|
||||||
|
let protocol = self.protocol.lock().await;
|
||||||
|
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
|
||||||
|
Ok(Some(client.download_icon(icon_id).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Purge cached protocol-owned assets.
|
||||||
|
pub async fn clear_cache(&self) -> Result<(), CoreError> {
|
||||||
|
let service = { self.file_transfer.lock().await.clone() };
|
||||||
|
if let Some(service) = service {
|
||||||
|
service.clear_cache().await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Report the configured blob-cache size.
|
||||||
|
pub async fn cache_size(&self) -> Result<u64, CoreError> {
|
||||||
|
let service = { self.file_transfer.lock().await.clone() };
|
||||||
|
match service {
|
||||||
|
Some(service) => Ok(service.cache_size().await?),
|
||||||
|
None => Ok(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// List persisted bookmarks. Returns an empty list if the store
|
/// List persisted bookmarks. Returns an empty list if the store
|
||||||
/// has not been wired or has no entries.
|
/// has not been wired or has no entries.
|
||||||
pub async fn list_bookmarks(&self) -> Result<Vec<Bookmark>, CoreError> {
|
pub async fn list_bookmarks(&self) -> Result<Vec<Bookmark>, CoreError> {
|
||||||
@@ -514,6 +595,7 @@ impl ChanoraSession {
|
|||||||
|
|
||||||
let supervisor = tokio::spawn(supervisor_loop(SupervisorContext {
|
let supervisor = tokio::spawn(supervisor_loop(SupervisorContext {
|
||||||
state_arc: self.inner.clone(),
|
state_arc: self.inner.clone(),
|
||||||
|
protocol: self.protocol.clone(),
|
||||||
events_tx: self.events_tx.clone(),
|
events_tx: self.events_tx.clone(),
|
||||||
initial_cfg: cfg.clone(),
|
initial_cfg: cfg.clone(),
|
||||||
initial_lost_rx: lost_rx,
|
initial_lost_rx: lost_rx,
|
||||||
@@ -568,9 +650,9 @@ impl ChanoraSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
spawn_event_forwarders(&client, &self.events_tx);
|
spawn_event_forwarders(&client, &self.events_tx);
|
||||||
|
self.store_protocol(Some(client)).await;
|
||||||
|
|
||||||
*guard = Some(ConnectedState {
|
*guard = Some(ConnectedState {
|
||||||
protocol: client,
|
|
||||||
audio: None,
|
audio: None,
|
||||||
ptt_controller: None,
|
ptt_controller: None,
|
||||||
cancel_tx: Some(cancel_tx),
|
cancel_tx: Some(cancel_tx),
|
||||||
@@ -631,9 +713,13 @@ impl ChanoraSession {
|
|||||||
|
|
||||||
/// Return a fresh snapshot of the current server state.
|
/// Return a fresh snapshot of the current server state.
|
||||||
pub async fn snapshot(&self) -> Result<ServerSnapshot, CoreError> {
|
pub async fn snapshot(&self) -> Result<ServerSnapshot, CoreError> {
|
||||||
|
let snap = {
|
||||||
|
let protocol = self.protocol.lock().await;
|
||||||
|
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
|
||||||
|
client.snapshot().await?
|
||||||
|
};
|
||||||
let mut guard = self.inner.lock().await;
|
let mut guard = self.inner.lock().await;
|
||||||
let state = guard.as_mut().ok_or(CoreError::NotConnected)?;
|
let state = guard.as_mut().ok_or(CoreError::NotConnected)?;
|
||||||
let snap = state.protocol.snapshot().await?;
|
|
||||||
let current_channel = self
|
let current_channel = self
|
||||||
.find_own_in(&snap)
|
.find_own_in(&snap)
|
||||||
.await
|
.await
|
||||||
@@ -661,9 +747,9 @@ impl ChanoraSession {
|
|||||||
|
|
||||||
/// Fetch richer profile and live connection details for one online client.
|
/// Fetch richer profile and live connection details for one online client.
|
||||||
pub async fn client_profile(&self, client_id: u64) -> Result<ClientProfile, CoreError> {
|
pub async fn client_profile(&self, client_id: u64) -> Result<ClientProfile, CoreError> {
|
||||||
let guard = self.inner.lock().await;
|
let protocol = self.protocol.lock().await;
|
||||||
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
|
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
|
||||||
Ok(state.protocol.client_profile(client_id).await?)
|
Ok(client.client_profile(client_id).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True if a connection is currently active.
|
/// True if a connection is currently active.
|
||||||
@@ -680,9 +766,9 @@ impl ChanoraSession {
|
|||||||
if !should_dispatch_text_message(&message, &target) {
|
if !should_dispatch_text_message(&message, &target) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let guard = self.inner.lock().await;
|
let protocol = self.protocol.lock().await;
|
||||||
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
|
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
|
||||||
state.protocol.send_text_message(message, target).await?;
|
client.send_text_message(message, target).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -741,11 +827,16 @@ impl ChanoraSession {
|
|||||||
// session permanently unable to restart audio without a
|
// session permanently unable to restart audio without a
|
||||||
// reconnect (the user saw "voice_in already taken" on the
|
// reconnect (the user saw "voice_in already taken" on the
|
||||||
// second channel switch).
|
// second channel switch).
|
||||||
let voice_out = state.protocol.voice_out();
|
let (voice_out, voice_in) = {
|
||||||
let voice_in = state
|
let protocol = self.protocol.lock().await;
|
||||||
.protocol
|
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
|
||||||
.take_voice_in()
|
(
|
||||||
.ok_or(CoreError::Invariant("voice_in already taken"))?;
|
client.voice_out(),
|
||||||
|
client
|
||||||
|
.take_voice_in()
|
||||||
|
.ok_or(CoreError::Invariant("voice_in already taken"))?,
|
||||||
|
)
|
||||||
|
};
|
||||||
let gate = AudioTransmitGate::new(cfg.ptt_initial);
|
let gate = AudioTransmitGate::new(cfg.ptt_initial);
|
||||||
cfg.voice_activity_selector = Some(self.voice_selector.clone());
|
cfg.voice_activity_selector = Some(self.voice_selector.clone());
|
||||||
let new_engine = match chanora_audio::AudioEngine::start_with_gate(
|
let new_engine = match chanora_audio::AudioEngine::start_with_gate(
|
||||||
@@ -833,21 +924,13 @@ impl ChanoraSession {
|
|||||||
// dialog) re-publish through the controller's
|
// dialog) re-publish through the controller's
|
||||||
// descriptor-watch.
|
// descriptor-watch.
|
||||||
let initial_desc = controller.descriptor().await;
|
let initial_desc = controller.descriptor().await;
|
||||||
let _ = self.events_tx.send(SessionEvent::PttCapability {
|
let _ = self.events_tx.send(SessionEvent::ptt_capability_from_descriptor(&initial_desc));
|
||||||
level: initial_desc.level.as_str().to_string(),
|
|
||||||
backend_id: initial_desc.backend_id.to_string(),
|
|
||||||
bound_input_class: initial_desc.bound_input_class.unwrap_or("").to_string(),
|
|
||||||
});
|
|
||||||
let mut watch_rx = controller.descriptor_watch();
|
let mut watch_rx = controller.descriptor_watch();
|
||||||
let events_tx = self.events_tx.clone();
|
let events_tx = self.events_tx.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while watch_rx.changed().await.is_ok() {
|
while watch_rx.changed().await.is_ok() {
|
||||||
let d = watch_rx.borrow_and_update().clone();
|
let d = watch_rx.borrow_and_update().clone();
|
||||||
let _ = events_tx.send(SessionEvent::PttCapability {
|
let _ = events_tx.send(SessionEvent::ptt_capability_from_descriptor(&d));
|
||||||
level: d.level.as_str().to_string(),
|
|
||||||
backend_id: d.backend_id.to_string(),
|
|
||||||
bound_input_class: d.bound_input_class.unwrap_or("").to_string(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -909,11 +992,7 @@ impl ChanoraSession {
|
|||||||
if let Some(state) = guard.as_ref() {
|
if let Some(state) = guard.as_ref() {
|
||||||
if let Some(controller) = state.ptt_controller.as_ref() {
|
if let Some(controller) = state.ptt_controller.as_ref() {
|
||||||
let desc = controller.set_binding(binding).await?;
|
let desc = controller.set_binding(binding).await?;
|
||||||
let _ = self.events_tx.send(SessionEvent::PttCapability {
|
let _ = self.events_tx.send(SessionEvent::ptt_capability_from_descriptor(&desc));
|
||||||
level: desc.level.as_str().to_string(),
|
|
||||||
backend_id: desc.backend_id.to_string(),
|
|
||||||
bound_input_class: desc.bound_input_class.unwrap_or("").to_string(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -976,10 +1055,11 @@ impl ChanoraSession {
|
|||||||
let password_to_send = requested_password
|
let password_to_send = requested_password
|
||||||
.clone()
|
.clone()
|
||||||
.or_else(|| state.channel_passwords.get(&channel_id).cloned());
|
.or_else(|| state.channel_passwords.get(&channel_id).cloned());
|
||||||
state
|
{
|
||||||
.protocol
|
let protocol = self.protocol.lock().await;
|
||||||
.move_to_channel(channel_id, password_to_send)
|
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
|
||||||
.await?;
|
client.move_to_channel(channel_id, password_to_send).await?;
|
||||||
|
}
|
||||||
if let Some(pw) = requested_password {
|
if let Some(pw) = requested_password {
|
||||||
state.channel_passwords.insert(channel_id, pw);
|
state.channel_passwords.insert(channel_id, pw);
|
||||||
}
|
}
|
||||||
@@ -1004,7 +1084,11 @@ impl ChanoraSession {
|
|||||||
if let Some(muted) = output {
|
if let Some(muted) = output {
|
||||||
state.local_output_muted = muted;
|
state.local_output_muted = muted;
|
||||||
}
|
}
|
||||||
state.protocol.set_muted(input, output).await?;
|
{
|
||||||
|
let protocol = self.protocol.lock().await;
|
||||||
|
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
|
||||||
|
client.set_muted(input, output).await?;
|
||||||
|
}
|
||||||
if let Some(muted) = output {
|
if let Some(muted) = output {
|
||||||
if let Some(audio) = state.audio.as_ref() {
|
if let Some(audio) = state.audio.as_ref() {
|
||||||
audio.set_output_muted(muted);
|
audio.set_output_muted(muted);
|
||||||
@@ -1029,6 +1113,21 @@ impl ChanoraSession {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read the current master output gain. Returns `1.0` (unity) if
|
||||||
|
/// audio is not started.
|
||||||
|
pub async fn output_gain(&self) -> f32 {
|
||||||
|
let guard = self.inner.lock().await;
|
||||||
|
let state = match guard.as_ref() {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return 1.0,
|
||||||
|
};
|
||||||
|
let audio = match state.audio.as_ref() {
|
||||||
|
Some(a) => a,
|
||||||
|
None => return 1.0,
|
||||||
|
};
|
||||||
|
audio.output_gain()
|
||||||
|
}
|
||||||
|
|
||||||
/// Set per-client output volume (SRS-075). `1.0` is unity, `0.0`
|
/// Set per-client output volume (SRS-075). `1.0` is unity, `0.0`
|
||||||
/// mutes. No-op if audio is not started or client has no active
|
/// mutes. No-op if audio is not started or client has no active
|
||||||
/// voice queue.
|
/// voice queue.
|
||||||
@@ -1116,11 +1215,19 @@ impl ChanoraSession {
|
|||||||
/// Configure the preferred Silero ONNX VAD model path on platforms
|
/// Configure the preferred Silero ONNX VAD model path on platforms
|
||||||
/// that ship the ONNX detector.
|
/// that ship the ONNX detector.
|
||||||
///
|
///
|
||||||
/// This does not require an active connection. Running non-iOS
|
/// Set the Silero VAD model path on supported platforms.
|
||||||
/// audio backends can observe the model-path epoch and reload on
|
///
|
||||||
/// the next capture frame when Silero is selected.
|
/// This does not require an active connection. On desktop, the audio
|
||||||
|
/// engine immediately reloads the Silero ONNX worker if one is active
|
||||||
|
/// or if the model file is now available at the new path.
|
||||||
pub async fn set_vad_model_path(&self, path: String) -> Result<(), CoreError> {
|
pub async fn set_vad_model_path(&self, path: String) -> Result<(), CoreError> {
|
||||||
chanora_audio::vad::set_silero_model_path(&path)?;
|
chanora_audio::vad::set_silero_model_path(&path)?;
|
||||||
|
let guard = self.inner.lock().await;
|
||||||
|
if let Some(state) = guard.as_ref() {
|
||||||
|
if let Some(audio) = state.audio.as_ref() {
|
||||||
|
audio.reload_audio_processing_config()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1319,11 +1426,12 @@ impl ChanoraSession {
|
|||||||
let password_to_send = requested_password
|
let password_to_send = requested_password
|
||||||
.clone()
|
.clone()
|
||||||
.or_else(|| state.channel_passwords.get(&channel_id).cloned());
|
.or_else(|| state.channel_passwords.get(&channel_id).cloned());
|
||||||
if let Err(e) = state
|
let move_result = {
|
||||||
.protocol
|
let protocol = self.protocol.lock().await;
|
||||||
.queue_move_to_channel(channel_id, password_to_send)
|
let client = protocol.as_ref().ok_or(CoreError::NotConnected)?;
|
||||||
.await
|
client.queue_move_to_channel(channel_id, password_to_send).await
|
||||||
{
|
};
|
||||||
|
if let Err(e) = move_result {
|
||||||
// TS3 error 0x0302 = `channel_already_in`: we're already
|
// TS3 error 0x0302 = `channel_already_in`: we're already
|
||||||
// in the target channel, so this is a no-op success.
|
// in the target channel, so this is a no-op success.
|
||||||
// Rolling `in_channel` back to false would break PTT
|
// Rolling `in_channel` back to false would break PTT
|
||||||
@@ -1585,7 +1693,9 @@ impl ChanoraSession {
|
|||||||
audio.stop();
|
audio.stop();
|
||||||
let _ = self.events_tx.send(SessionEvent::AudioStopped);
|
let _ = self.events_tx.send(SessionEvent::AudioStopped);
|
||||||
}
|
}
|
||||||
state.protocol.disconnect().await;
|
if let Some(protocol) = self.take_protocol().await {
|
||||||
|
protocol.disconnect().await;
|
||||||
|
}
|
||||||
// Wait for the supervisor to wind down so we don't race
|
// Wait for the supervisor to wind down so we don't race
|
||||||
// a redial against the explicit disconnect.
|
// a redial against the explicit disconnect.
|
||||||
if let Some(handle) = state.supervisor.take() {
|
if let Some(handle) = state.supervisor.take() {
|
||||||
@@ -1647,6 +1757,7 @@ async fn await_supervisor_shutdown(mut handle: JoinHandle<()>, timeout_duration:
|
|||||||
|
|
||||||
struct SupervisorContext {
|
struct SupervisorContext {
|
||||||
state_arc: Arc<Mutex<Option<ConnectedState>>>,
|
state_arc: Arc<Mutex<Option<ConnectedState>>>,
|
||||||
|
protocol: Arc<Mutex<Option<chanora_protocol::ProtocolClient>>>,
|
||||||
events_tx: broadcast::Sender<SessionEvent>,
|
events_tx: broadcast::Sender<SessionEvent>,
|
||||||
initial_cfg: ConnectConfig,
|
initial_cfg: ConnectConfig,
|
||||||
initial_lost_rx: oneshot::Receiver<chanora_protocol::DisconnectReason>,
|
initial_lost_rx: oneshot::Receiver<chanora_protocol::DisconnectReason>,
|
||||||
@@ -1781,6 +1892,7 @@ fn spawn_event_forwarders(
|
|||||||
async fn supervisor_loop(ctx: SupervisorContext) {
|
async fn supervisor_loop(ctx: SupervisorContext) {
|
||||||
let SupervisorContext {
|
let SupervisorContext {
|
||||||
state_arc,
|
state_arc,
|
||||||
|
protocol,
|
||||||
events_tx,
|
events_tx,
|
||||||
initial_cfg,
|
initial_cfg,
|
||||||
initial_lost_rx,
|
initial_lost_rx,
|
||||||
@@ -2050,20 +2162,21 @@ async fn supervisor_loop(ctx: SupervisorContext) {
|
|||||||
|
|
||||||
// Reattach into the session state.
|
// Reattach into the session state.
|
||||||
let restart_audio = {
|
let restart_audio = {
|
||||||
|
let guard = state_arc.lock().await;
|
||||||
|
if guard.is_none() {
|
||||||
|
// Session was disposed mid-reconnect.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
drop(guard);
|
||||||
|
let old = protocol.lock().await.replace(new_client);
|
||||||
|
drop(old);
|
||||||
let mut guard = state_arc.lock().await;
|
let mut guard = state_arc.lock().await;
|
||||||
let state = match guard.as_mut() {
|
let state = match guard.as_mut() {
|
||||||
Some(s) => s,
|
Some(s) => s,
|
||||||
None => {
|
None => {
|
||||||
// Session was disposed mid-reconnect.
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Replace the dead protocol client with the new one.
|
|
||||||
// The old client's background task either already
|
|
||||||
// exited (loss notifier fired) or will exit when
|
|
||||||
// its request channel drops (watchdog path).
|
|
||||||
let old = std::mem::replace(&mut state.protocol, new_client);
|
|
||||||
drop(old);
|
|
||||||
|
|
||||||
let _ = channel_join::reduce(
|
let _ = channel_join::reduce(
|
||||||
&mut state.join_state,
|
&mut state.join_state,
|
||||||
@@ -2093,9 +2206,9 @@ async fn supervisor_loop(ctx: SupervisorContext) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
{
|
{
|
||||||
let guard = state_arc.lock().await;
|
let protocol = protocol.lock().await;
|
||||||
if let Some(state) = guard.as_ref() {
|
if let Some(client) = protocol.as_ref() {
|
||||||
spawn_event_forwarders(&state.protocol, &events_tx);
|
spawn_event_forwarders(client, &events_tx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2107,8 +2220,15 @@ async fn supervisor_loop(ctx: SupervisorContext) {
|
|||||||
};
|
};
|
||||||
let mut guard = state_arc.lock().await;
|
let mut guard = state_arc.lock().await;
|
||||||
if let Some(state) = guard.as_mut() {
|
if let Some(state) = guard.as_mut() {
|
||||||
let voice_out = state.protocol.voice_out();
|
let (voice_out, voice_in) = {
|
||||||
if let Some(voice_in) = state.protocol.take_voice_in() {
|
let protocol = protocol.lock().await;
|
||||||
|
let client = match protocol.as_ref() {
|
||||||
|
Some(client) => client,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
(client.voice_out(), client.take_voice_in())
|
||||||
|
};
|
||||||
|
if let Some(voice_in) = voice_in {
|
||||||
let gate = chanora_audio::AudioTransmitGate::new(
|
let gate = chanora_audio::AudioTransmitGate::new(
|
||||||
audio_cfg.ptt_initial,
|
audio_cfg.ptt_initial,
|
||||||
);
|
);
|
||||||
@@ -2152,14 +2272,7 @@ async fn supervisor_loop(ctx: SupervisorContext) {
|
|||||||
// capability (SRS-196 / SDD-091).
|
// capability (SRS-196 / SDD-091).
|
||||||
let d = controller.descriptor().await;
|
let d = controller.descriptor().await;
|
||||||
let _ =
|
let _ =
|
||||||
events_tx.send(SessionEvent::PttCapability {
|
events_tx.send(SessionEvent::ptt_capability_from_descriptor(&d));
|
||||||
level: d.level.as_str().to_string(),
|
|
||||||
backend_id: d.backend_id.to_string(),
|
|
||||||
bound_input_class: d
|
|
||||||
.bound_input_class
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(
|
warn!(
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
use std::env;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_avatar_returns_cached_bytes_without_connection() {
|
||||||
|
let tmp = mktemp("chanora_core_avatar_cache_test");
|
||||||
|
let session = chanora_core::ChanoraSession::new();
|
||||||
|
session.init_cache(tmp.to_str().unwrap()).await.unwrap();
|
||||||
|
|
||||||
|
let cache = chanora_cache::BlobCache::new(&tmp, 100 * 1024 * 1024).unwrap();
|
||||||
|
let hash = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6";
|
||||||
|
cache
|
||||||
|
.put(chanora_cache::PREFIX_AVATAR, hash, b"avatar-bytes")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let bytes = session
|
||||||
|
.get_avatar(hash, "client-uid")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(bytes, b"avatar-bytes");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&tmp);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_avatar_without_cache_or_connection_returns_not_connected() {
|
||||||
|
let session = chanora_core::ChanoraSession::new();
|
||||||
|
let err = session
|
||||||
|
.get_avatar("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "client-uid")
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(err, chanora_core::CoreError::NotConnected));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mktemp(label: &str) -> PathBuf {
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let p = env::temp_dir()
|
||||||
|
.join(label)
|
||||||
|
.join(format!("{}-{nanos}", process::id()));
|
||||||
|
std::fs::create_dir_all(&p).unwrap();
|
||||||
|
p
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ coreaudio-rs = "0.14"
|
|||||||
# on the main queue to avoid the VPIO RPC timeout on iOS simulator.
|
# on the main queue to avoid the VPIO RPC timeout on iOS simulator.
|
||||||
dispatch2 = "0.3"
|
dispatch2 = "0.3"
|
||||||
|
|
||||||
[target.'cfg(not(target_os = "ios"))'.dependencies]
|
[target.'cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))'.dependencies]
|
||||||
ort = { version = "2.0.0-rc.12", default-features = false, features = ["load-dynamic", "ndarray", "api-24"] }
|
ort = { version = "2.0.0-rc.12", default-features = false, features = ["load-dynamic", "ndarray", "api-24"] }
|
||||||
|
|
||||||
[target.'cfg(target_os = "android")'.dependencies]
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# chanora_audio
|
||||||
|
|
||||||
|
Real-time audio subsystem: capture, Opus encoding/decoding, voice rendering, PTT gating, and audio processing. Promoted from `poc/audio-capture-playback-spike`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Engine
|
||||||
|
|
||||||
|
- **`AudioEngine`** — the primary type. Starts a platform audio backend (capture + playback), wires an `AudioTransmitGate` for PTT gating, and feeds encoded Opus frames to the protocol layer via `voice_out`. Inbound voice packets are decoded and mixed by `tsclientlib::audio::AudioHandler` and pulled by the platform output callback at 48 kHz stereo.
|
||||||
|
|
||||||
|
### Platform backends (cfg-gated)
|
||||||
|
|
||||||
|
| Target | Backend | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Android | Oboe (via `android_voice_unit`) | Requires `ndk_context` before start |
|
||||||
|
| iOS/macOS | Apple VoiceProcessingIO (`ios_voice_unit`) | Platform AEC/AGC/NS, route-change handling |
|
||||||
|
| Linux | SDL (`sdl_output`) | PulseAudio/ALSA via SDL |
|
||||||
|
| Other desktop | cpal | Fallback |
|
||||||
|
|
||||||
|
### Key modules
|
||||||
|
|
||||||
|
- **`audio_processing`** — P1 audio processing config, stats, route policy, effect ownership (Platform/Sonora/WebRTC APM)
|
||||||
|
- **`opus_voice`** — 20 ms / 48 kHz mono Opus encode/decode via `audiopus`
|
||||||
|
- **`transmit_mode`** — `TransmitMode` enum: Ptt, Continuous, VoiceActivity
|
||||||
|
- **`transmit_selector`** — `TransmitModeSelector` combining mode, hard-mute, PTT gate, permission gate, and in-channel state
|
||||||
|
- **`ptt`** — `AudioTransmitGate` (atomic bool), `PttCapabilityLevel`, `PttBackendDescriptor`
|
||||||
|
- **`ptt_backends`** — platform PTT backends: `DesktopPttBackend` (Linux portal), `FocusedPttBackend` (in-app fallback)
|
||||||
|
- **`release_tail`** — `ReleaseTailTimer` for configurable PTT release delay (default 200 ms, max 500 ms)
|
||||||
|
- **`vad`** — Voice-activity detection: Silero ONNX (desktop), WebRTC fallback, energy debug
|
||||||
|
- **`voice_render`** — mixes per-client decoded f32 PCM into the output buffer
|
||||||
|
- **`debug_wav`** — optional WAV file dump for diagnostics (DIAG_002/003)
|
||||||
|
- **`mobile_voice_backend`** — shared mobile voice-unit lifecycle abstraction
|
||||||
|
- **`frame`** — frame-aligned buffer utilities
|
||||||
|
|
||||||
|
## Public API Summary
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
| Type | Role |
|
||||||
|
|---|---|
|
||||||
|
| `AudioEngine` | Start/stop audio, set gain/mute/volume, read stats |
|
||||||
|
| `AudioEngineConfig` | Capture/playback device selection, PTT initial state, processing config |
|
||||||
|
| `AudioDeviceInfo` / `AudioDeviceList` | Device enumeration |
|
||||||
|
| `AudioTransmitGate` | Atomic PTT gate |
|
||||||
|
| `TransmitMode` / `TransmitModeSelector` | Mode selection with hard-mute clamp |
|
||||||
|
| `ReleaseTailTimer` | Configurable release delay (SDD-096) |
|
||||||
|
| `PttBinding` / `PttInputClass` | PTT key binding types |
|
||||||
|
| `PttBackendDescriptor` / `PttCapabilityLevel` | Capability query |
|
||||||
|
| `AudioProcessingConfig` / `AudioProcessingStats` | P1 processing control and telemetry |
|
||||||
|
| `AudioRoute` | Speaker/Earpiece/Wired/Bluetooth enum |
|
||||||
|
| `AudioEffects` | Effect toggles (AEC/AGC/NS/HPF), all enabled by default (DEC-007..010) |
|
||||||
|
| `AudioError` | Typed error catalogue |
|
||||||
|
|
||||||
|
### Key functions
|
||||||
|
|
||||||
|
- `AudioEngine::start_with_gate(cfg, voice_out, voice_in, gate)` — construct and start
|
||||||
|
- `AudioEngine::stop()` — tear down
|
||||||
|
- `list_audio_devices()` — enumerate available input/output devices
|
||||||
|
- `select_ptt_backend()` — choose the best PTT backend for the current platform
|
||||||
|
|
||||||
|
## Platform notes
|
||||||
|
|
||||||
|
- Android requires `initChanoraContext` (NDK context) before engine start.
|
||||||
|
- iOS/macOS uses VoiceProcessingIO for platform AEC/AGC/NS in the default route.
|
||||||
|
- Desktop can use Silero ONNX VAD when the model file is available.
|
||||||
|
- `bench_seam` is exposed (`#[doc(hidden)]`) for criterion benchmarks on non-mobile targets.
|
||||||
@@ -375,38 +375,20 @@ impl AndroidCaptureState {
|
|||||||
speech: true,
|
speech: true,
|
||||||
}
|
}
|
||||||
} else if vad_backend == crate::VadBackend::SileroOnnx {
|
} else if vad_backend == crate::VadBackend::SileroOnnx {
|
||||||
match crate::vad::callback_vad_worker_policy(
|
if let Some(worker) = self.silero_vad_worker.as_ref() {
|
||||||
voice_activity_mode,
|
let enqueued = worker.try_send(capture_seq, &frame);
|
||||||
vad_backend,
|
if !worker.is_stale(capture_seq) {
|
||||||
self.silero_vad_worker.is_some(),
|
let p = worker.latest_probability();
|
||||||
) {
|
crate::vad::VadOutput {
|
||||||
crate::vad::VadWorkerPolicy::UseWorker => {
|
probability: p,
|
||||||
let worker = self
|
speech: p >= 0.5,
|
||||||
.silero_vad_worker
|
|
||||||
.as_ref()
|
|
||||||
.expect("policy checked worker");
|
|
||||||
let enqueued = worker.try_send(capture_seq, &frame);
|
|
||||||
if !worker.is_stale(capture_seq) {
|
|
||||||
let p = worker.latest_probability();
|
|
||||||
crate::vad::VadOutput {
|
|
||||||
probability: p,
|
|
||||||
speech: p >= 0.5,
|
|
||||||
}
|
|
||||||
} else if enqueued {
|
|
||||||
crate::vad::VadOutput {
|
|
||||||
probability: 0.0,
|
|
||||||
speech: false,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
used_fallback_vad = true;
|
|
||||||
self.mark_vad_fallback_active(vad_backend);
|
|
||||||
crate::vad::VoiceActivityDetector::process_10ms(
|
|
||||||
&mut self.vad_detector,
|
|
||||||
&frame,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
} else if enqueued {
|
||||||
crate::vad::VadWorkerPolicy::UseFallback => {
|
crate::vad::VadOutput {
|
||||||
|
probability: 0.0,
|
||||||
|
speech: false,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
used_fallback_vad = true;
|
used_fallback_vad = true;
|
||||||
self.mark_vad_fallback_active(vad_backend);
|
self.mark_vad_fallback_active(vad_backend);
|
||||||
crate::vad::VoiceActivityDetector::process_10ms(
|
crate::vad::VoiceActivityDetector::process_10ms(
|
||||||
@@ -414,10 +396,13 @@ impl AndroidCaptureState {
|
|||||||
&frame,
|
&frame,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
crate::vad::VadWorkerPolicy::NotModelBacked => crate::vad::VadOutput {
|
} else {
|
||||||
probability: 1.0,
|
used_fallback_vad = true;
|
||||||
speech: true,
|
self.mark_vad_fallback_active(vad_backend);
|
||||||
},
|
crate::vad::VoiceActivityDetector::process_10ms(
|
||||||
|
&mut self.vad_detector,
|
||||||
|
&frame,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||||
@@ -870,7 +855,7 @@ impl AndroidVoiceUnit {
|
|||||||
// by the capture callback's WebRtcApmProcessor.
|
// by the capture callback's WebRtcApmProcessor.
|
||||||
{
|
{
|
||||||
use crate::audio_processing::EffectOwner;
|
use crate::audio_processing::EffectOwner;
|
||||||
let mut apm_cfg = apm_config_clone.lock().unwrap();
|
let mut apm_cfg = apm_config_clone.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
let hw_aec = hw_effects.aec.is_some();
|
let hw_aec = hw_effects.aec.is_some();
|
||||||
let hw_ns = hw_effects.ns.is_some();
|
let hw_ns = hw_effects.ns.is_some();
|
||||||
let hw_agc = hw_effects.agc.is_some();
|
let hw_agc = hw_effects.agc.is_some();
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ pub enum AudioCommand {
|
|||||||
/// Set a client's output volume.
|
/// Set a client's output volume.
|
||||||
SetVolume(SessionAudioId, f32),
|
SetVolume(SessionAudioId, f32),
|
||||||
/// Remove a client's decode queue.
|
/// Remove a client's decode queue.
|
||||||
// TODO: Wire to client disconnect path; handled in callback but no
|
// TRACKED(TODO-005): Wire to client disconnect path; handled in callback
|
||||||
// producer currently pushes this command.
|
// but no producer currently pushes this command.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
RemoveClient(SessionAudioId),
|
RemoveClient(SessionAudioId),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,10 +56,8 @@ impl AudioRoute {
|
|||||||
/// iOS voice-processing mode.
|
/// iOS voice-processing mode.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum IosVoiceProcessingMode {
|
pub enum IosVoiceProcessingMode {
|
||||||
/// Shipping default: Apple VoiceProcessingIO owns AEC/NS/AGC.
|
/// Apple VoiceProcessingIO owns AEC/NS/AGC.
|
||||||
PlatformVoiceProcessing,
|
PlatformVoiceProcessing,
|
||||||
/// Experimental raw capture-processing path.
|
|
||||||
SonoraExperimental,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Processing backend selected by policy/config.
|
/// Processing backend selected by policy/config.
|
||||||
@@ -195,42 +193,23 @@ impl AudioProcessingConfig {
|
|||||||
"bluetooth_a2dp is output-only and cannot transmit duplex voice".to_string(),
|
"bluetooth_a2dp is output-only and cannot transmit duplex voice".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if self.ios_mode == IosVoiceProcessingMode::PlatformVoiceProcessing
|
if self.processing_backend == AudioBackend::Sonora
|
||||||
&& (self.processing_backend == AudioBackend::Sonora
|
|| self.processing_backend == AudioBackend::WebrtcApm
|
||||||
|| self.processing_backend == AudioBackend::WebrtcApm
|
|| self.aec == EffectOwner::Sonora
|
||||||
|| self.aec == EffectOwner::Sonora
|
|| self.aec == EffectOwner::WebrtcApm
|
||||||
|| self.aec == EffectOwner::WebrtcApm
|
|| self.ns == EffectOwner::Sonora
|
||||||
|| self.ns == EffectOwner::Sonora
|
|| self.ns == EffectOwner::WebrtcApm
|
||||||
|| self.ns == EffectOwner::WebrtcApm
|
|| self.agc == EffectOwner::Sonora
|
||||||
|| self.agc == EffectOwner::Sonora
|
|| self.agc == EffectOwner::WebrtcApm
|
||||||
|| self.agc == EffectOwner::WebrtcApm)
|
|
||||||
{
|
{
|
||||||
return Err(AudioError::InvalidAudioProcessingConfig(
|
return Err(AudioError::InvalidAudioProcessingConfig(
|
||||||
"software audio processing cannot be enabled with iOS VoiceProcessingIO"
|
"software audio processing cannot be enabled with iOS VoiceProcessingIO"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if self.ios_mode == IosVoiceProcessingMode::SonoraExperimental
|
|
||||||
&& self.processing_backend != AudioBackend::WebrtcApm
|
|
||||||
{
|
|
||||||
return Err(AudioError::InvalidAudioProcessingConfig(
|
|
||||||
"ios raw processing mode requires the WebRTC APM backend".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Demote a failed VAD backend to the WebRTC fallback.
|
|
||||||
///
|
|
||||||
/// Returns `true` when the config changed.
|
|
||||||
pub fn disable_failed_vad_backend(&mut self, failed_backend: VadBackend) -> bool {
|
|
||||||
if self.vad_backend == failed_backend && failed_backend != VadBackend::WebrtcVad {
|
|
||||||
self.vad_backend = VadBackend::WebrtcVad;
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -261,46 +240,6 @@ mod tests {
|
|||||||
|
|
||||||
assert!(config.validate_for_ios().is_err());
|
assert!(config.validate_for_ios().is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn raw_processing_allows_full_webrtc_apm_chain() {
|
|
||||||
let config = AudioProcessingConfig {
|
|
||||||
ios_mode: IosVoiceProcessingMode::SonoraExperimental,
|
|
||||||
processing_backend: AudioBackend::WebrtcApm,
|
|
||||||
aec: EffectOwner::WebrtcApm,
|
|
||||||
ns: EffectOwner::WebrtcApm,
|
|
||||||
agc: EffectOwner::WebrtcApm,
|
|
||||||
..AudioProcessingConfig::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(config.validate_for_ios().is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn raw_processing_rejects_non_webrtc_apm_backend() {
|
|
||||||
let config = AudioProcessingConfig {
|
|
||||||
ios_mode: IosVoiceProcessingMode::SonoraExperimental,
|
|
||||||
processing_backend: AudioBackend::PlatformVoiceProcessing,
|
|
||||||
aec: EffectOwner::WebrtcApm,
|
|
||||||
ns: EffectOwner::WebrtcApm,
|
|
||||||
agc: EffectOwner::WebrtcApm,
|
|
||||||
..AudioProcessingConfig::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(config.validate_for_ios().is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn disable_failed_vad_backend_demotes_to_webrtc() {
|
|
||||||
let mut config = AudioProcessingConfig {
|
|
||||||
vad_backend: VadBackend::SileroOnnx,
|
|
||||||
..AudioProcessingConfig::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(config.disable_failed_vad_backend(VadBackend::SileroOnnx));
|
|
||||||
assert_eq!(config.vad_backend, VadBackend::WebrtcVad);
|
|
||||||
assert!(!config.disable_failed_vad_backend(VadBackend::SileroOnnx));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runtime audio processing stats exposed to bridge/UI diagnostics.
|
/// Runtime audio processing stats exposed to bridge/UI diagnostics.
|
||||||
@@ -404,10 +343,8 @@ impl Default for SharedAudioProcessingStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SharedAudioProcessingStats {
|
impl SharedAudioProcessingStats {
|
||||||
/// Store the raw input dBFS level (desktop capture path).
|
/// Store the raw input dBFS level for capture paths that do not
|
||||||
/// Mobile platforms use [`Self::update_capture`] instead, which
|
/// update the full processing/VAD snapshot on this callback.
|
||||||
/// also records VAD state; this lighter method is for the cpal
|
|
||||||
/// capture path that has no VAD pipeline.
|
|
||||||
pub fn set_input_dbfs(&self, dbfs: f32) {
|
pub fn set_input_dbfs(&self, dbfs: f32) {
|
||||||
self.input_dbfs.store(dbfs.to_bits(), Ordering::Relaxed);
|
self.input_dbfs.store(dbfs.to_bits(), Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -314,15 +314,6 @@ mod tests {
|
|||||||
rec.stop();
|
rec.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ios_raw_debug_wav_does_not_push_from_realtime_callback() {
|
|
||||||
let src = include_str!("ios_raw_unit.rs");
|
|
||||||
assert!(
|
|
||||||
!src.contains("push_raw_mic") && !src.contains("push_processed_mic"),
|
|
||||||
"ios raw callbacks must not call WavDebugRecorder push_*_mic until it has a preallocated handoff"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn wav_header_is_44_bytes() {
|
fn wav_header_is_44_bytes() {
|
||||||
// Write to a temp file to test the header.
|
// Write to a temp file to test the header.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,515 @@
|
|||||||
|
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use cpal::traits::StreamTrait;
|
||||||
|
use cpal::{SampleFormat, SizedSample};
|
||||||
|
use tracing::{debug, error, warn, info};
|
||||||
|
|
||||||
|
use chanora_protocol::OutPacket;
|
||||||
|
|
||||||
|
use audiopus::coder::Encoder as OpusEncoder;
|
||||||
|
|
||||||
|
use crate::AudioError;
|
||||||
|
|
||||||
|
use super::SAMPLE_RATE;
|
||||||
|
use super::FRAME_SAMPLES;
|
||||||
|
|
||||||
|
const LEVEL_METER_INTERVAL: std::time::Duration = std::time::Duration::from_millis(33);
|
||||||
|
|
||||||
|
pub(super) fn try_open_capture(
|
||||||
|
in_dev: &cpal::Device,
|
||||||
|
voice_out_tx: tokio::sync::mpsc::Sender<OutPacket>,
|
||||||
|
transmit_active: Arc<AtomicBool>,
|
||||||
|
frames_sent: Arc<AtomicU32>,
|
||||||
|
mic_gain: f32,
|
||||||
|
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||||
|
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
||||||
|
silero_vad_worker: Arc<Mutex<Option<crate::vad::silero_onnx::SileroOnnxVadWorker>>>,
|
||||||
|
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||||
|
) -> Result<cpal::Stream, AudioError> {
|
||||||
|
let in_cfg = in_dev
|
||||||
|
.default_input_config()
|
||||||
|
.map_err(|e| AudioError::StreamConfig(format!("input default: {e}")))?;
|
||||||
|
let in_sample_rate = in_cfg.sample_rate();
|
||||||
|
let in_channels = in_cfg.channels() as usize;
|
||||||
|
let in_format = in_cfg.sample_format();
|
||||||
|
let mut in_stream_cfg: cpal::StreamConfig = in_cfg.into();
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
in_stream_cfg.buffer_size = cpal::BufferSize::Fixed(2048);
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
|
in_stream_cfg.buffer_size = cpal::BufferSize::Default;
|
||||||
|
}
|
||||||
|
|
||||||
|
let opus_enc = crate::opus_voice::new_voip_encoder("cpal capture")?;
|
||||||
|
|
||||||
|
let capture_state = Arc::new(Mutex::new(CaptureState::new(
|
||||||
|
opus_enc,
|
||||||
|
in_sample_rate,
|
||||||
|
in_channels,
|
||||||
|
mic_gain,
|
||||||
|
crate::opus_voice::start_out_packet_worker(
|
||||||
|
voice_out_tx,
|
||||||
|
frames_sent.clone(),
|
||||||
|
"cpal-capture",
|
||||||
|
)?,
|
||||||
|
transmit_active,
|
||||||
|
voice_activity_selector,
|
||||||
|
audio_processing_config,
|
||||||
|
silero_vad_worker,
|
||||||
|
audio_processing_stats,
|
||||||
|
)));
|
||||||
|
|
||||||
|
let stream = match in_format {
|
||||||
|
SampleFormat::F32 => build_input_stream::<f32>(in_dev, &in_stream_cfg, capture_state)?,
|
||||||
|
SampleFormat::I16 => build_input_stream::<i16>(in_dev, &in_stream_cfg, capture_state)?,
|
||||||
|
SampleFormat::U16 => build_input_stream::<u16>(in_dev, &in_stream_cfg, capture_state)?,
|
||||||
|
other => {
|
||||||
|
return Err(AudioError::StreamConfig(format!(
|
||||||
|
"unsupported input format: {other:?}"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct CaptureState {
|
||||||
|
encoder: OpusEncoder,
|
||||||
|
pub(super) pcm_accum: Vec<f32>,
|
||||||
|
pub(super) pending_10ms: [f32; crate::frame::FRAME_10MS_SAMPLES],
|
||||||
|
pub(super) pending_10ms_len: usize,
|
||||||
|
pub(super) capture_frame_seq: u64,
|
||||||
|
in_sample_rate: u32,
|
||||||
|
in_channels: usize,
|
||||||
|
mic_gain: f32,
|
||||||
|
resample_pos: f64,
|
||||||
|
resample_last: f32,
|
||||||
|
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||||
|
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
||||||
|
transmit_active: Arc<AtomicBool>,
|
||||||
|
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||||
|
vad_detector: crate::vad::WebRtcFallbackVad,
|
||||||
|
silero_vad_worker: Arc<Mutex<Option<crate::vad::silero_onnx::SileroOnnxVadWorker>>>,
|
||||||
|
silero_model_epoch: u64,
|
||||||
|
current_vad_backend: crate::VadBackend,
|
||||||
|
fallback_warned_backend: Option<crate::VadBackend>,
|
||||||
|
vad_state: crate::voice_activity::VoiceActivityStateMachine,
|
||||||
|
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
||||||
|
mono_scratch: Vec<f32>,
|
||||||
|
frame_scratch: Vec<f32>,
|
||||||
|
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||||
|
last_level_emit: std::time::Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CaptureState {
|
||||||
|
pub(super) fn new(
|
||||||
|
encoder: OpusEncoder,
|
||||||
|
in_sample_rate: u32,
|
||||||
|
in_channels: usize,
|
||||||
|
mic_gain: f32,
|
||||||
|
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
||||||
|
transmit_active: Arc<AtomicBool>,
|
||||||
|
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||||
|
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
||||||
|
silero_vad_worker: Arc<Mutex<Option<crate::vad::silero_onnx::SileroOnnxVadWorker>>>,
|
||||||
|
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
encoder,
|
||||||
|
in_sample_rate,
|
||||||
|
in_channels,
|
||||||
|
mic_gain,
|
||||||
|
pcm_accum: Vec::with_capacity(FRAME_SAMPLES * 2),
|
||||||
|
resample_pos: 0.0,
|
||||||
|
resample_last: 0.0,
|
||||||
|
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||||
|
voice_out_tx,
|
||||||
|
transmit_active,
|
||||||
|
voice_activity_selector,
|
||||||
|
vad_detector: crate::vad::WebRtcFallbackVad::default(),
|
||||||
|
silero_vad_worker,
|
||||||
|
silero_model_epoch: crate::vad::silero_model_epoch(),
|
||||||
|
current_vad_backend: crate::VadBackend::Disabled,
|
||||||
|
fallback_warned_backend: None,
|
||||||
|
capture_frame_seq: 0,
|
||||||
|
vad_state: crate::voice_activity::VoiceActivityStateMachine::default(),
|
||||||
|
audio_processing_config,
|
||||||
|
pending_10ms: [0.0; crate::frame::FRAME_10MS_SAMPLES],
|
||||||
|
pending_10ms_len: 0,
|
||||||
|
mono_scratch: Vec::with_capacity(4096),
|
||||||
|
frame_scratch: Vec::with_capacity(FRAME_SAMPLES),
|
||||||
|
audio_processing_stats,
|
||||||
|
last_level_emit: std::time::Instant::now()
|
||||||
|
.checked_sub(LEVEL_METER_INTERVAL)
|
||||||
|
.unwrap_or_else(std::time::Instant::now),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn ingest<T: ToF32 + Copy>(&mut self, buf: &[T]) {
|
||||||
|
let in_channels = self.in_channels;
|
||||||
|
let mic_gain = self.mic_gain;
|
||||||
|
self.mono_scratch.clear();
|
||||||
|
let frame_count = buf.len() / in_channels.max(1);
|
||||||
|
self.mono_scratch.reserve(frame_count);
|
||||||
|
for frame in buf.chunks(in_channels) {
|
||||||
|
let sum: f32 = frame.iter().map(|s| s.to_f32_sample()).sum();
|
||||||
|
self.mono_scratch.push(sum / frame.len() as f32);
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
if now.duration_since(self.last_level_emit) >= LEVEL_METER_INTERVAL {
|
||||||
|
self.last_level_emit = now;
|
||||||
|
self.audio_processing_stats
|
||||||
|
.set_input_dbfs(crate::frame::dbfs(&self.mono_scratch));
|
||||||
|
}
|
||||||
|
|
||||||
|
if mic_gain != 1.0 {
|
||||||
|
for s in &mut self.mono_scratch {
|
||||||
|
*s *= mic_gain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let vad_start_offset = self.pcm_accum.len();
|
||||||
|
if self.in_sample_rate == SAMPLE_RATE {
|
||||||
|
let (src, dst) = (&self.mono_scratch, &mut self.pcm_accum);
|
||||||
|
dst.extend_from_slice(src);
|
||||||
|
} else {
|
||||||
|
let mono = std::mem::take(&mut self.mono_scratch);
|
||||||
|
self.resample_into_accum(&mono);
|
||||||
|
self.mono_scratch = mono;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.process_pending_vad_frames(vad_start_offset);
|
||||||
|
|
||||||
|
if !self.transmit_active.load(Ordering::Relaxed) {
|
||||||
|
self.pcm_accum.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
while self.pcm_accum.len() >= FRAME_SAMPLES {
|
||||||
|
let frame = &mut self.frame_scratch;
|
||||||
|
frame.clear();
|
||||||
|
frame.extend(self.pcm_accum.drain(..FRAME_SAMPLES));
|
||||||
|
for s in frame.iter_mut() {
|
||||||
|
if *s > 1.0 {
|
||||||
|
*s = 1.0;
|
||||||
|
} else if *s < -1.0 {
|
||||||
|
*s = -1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match self
|
||||||
|
.encoder
|
||||||
|
.encode_float(&frame[..], &mut self.opus_out[..])
|
||||||
|
{
|
||||||
|
Ok(len) => {
|
||||||
|
crate::opus_voice::send_voip_frame(
|
||||||
|
&self.voice_out_tx,
|
||||||
|
&self.opus_out,
|
||||||
|
len,
|
||||||
|
|| {
|
||||||
|
warn!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
"voice_out queue full; dropping frame"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|| {
|
||||||
|
warn!(target: "chanora_audio", "voice_out closed; stopping send");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!(target: "chanora_audio", error = %e, "opus encode failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn process_pending_vad_frames(&mut self, start_offset: usize) {
|
||||||
|
let mut offset = start_offset.min(self.pcm_accum.len());
|
||||||
|
while offset < self.pcm_accum.len() {
|
||||||
|
let remaining = crate::frame::FRAME_10MS_SAMPLES - self.pending_10ms_len;
|
||||||
|
let take = remaining.min(self.pcm_accum.len() - offset);
|
||||||
|
self.pending_10ms[self.pending_10ms_len..self.pending_10ms_len + take]
|
||||||
|
.copy_from_slice(&self.pcm_accum[offset..offset + take]);
|
||||||
|
self.pending_10ms_len += take;
|
||||||
|
offset += take;
|
||||||
|
|
||||||
|
if self.pending_10ms_len == crate::frame::FRAME_10MS_SAMPLES {
|
||||||
|
let frame = self.pending_10ms;
|
||||||
|
self.process_10ms_capture_frame(&frame);
|
||||||
|
self.pending_10ms_len = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark_vad_fallback_active(&mut self, failed_backend: crate::VadBackend) {
|
||||||
|
self.fallback_warned_backend = Some(failed_backend);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_vad_backend(&mut self, voice_activity_mode: bool, vad_backend: crate::VadBackend) {
|
||||||
|
if !voice_activity_mode {
|
||||||
|
self.current_vad_backend = crate::VadBackend::Disabled;
|
||||||
|
self.fallback_warned_backend = None;
|
||||||
|
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let silero_epoch = crate::vad::silero_model_epoch();
|
||||||
|
let silero_changed =
|
||||||
|
vad_backend == crate::VadBackend::SileroOnnx && silero_epoch != self.silero_model_epoch;
|
||||||
|
if vad_backend == self.current_vad_backend && !silero_changed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.current_vad_backend = vad_backend;
|
||||||
|
self.silero_model_epoch = silero_epoch;
|
||||||
|
self.fallback_warned_backend = None;
|
||||||
|
self.vad_state.reset();
|
||||||
|
|
||||||
|
match vad_backend {
|
||||||
|
crate::VadBackend::SileroOnnx => {
|
||||||
|
let worker_available = self
|
||||||
|
.silero_vad_worker
|
||||||
|
.try_lock()
|
||||||
|
.map(|worker| worker.is_some())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if worker_available {
|
||||||
|
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||||
|
} else {
|
||||||
|
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||||
|
self.audio_processing_stats.set_vad_fallback_active(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crate::VadBackend::WebrtcVad => {
|
||||||
|
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||||
|
}
|
||||||
|
crate::VadBackend::EnergyDebug => {
|
||||||
|
self.audio_processing_stats.set_vad_fallback_active(true);
|
||||||
|
}
|
||||||
|
crate::VadBackend::Disabled => {
|
||||||
|
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn process_10ms_capture_frame(&mut self, frame: &[f32; crate::frame::FRAME_10MS_SAMPLES]) {
|
||||||
|
let input_dbfs = crate::frame::dbfs(frame);
|
||||||
|
let (vad_backend, vad_hangover) = self
|
||||||
|
.audio_processing_config
|
||||||
|
.try_lock()
|
||||||
|
.map(|cfg| (cfg.vad_backend, cfg.vad_hangover_ms))
|
||||||
|
.unwrap_or((
|
||||||
|
crate::VadBackend::WebrtcVad,
|
||||||
|
crate::voice_activity::VAD_HANGOVER_MS,
|
||||||
|
));
|
||||||
|
let voice_activity_mode = self
|
||||||
|
.voice_activity_selector
|
||||||
|
.as_ref()
|
||||||
|
.map(|selector| selector.mode() == crate::TransmitMode::VoiceActivity)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if voice_activity_mode {
|
||||||
|
self.sync_vad_backend(true, vad_backend);
|
||||||
|
self.vad_state.configure(
|
||||||
|
crate::voice_activity::VAD_OPEN_AFTER_MS,
|
||||||
|
vad_hangover,
|
||||||
|
crate::voice_activity::VAD_MIN_TX_MS,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
self.sync_vad_backend(false, vad_backend);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (vad_probability, gate_open, used_fallback_vad) = if voice_activity_mode {
|
||||||
|
self.capture_frame_seq = self.capture_frame_seq.wrapping_add(1);
|
||||||
|
let capture_seq = self.capture_frame_seq;
|
||||||
|
let mut used_fallback_vad = false;
|
||||||
|
let vad = match vad_backend {
|
||||||
|
crate::VadBackend::Disabled => crate::vad::VadOutput {
|
||||||
|
probability: 1.0,
|
||||||
|
speech: true,
|
||||||
|
},
|
||||||
|
crate::VadBackend::SileroOnnx => {
|
||||||
|
let worker_output = {
|
||||||
|
let guard = self.silero_vad_worker.try_lock().ok();
|
||||||
|
guard.and_then(|guard| {
|
||||||
|
let worker = guard.as_ref()?;
|
||||||
|
if worker.try_send(capture_seq, frame) && !worker.is_stale(capture_seq)
|
||||||
|
{
|
||||||
|
let p = worker.latest_probability();
|
||||||
|
Some(crate::vad::VadOutput {
|
||||||
|
probability: p,
|
||||||
|
speech: p >= 0.5,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
if let Some(output) = worker_output {
|
||||||
|
output
|
||||||
|
} else {
|
||||||
|
used_fallback_vad = true;
|
||||||
|
self.mark_vad_fallback_active(vad_backend);
|
||||||
|
crate::vad::VoiceActivityDetector::process_10ms(
|
||||||
|
&mut self.vad_detector,
|
||||||
|
frame,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crate::VadBackend::WebrtcVad | crate::VadBackend::EnergyDebug => {
|
||||||
|
used_fallback_vad = vad_backend == crate::VadBackend::EnergyDebug;
|
||||||
|
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, frame)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(
|
||||||
|
vad.probability,
|
||||||
|
self.vad_state.update(vad.speech),
|
||||||
|
used_fallback_vad,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(0.0, false, false)
|
||||||
|
};
|
||||||
|
self.audio_processing_stats
|
||||||
|
.set_vad_fallback_active(used_fallback_vad);
|
||||||
|
|
||||||
|
let vad_active = voice_activity_mode && gate_open;
|
||||||
|
if let Some(selector) = &self.voice_activity_selector {
|
||||||
|
selector.set_voice_activity_open(vad_active);
|
||||||
|
}
|
||||||
|
self.audio_processing_stats.update_capture(
|
||||||
|
input_dbfs,
|
||||||
|
input_dbfs,
|
||||||
|
vad_probability,
|
||||||
|
vad_active,
|
||||||
|
self.transmit_active.load(Ordering::Relaxed),
|
||||||
|
);
|
||||||
|
self.audio_processing_stats
|
||||||
|
.record_capture_frame(frame.iter().all(|sample| sample.abs() <= 0.000_001));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resample_into_accum(&mut self, mono: &[f32]) {
|
||||||
|
if mono.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ratio = self.in_sample_rate as f64 / SAMPLE_RATE as f64;
|
||||||
|
let mut pos = self.resample_pos;
|
||||||
|
while pos < mono.len() as f64 {
|
||||||
|
let i = pos.floor() as isize;
|
||||||
|
let frac = pos - i as f64;
|
||||||
|
let a = if i <= 0 {
|
||||||
|
self.resample_last
|
||||||
|
} else {
|
||||||
|
mono[(i - 1) as usize]
|
||||||
|
};
|
||||||
|
let b = if i < mono.len() as isize {
|
||||||
|
mono[i as usize]
|
||||||
|
} else {
|
||||||
|
a
|
||||||
|
};
|
||||||
|
self.pcm_accum
|
||||||
|
.push((a as f64 + frac * (b - a) as f64) as f32);
|
||||||
|
pos += ratio;
|
||||||
|
}
|
||||||
|
self.resample_pos = pos - mono.len() as f64;
|
||||||
|
self.resample_last = *mono.last().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trait ToF32 {
|
||||||
|
fn to_f32_sample(self) -> f32;
|
||||||
|
}
|
||||||
|
impl ToF32 for f32 {
|
||||||
|
fn to_f32_sample(self) -> f32 {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl ToF32 for i16 {
|
||||||
|
fn to_f32_sample(self) -> f32 {
|
||||||
|
f32::from(self) / f32::from(i16::MAX)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl ToF32 for u16 {
|
||||||
|
fn to_f32_sample(self) -> f32 {
|
||||||
|
(f32::from(self) - f32::from(i16::MAX) - 1.0) / f32::from(i16::MAX)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_input_stream<T>(
|
||||||
|
device: &cpal::Device,
|
||||||
|
config: &cpal::StreamConfig,
|
||||||
|
state: Arc<Mutex<CaptureState>>,
|
||||||
|
) -> Result<cpal::Stream, AudioError>
|
||||||
|
where
|
||||||
|
T: SizedSample + ToF32 + Send + 'static,
|
||||||
|
{
|
||||||
|
let stream = device
|
||||||
|
.build_input_stream(
|
||||||
|
*config,
|
||||||
|
move |data: &[T], _: &cpal::InputCallbackInfo| {
|
||||||
|
let mut s = state.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
s.ingest(data);
|
||||||
|
},
|
||||||
|
move |e| {
|
||||||
|
error!(target: "chanora_audio", error = %e, "input stream error");
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(|e| AudioError::Backend(format!("build_input_stream: {e}")))?;
|
||||||
|
Ok(stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub mod bench_seam {
|
||||||
|
use super::{Arc, AtomicBool, AtomicU32, CaptureState, OutPacket};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
pub struct CaptureBenchHandle {
|
||||||
|
pub(crate) state: CaptureState,
|
||||||
|
_rx: mpsc::Receiver<OutPacket>,
|
||||||
|
transmit_active: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CaptureBenchHandle {
|
||||||
|
pub fn new(in_sample_rate: u32, in_channels: usize) -> Self {
|
||||||
|
let encoder =
|
||||||
|
crate::opus_voice::new_voip_encoder("cpal bench").expect("opus encoder init");
|
||||||
|
let (tx, rx) = mpsc::channel::<OutPacket>(64);
|
||||||
|
let transmit_active = Arc::new(AtomicBool::new(true));
|
||||||
|
let frames_sent = Arc::new(AtomicU32::new(0));
|
||||||
|
let voice_out_tx =
|
||||||
|
crate::opus_voice::start_out_packet_worker(tx, frames_sent, "cpal-bench")
|
||||||
|
.expect("start_out_packet_worker");
|
||||||
|
let state = CaptureState::new(
|
||||||
|
encoder,
|
||||||
|
in_sample_rate,
|
||||||
|
in_channels,
|
||||||
|
1.0,
|
||||||
|
voice_out_tx,
|
||||||
|
transmit_active.clone(),
|
||||||
|
None,
|
||||||
|
Arc::new(std::sync::Mutex::new(
|
||||||
|
crate::AudioProcessingConfig::default(),
|
||||||
|
)),
|
||||||
|
Arc::new(std::sync::Mutex::new(None)),
|
||||||
|
Arc::new(crate::SharedAudioProcessingStats::default()),
|
||||||
|
);
|
||||||
|
Self {
|
||||||
|
state,
|
||||||
|
_rx: rx,
|
||||||
|
transmit_active,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn ingest_f32(&mut self, buf: &[f32]) {
|
||||||
|
self.state.ingest(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_transmit_active(&self, active: bool) {
|
||||||
|
self.transmit_active
|
||||||
|
.store(active, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,176 @@
|
|||||||
|
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use cpal::traits::StreamTrait;
|
||||||
|
use cpal::SampleFormat;
|
||||||
|
use tracing::{error, warn};
|
||||||
|
use tsclientlib::audio::AudioHandler;
|
||||||
|
|
||||||
|
use crate::AudioError;
|
||||||
|
|
||||||
|
use super::SessionAudioId;
|
||||||
|
use super::SAMPLE_RATE;
|
||||||
|
|
||||||
|
pub(super) fn build_output_stream<T>(
|
||||||
|
device: &cpal::Device,
|
||||||
|
config: &cpal::StreamConfig,
|
||||||
|
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
|
||||||
|
output_gain: Arc<AtomicU32>,
|
||||||
|
output_muted: Arc<AtomicBool>,
|
||||||
|
dev_sample_rate: u32,
|
||||||
|
dev_channels: usize,
|
||||||
|
) -> Result<cpal::Stream, AudioError>
|
||||||
|
where
|
||||||
|
T: cpal::SizedSample + FromF32 + Send + 'static,
|
||||||
|
{
|
||||||
|
let resample_ratio = SAMPLE_RATE as f64 / dev_sample_rate as f64;
|
||||||
|
let same_rate = dev_sample_rate == SAMPLE_RATE;
|
||||||
|
let resample_state: Arc<Mutex<PlaybackResampleState>> =
|
||||||
|
Arc::new(Mutex::new(PlaybackResampleState {
|
||||||
|
pos: 0.0,
|
||||||
|
last_l: 0.0,
|
||||||
|
last_r: 0.0,
|
||||||
|
}));
|
||||||
|
let mut scratch: Vec<f32> = Vec::with_capacity(8192);
|
||||||
|
let mut last_slow_warn = std::time::Instant::now()
|
||||||
|
.checked_sub(std::time::Duration::from_secs(2))
|
||||||
|
.unwrap_or_else(std::time::Instant::now);
|
||||||
|
let stream = device
|
||||||
|
.build_output_stream(
|
||||||
|
*config,
|
||||||
|
move |out: &mut [T], _: &cpal::OutputCallbackInfo| {
|
||||||
|
let cb_start = std::time::Instant::now();
|
||||||
|
let muted = output_muted.load(Ordering::Relaxed);
|
||||||
|
let dev_frames = out.len() / dev_channels.max(1);
|
||||||
|
let src_frames = if same_rate {
|
||||||
|
dev_frames
|
||||||
|
} else {
|
||||||
|
((dev_frames as f64 * resample_ratio).ceil() as usize) + 2
|
||||||
|
};
|
||||||
|
let needed = src_frames * 2;
|
||||||
|
if scratch.len() < needed {
|
||||||
|
scratch.resize(needed, 0.0);
|
||||||
|
}
|
||||||
|
scratch[..needed].fill(0.0);
|
||||||
|
{
|
||||||
|
let mut h = handler.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
h.fill_buffer(&mut scratch[..needed]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if muted {
|
||||||
|
for dst in out.iter_mut() {
|
||||||
|
*dst = T::from_f32_sample(0.0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let gain = f32::from_bits(output_gain.load(Ordering::Relaxed));
|
||||||
|
if same_rate && dev_channels == 2 {
|
||||||
|
for (dst, s) in out.iter_mut().zip(scratch[..needed].iter().copied()) {
|
||||||
|
*dst = T::from_f32_sample(s * gain);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let mut state = resample_state.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let mut pos = state.pos;
|
||||||
|
let mut last_l = state.last_l;
|
||||||
|
let mut last_r = state.last_r;
|
||||||
|
for frame_idx in 0..dev_frames {
|
||||||
|
let i = pos.floor() as isize;
|
||||||
|
let frac = pos - i as f64;
|
||||||
|
let (a_l, a_r) = if i <= 0 {
|
||||||
|
(last_l, last_r)
|
||||||
|
} else {
|
||||||
|
let idx = ((i - 1) as usize) * 2;
|
||||||
|
(scratch[idx], scratch[idx + 1])
|
||||||
|
};
|
||||||
|
let i_usize = i.max(0) as usize;
|
||||||
|
let (b_l, b_r) = if i_usize < src_frames {
|
||||||
|
let idx = i_usize * 2;
|
||||||
|
(scratch[idx], scratch[idx + 1])
|
||||||
|
} else {
|
||||||
|
(a_l, a_r)
|
||||||
|
};
|
||||||
|
let l = (a_l as f64 + frac * (b_l - a_l) as f64) as f32 * gain;
|
||||||
|
let r = (a_r as f64 + frac * (b_r - a_r) as f64) as f32 * gain;
|
||||||
|
let base = frame_idx * dev_channels;
|
||||||
|
if dev_channels == 1 {
|
||||||
|
out[base] = T::from_f32_sample((l + r) * 0.5);
|
||||||
|
} else {
|
||||||
|
out[base] = T::from_f32_sample(l);
|
||||||
|
if dev_channels >= 2 {
|
||||||
|
out[base + 1] = T::from_f32_sample(r);
|
||||||
|
}
|
||||||
|
for c in 2..dev_channels {
|
||||||
|
out[base + c] = T::from_f32_sample(0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pos += resample_ratio;
|
||||||
|
}
|
||||||
|
let consumed = pos.floor() as usize;
|
||||||
|
state.pos = pos - consumed as f64;
|
||||||
|
if consumed > 0 && consumed <= src_frames {
|
||||||
|
let idx = (consumed - 1) * 2;
|
||||||
|
last_l = scratch[idx];
|
||||||
|
last_r = scratch[idx + 1];
|
||||||
|
state.last_l = last_l;
|
||||||
|
state.last_r = last_r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let elapsed = cb_start.elapsed();
|
||||||
|
let period_us = (dev_frames as u64 * 1_000_000) / dev_sample_rate as u64;
|
||||||
|
if elapsed.as_micros() as u64 > period_us / 2
|
||||||
|
&& last_slow_warn.elapsed() > std::time::Duration::from_secs(1)
|
||||||
|
{
|
||||||
|
last_slow_warn = std::time::Instant::now();
|
||||||
|
warn!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
callback_us = elapsed.as_micros() as u64,
|
||||||
|
period_us,
|
||||||
|
dev_frames,
|
||||||
|
"output callback exceeded half the period budget — possible underrun cause"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
move |e| {
|
||||||
|
error!(target: "chanora_audio", error = %e, "output stream error");
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(|e| {
|
||||||
|
error!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
error = %e,
|
||||||
|
requested_channels = config.channels,
|
||||||
|
requested_sample_rate = config.sample_rate,
|
||||||
|
"build_output_stream FAILED"
|
||||||
|
);
|
||||||
|
AudioError::Backend(format!("build_output_stream: {e}"))
|
||||||
|
})?;
|
||||||
|
Ok(stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PlaybackResampleState {
|
||||||
|
pos: f64,
|
||||||
|
last_l: f32,
|
||||||
|
last_r: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) trait FromF32 {
|
||||||
|
fn from_f32_sample(v: f32) -> Self;
|
||||||
|
}
|
||||||
|
impl FromF32 for f32 {
|
||||||
|
fn from_f32_sample(v: f32) -> Self {
|
||||||
|
v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl FromF32 for i16 {
|
||||||
|
fn from_f32_sample(v: f32) -> Self {
|
||||||
|
(v.clamp(-1.0, 1.0) * f32::from(i16::MAX)) as i16
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl FromF32 for u16 {
|
||||||
|
fn from_f32_sample(v: f32) -> Self {
|
||||||
|
let s = (v.clamp(-1.0, 1.0) * f32::from(i16::MAX)) as i32;
|
||||||
|
(s + i32::from(i16::MAX) + 1) as u16
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,56 +16,12 @@ pub const FRAME_10MS_SAMPLES: usize = 480;
|
|||||||
/// Samples in one 20 ms mono frame at 48 kHz.
|
/// Samples in one 20 ms mono frame at 48 kHz.
|
||||||
pub const FRAME_20MS_SAMPLES: usize = 960;
|
pub const FRAME_20MS_SAMPLES: usize = 960;
|
||||||
|
|
||||||
/// 10 ms, 48 kHz, mono f32 processing frame.
|
/// Convert i16 PCM sample to normalized f32 PCM (-1.0 to 1.0).
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub struct AudioFrame10ms {
|
|
||||||
/// Samples normalized to `[-1.0, 1.0]`.
|
|
||||||
pub samples: [f32; FRAME_10MS_SAMPLES],
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 20 ms, 48 kHz, mono f32 network-frame-sized buffer.
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub struct AudioFrame20ms {
|
|
||||||
/// Samples normalized to `[-1.0, 1.0]`.
|
|
||||||
pub samples: [f32; FRAME_20MS_SAMPLES],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AudioFrame20ms {
|
|
||||||
/// Convert one 20 ms frame into two 10 ms processing frames.
|
|
||||||
pub fn split(&self) -> (AudioFrame10ms, AudioFrame10ms) {
|
|
||||||
let mut first = [0.0; FRAME_10MS_SAMPLES];
|
|
||||||
let mut second = [0.0; FRAME_10MS_SAMPLES];
|
|
||||||
first.copy_from_slice(&self.samples[..FRAME_10MS_SAMPLES]);
|
|
||||||
second.copy_from_slice(&self.samples[FRAME_10MS_SAMPLES..]);
|
|
||||||
(
|
|
||||||
AudioFrame10ms { samples: first },
|
|
||||||
AudioFrame10ms { samples: second },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AudioFrame10ms {
|
|
||||||
/// Merge two 10 ms processing frames back into the 20 ms network
|
|
||||||
/// cadence used by the existing Opus path.
|
|
||||||
pub fn merge(first: &Self, second: &Self) -> AudioFrame20ms {
|
|
||||||
let mut samples = [0.0; FRAME_20MS_SAMPLES];
|
|
||||||
samples[..FRAME_10MS_SAMPLES].copy_from_slice(&first.samples);
|
|
||||||
samples[FRAME_10MS_SAMPLES..].copy_from_slice(&second.samples);
|
|
||||||
AudioFrame20ms { samples }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compute RMS dBFS for diagnostics and fallback VAD.
|
|
||||||
pub fn dbfs(&self) -> f32 {
|
|
||||||
dbfs(&self.samples)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert i16 PCM to normalized f32 PCM.
|
|
||||||
pub fn i16_to_f32(sample: i16) -> f32 {
|
pub fn i16_to_f32(sample: i16) -> f32 {
|
||||||
sample as f32 / i16::MAX as f32
|
sample as f32 / i16::MAX as f32
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert normalized f32 PCM to saturated i16 PCM.
|
/// Convert normalized f32 PCM to saturated i16 PCM (clamps to [-1.0, 1.0]).
|
||||||
pub fn f32_to_i16(sample: f32) -> i16 {
|
pub fn f32_to_i16(sample: f32) -> i16 {
|
||||||
(sample.clamp(-1.0, 1.0) * i16::MAX as f32) as i16
|
(sample.clamp(-1.0, 1.0) * i16::MAX as f32) as i16
|
||||||
}
|
}
|
||||||
@@ -84,18 +40,4 @@ pub fn dbfs(samples: &[f32]) -> f32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn split_merge_preserves_samples() {
|
|
||||||
let mut samples = [0.0; FRAME_20MS_SAMPLES];
|
|
||||||
for (i, s) in samples.iter_mut().enumerate() {
|
|
||||||
*s = i as f32 / FRAME_20MS_SAMPLES as f32;
|
|
||||||
}
|
|
||||||
let original = AudioFrame20ms { samples };
|
|
||||||
let (a, b) = original.split();
|
|
||||||
assert_eq!(AudioFrame10ms::merge(&a, &b), original);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,538 +0,0 @@
|
|||||||
//! Optional raw iOS RemoteIO path for the WebRTC APM experimental mode.
|
|
||||||
//!
|
|
||||||
//! Provides an alternative to `ios_voice_unit.rs` for the
|
|
||||||
//! `SonoraExperimental` processing mode. Instead of
|
|
||||||
//! `kAudioUnitSubType_VoiceProcessingIO` (which owns AEC/NS/AGC), it
|
|
||||||
//! opens `kAudioUnitSubType_RemoteIO` with voice processing explicitly
|
|
||||||
//! disabled so WebRTC APM can own the full signal path.
|
|
||||||
//!
|
|
||||||
//! ## Hard invariants enforced here
|
|
||||||
//!
|
|
||||||
//! * INV_009: Rust AEC only active when platform AEC is disabled.
|
|
||||||
//! * INV_010: VoiceProcessingIO and WebRTC APM AEC are mutually exclusive.
|
|
||||||
//! * INV_011: Software AEC backend receives both capture and render-reference.
|
|
||||||
//! * INV_012: Render reference is copied from decoded/mixed remote PCM
|
|
||||||
//! before playout.
|
|
||||||
//!
|
|
||||||
//! ## Fallback
|
|
||||||
//!
|
|
||||||
//! If RemoteIO construction fails, the caller falls back to `IosVoiceUnit`
|
|
||||||
//! (VPIO) and logs the error.
|
|
||||||
//!
|
|
||||||
//! ## Status
|
|
||||||
//!
|
|
||||||
//! Experimental / disabled by default. Only activated when the user
|
|
||||||
//! explicitly selects `SonoraExperimental` mode via the bridge API.
|
|
||||||
//!
|
|
||||||
//! ## Platform
|
|
||||||
//!
|
|
||||||
//! `kAudioUnitSubType_RemoteIO` is only available in the iOS SDK.
|
|
||||||
//! This module is gated to `target_os = "ios"`.
|
|
||||||
|
|
||||||
#[cfg(target_os = "ios")]
|
|
||||||
pub use inner::IosRawUnit;
|
|
||||||
|
|
||||||
#[cfg(target_os = "ios")]
|
|
||||||
mod inner {
|
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
use audiopus::coder::Encoder as OpusEncoder;
|
|
||||||
use coreaudio::audio_unit::audio_format::LinearPcmFlags;
|
|
||||||
use coreaudio::audio_unit::render_callback::{self, data};
|
|
||||||
use coreaudio::audio_unit::IOType;
|
|
||||||
use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat};
|
|
||||||
use tracing::{info, warn};
|
|
||||||
|
|
||||||
use crate::mobile_voice_backend::VoiceAudioParams;
|
|
||||||
use crate::processor::AudioProcessor;
|
|
||||||
use crate::AudioError;
|
|
||||||
|
|
||||||
const SAMPLE_RATE_HZ: f64 = 48_000.0;
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ //
|
|
||||||
// Render-reference ring buffer //
|
|
||||||
// ------------------------------------------------------------------ //
|
|
||||||
|
|
||||||
/// 4-slot ring buffer shared between the render callback (writer) and
|
|
||||||
/// the capture callback (reader for Sonora AEC3). Capacity: 4 × 10 ms
|
|
||||||
/// = 40 ms of headroom.
|
|
||||||
///
|
|
||||||
/// If the capture callback runs before the render callback has written
|
|
||||||
/// a frame it reads zeros (silence reference), which is safe — Sonora
|
|
||||||
/// AEC3 simply skips cancellation for that frame.
|
|
||||||
type RenderReferenceBuffer = crate::render_reference::RenderReferenceBuffer<480, 4>;
|
|
||||||
type RenderReferenceFrameAccumulator =
|
|
||||||
crate::render_reference::RenderReferenceFrameAccumulator<480>;
|
|
||||||
const RAW_RENDER_SCRATCH_FRAMES: usize = 1024;
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ //
|
|
||||||
// Capture pipeline state //
|
|
||||||
// ------------------------------------------------------------------ //
|
|
||||||
|
|
||||||
struct RawCaptureState {
|
|
||||||
encoder: OpusEncoder,
|
|
||||||
pcm_accum: Vec<i16>,
|
|
||||||
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
|
||||||
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
|
||||||
transmit_active: Arc<AtomicBool>,
|
|
||||||
output_muted: Arc<AtomicBool>,
|
|
||||||
mic_gain: f32,
|
|
||||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
|
||||||
vad_detector: crate::vad::WebRtcFallbackVad,
|
|
||||||
silero_coreml_worker: Option<crate::vad::apple_coreml::AppleCoreMlVadWorker>,
|
|
||||||
current_vad_backend: crate::VadBackend,
|
|
||||||
capture_frame_seq: u64,
|
|
||||||
vad_state: crate::voice_activity::VoiceActivityStateMachine,
|
|
||||||
/// Processing config — retained for route-change reloads.
|
|
||||||
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
|
|
||||||
webrtc_apm_processor: crate::processor::WebRtcApmProcessor,
|
|
||||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
|
||||||
render_reference: Arc<RenderReferenceBuffer>,
|
|
||||||
pending_10ms: [i16; crate::frame::FRAME_10MS_SAMPLES],
|
|
||||||
pending_10ms_len: usize,
|
|
||||||
fallback_warned_backend: Option<crate::VadBackend>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RawCaptureState {
|
|
||||||
fn new(
|
|
||||||
params: &VoiceAudioParams,
|
|
||||||
render_reference: Arc<RenderReferenceBuffer>,
|
|
||||||
) -> Result<Self, AudioError> {
|
|
||||||
let encoder = crate::opus_voice::new_voip_encoder("ios raw")?;
|
|
||||||
let webrtc_apm_config = params
|
|
||||||
.audio_processing_config
|
|
||||||
.lock()
|
|
||||||
.map(|cfg| crate::processor::webrtc_apm::WebRtcApmConfig::from_audio_config(&cfg))
|
|
||||||
.unwrap_or_default();
|
|
||||||
Ok(Self {
|
|
||||||
encoder,
|
|
||||||
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
|
|
||||||
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
|
||||||
voice_out_tx: crate::opus_voice::start_out_packet_worker(
|
|
||||||
params.voice_out_tx.clone(),
|
|
||||||
params.frames_sent.clone(),
|
|
||||||
"ios-raw",
|
|
||||||
)?,
|
|
||||||
transmit_active: params.transmit_active.clone(),
|
|
||||||
output_muted: params.output_muted.clone(),
|
|
||||||
mic_gain: params.mic_gain,
|
|
||||||
voice_activity_selector: params.voice_activity_selector.clone(),
|
|
||||||
vad_detector: crate::vad::WebRtcFallbackVad::default(),
|
|
||||||
silero_coreml_worker: None,
|
|
||||||
current_vad_backend: crate::VadBackend::WebrtcVad,
|
|
||||||
capture_frame_seq: 0,
|
|
||||||
vad_state: crate::voice_activity::VoiceActivityStateMachine::default(),
|
|
||||||
audio_processing_config: params.audio_processing_config.clone(),
|
|
||||||
webrtc_apm_processor: crate::processor::WebRtcApmProcessor::with_config(
|
|
||||||
webrtc_apm_config,
|
|
||||||
)?,
|
|
||||||
audio_processing_stats: params.audio_processing_stats.clone(),
|
|
||||||
render_reference,
|
|
||||||
pending_10ms: [0_i16; crate::frame::FRAME_10MS_SAMPLES],
|
|
||||||
pending_10ms_len: 0,
|
|
||||||
fallback_warned_backend: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mark_vad_fallback_active(&mut self, failed_backend: crate::VadBackend) {
|
|
||||||
if self.fallback_warned_backend != Some(failed_backend) {
|
|
||||||
self.fallback_warned_backend = Some(failed_backend);
|
|
||||||
if self.capture_frame_seq < 128 {
|
|
||||||
tracing::info!(
|
|
||||||
target: "chanora_audio",
|
|
||||||
backend = failed_backend.as_str(),
|
|
||||||
seq = self.capture_frame_seq,
|
|
||||||
"VAD backend warming up; using WebRTC fallback"
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
tracing::warn!(
|
|
||||||
target: "chanora_audio",
|
|
||||||
backend = failed_backend.as_str(),
|
|
||||||
"VAD backend unavailable; using WebRTC fallback for runtime detection"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ingest_i16(&mut self, samples: &[i16]) {
|
|
||||||
// Accumulate into 10 ms frames for VAD / Sonora processing.
|
|
||||||
let mut offset = 0;
|
|
||||||
while offset < samples.len() {
|
|
||||||
let remaining = crate::frame::FRAME_10MS_SAMPLES - self.pending_10ms_len;
|
|
||||||
let take = remaining.min(samples.len() - offset);
|
|
||||||
self.pending_10ms[self.pending_10ms_len..self.pending_10ms_len + take]
|
|
||||||
.copy_from_slice(&samples[offset..offset + take]);
|
|
||||||
self.pending_10ms_len += take;
|
|
||||||
offset += take;
|
|
||||||
|
|
||||||
if self.pending_10ms_len == crate::frame::FRAME_10MS_SAMPLES {
|
|
||||||
let frame = self.pending_10ms;
|
|
||||||
self.process_10ms_capture_frame(&frame);
|
|
||||||
self.encode_complete_20ms_frames();
|
|
||||||
self.pending_10ms_len = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !self.transmit_active.load(Ordering::Relaxed) {
|
|
||||||
self.pcm_accum.clear();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.encode_complete_20ms_frames();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn encode_complete_20ms_frames(&mut self) {
|
|
||||||
while self.pcm_accum.len() >= crate::frame::FRAME_20MS_SAMPLES {
|
|
||||||
let mut frame = [0i16; crate::frame::FRAME_20MS_SAMPLES];
|
|
||||||
frame.copy_from_slice(&self.pcm_accum[..crate::frame::FRAME_20MS_SAMPLES]);
|
|
||||||
self.pcm_accum.drain(..crate::frame::FRAME_20MS_SAMPLES);
|
|
||||||
|
|
||||||
match self.encoder.encode(&frame, &mut self.opus_out[..]) {
|
|
||||||
Ok(len) => {
|
|
||||||
crate::opus_voice::send_voip_frame(
|
|
||||||
&self.voice_out_tx,
|
|
||||||
&self.opus_out,
|
|
||||||
len,
|
|
||||||
|| {
|
|
||||||
warn!(
|
|
||||||
target: "chanora_audio",
|
|
||||||
"ios raw: voice_out queue full; dropping frame"
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|| {},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!(target: "chanora_audio",
|
|
||||||
error = %e, "ios raw opus encode failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn process_10ms_capture_frame(
|
|
||||||
&mut self,
|
|
||||||
samples: &[i16; crate::frame::FRAME_10MS_SAMPLES],
|
|
||||||
) {
|
|
||||||
let mut frame = [0.0_f32; crate::frame::FRAME_10MS_SAMPLES];
|
|
||||||
for (dst, src) in frame.iter_mut().zip(samples.iter().copied()) {
|
|
||||||
*dst = crate::frame::i16_to_f32(src);
|
|
||||||
}
|
|
||||||
let input_dbfs = crate::frame::dbfs(&frame);
|
|
||||||
|
|
||||||
// Debug WAV mic taps are intentionally unavailable on iOS raw
|
|
||||||
// realtime callbacks until WavDebugRecorder supports a
|
|
||||||
// preallocated handoff path; the current recorder push path
|
|
||||||
// allocates per frame.
|
|
||||||
|
|
||||||
// Feed render reference to WebRTC APM before capture so AEC can adapt.
|
|
||||||
let render_ref = self.render_reference.read_latest();
|
|
||||||
self.webrtc_apm_processor.process_render(&render_ref);
|
|
||||||
self.webrtc_apm_processor.process_capture(&mut frame);
|
|
||||||
|
|
||||||
// Processed-mic debug WAV capture is disabled for the same
|
|
||||||
// realtime allocation reason as the raw-mic tap above.
|
|
||||||
|
|
||||||
let voice_activity_mode = self
|
|
||||||
.voice_activity_selector
|
|
||||||
.as_ref()
|
|
||||||
.map(|selector| selector.mode() == crate::TransmitMode::VoiceActivity)
|
|
||||||
.unwrap_or(false);
|
|
||||||
if !voice_activity_mode {
|
|
||||||
self.silero_coreml_worker = None;
|
|
||||||
self.current_vad_backend = crate::VadBackend::Disabled;
|
|
||||||
self.fallback_warned_backend = None;
|
|
||||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
let (vad_backend, vad_hangover) = self
|
|
||||||
.audio_processing_config
|
|
||||||
.try_lock()
|
|
||||||
.map(|cfg| (cfg.vad_backend, cfg.vad_hangover_ms))
|
|
||||||
.unwrap_or((
|
|
||||||
crate::VadBackend::WebrtcVad,
|
|
||||||
crate::voice_activity::VAD_HANGOVER_MS,
|
|
||||||
));
|
|
||||||
if voice_activity_mode {
|
|
||||||
self.vad_state.configure(
|
|
||||||
crate::voice_activity::VAD_OPEN_AFTER_MS,
|
|
||||||
vad_hangover,
|
|
||||||
crate::voice_activity::VAD_MIN_TX_MS,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if voice_activity_mode && vad_backend != self.current_vad_backend {
|
|
||||||
self.current_vad_backend = vad_backend;
|
|
||||||
self.fallback_warned_backend = None;
|
|
||||||
if vad_backend == crate::VadBackend::SileroOnnx {
|
|
||||||
self.silero_coreml_worker = None;
|
|
||||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
|
||||||
self.audio_processing_stats.set_vad_fallback_active(true);
|
|
||||||
} else {
|
|
||||||
self.silero_coreml_worker = None;
|
|
||||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
|
||||||
}
|
|
||||||
self.vad_state.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
let (vad_probability, active) = if voice_activity_mode {
|
|
||||||
self.capture_frame_seq = self.capture_frame_seq.wrapping_add(1);
|
|
||||||
let capture_seq = self.capture_frame_seq;
|
|
||||||
let mut used_fallback_vad = false;
|
|
||||||
let vad = if vad_backend == crate::VadBackend::Disabled {
|
|
||||||
crate::vad::VadOutput {
|
|
||||||
probability: 1.0,
|
|
||||||
speech: true,
|
|
||||||
}
|
|
||||||
} else if vad_backend == crate::VadBackend::SileroOnnx {
|
|
||||||
match crate::vad::callback_vad_worker_policy(
|
|
||||||
voice_activity_mode,
|
|
||||||
vad_backend,
|
|
||||||
self.silero_coreml_worker.is_some(),
|
|
||||||
) {
|
|
||||||
crate::vad::VadWorkerPolicy::UseWorker => {
|
|
||||||
let worker = self
|
|
||||||
.silero_coreml_worker
|
|
||||||
.as_ref()
|
|
||||||
.expect("policy checked worker");
|
|
||||||
let enqueued = worker.try_send(capture_seq, &frame);
|
|
||||||
if !worker.is_stale(capture_seq) {
|
|
||||||
let p = worker.latest_probability();
|
|
||||||
crate::vad::VadOutput {
|
|
||||||
probability: p,
|
|
||||||
speech: p >= 0.5,
|
|
||||||
}
|
|
||||||
} else if enqueued {
|
|
||||||
crate::vad::VadOutput {
|
|
||||||
probability: 0.0,
|
|
||||||
speech: false,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
used_fallback_vad = true;
|
|
||||||
self.mark_vad_fallback_active(vad_backend);
|
|
||||||
crate::vad::VoiceActivityDetector::process_10ms(
|
|
||||||
&mut self.vad_detector,
|
|
||||||
&frame,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
crate::vad::VadWorkerPolicy::UseFallback => {
|
|
||||||
used_fallback_vad = true;
|
|
||||||
self.mark_vad_fallback_active(vad_backend);
|
|
||||||
crate::vad::VoiceActivityDetector::process_10ms(
|
|
||||||
&mut self.vad_detector,
|
|
||||||
&frame,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
crate::vad::VadWorkerPolicy::NotModelBacked => crate::vad::VadOutput {
|
|
||||||
probability: 1.0,
|
|
||||||
speech: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
|
||||||
};
|
|
||||||
self.audio_processing_stats
|
|
||||||
.set_vad_fallback_active(used_fallback_vad);
|
|
||||||
(vad.probability, self.vad_state.update(vad.speech))
|
|
||||||
} else {
|
|
||||||
(0.0, false)
|
|
||||||
};
|
|
||||||
if let Some(sel) = &self.voice_activity_selector {
|
|
||||||
sel.set_voice_activity_open(voice_activity_mode && active);
|
|
||||||
}
|
|
||||||
self.audio_processing_stats.update_capture(
|
|
||||||
input_dbfs,
|
|
||||||
crate::frame::dbfs(&frame),
|
|
||||||
vad_probability,
|
|
||||||
voice_activity_mode && active,
|
|
||||||
self.transmit_active.load(Ordering::Relaxed),
|
|
||||||
);
|
|
||||||
|
|
||||||
if !self.transmit_active.load(Ordering::Relaxed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if crate::capture_accumulator::append_processed_i16_bounded(
|
|
||||||
&mut self.pcm_accum,
|
|
||||||
&frame,
|
|
||||||
self.mic_gain,
|
|
||||||
) {
|
|
||||||
self.audio_processing_stats.increment_callback_xrun();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ //
|
|
||||||
// IosRawUnit //
|
|
||||||
// ------------------------------------------------------------------ //
|
|
||||||
|
|
||||||
/// Raw iOS RemoteIO audio unit for the Sonora experimental path.
|
|
||||||
pub struct IosRawUnit {
|
|
||||||
unit: AudioUnit,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IosRawUnit {
|
|
||||||
/// Open a RemoteIO AudioUnit, install render + input callbacks, start.
|
|
||||||
pub(crate) fn start(params: VoiceAudioParams) -> Result<Self, AudioError> {
|
|
||||||
// INV_010: reject if config requests VPIO (that's IosVoiceUnit's job).
|
|
||||||
{
|
|
||||||
let cfg = params.audio_processing_config.lock().unwrap();
|
|
||||||
if cfg.ios_mode == crate::IosVoiceProcessingMode::PlatformVoiceProcessing {
|
|
||||||
return Err(AudioError::InvalidAudioProcessingConfig(
|
|
||||||
"IosRawUnit requires raw WebRTC APM mode".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut unit = AudioUnit::new_uninitialized(IOType::RemoteIO)
|
|
||||||
.map_err(|e| AudioError::Backend(format!("remoteio new: {e}")))?;
|
|
||||||
|
|
||||||
// Enable input on bus 1.
|
|
||||||
const ENABLE_IO: u32 = 2003;
|
|
||||||
let enable: u32 = 1;
|
|
||||||
unit.set_property(ENABLE_IO, Scope::Input, Element::Input, Some(&enable))
|
|
||||||
.map_err(|e| AudioError::Backend(format!("remoteio enable input: {e}")))?;
|
|
||||||
|
|
||||||
// 48 kHz Int16 mono on both buses.
|
|
||||||
let fmt = StreamFormat {
|
|
||||||
sample_rate: SAMPLE_RATE_HZ,
|
|
||||||
sample_format: SampleFormat::I16,
|
|
||||||
flags: LinearPcmFlags::IS_SIGNED_INTEGER | LinearPcmFlags::IS_PACKED,
|
|
||||||
channels: 1,
|
|
||||||
};
|
|
||||||
unit.set_stream_format(fmt, Scope::Input, Element::Output)
|
|
||||||
.map_err(|e| AudioError::StreamConfig(format!("remoteio fmt output: {e}")))?;
|
|
||||||
unit.set_stream_format(fmt, Scope::Output, Element::Input)
|
|
||||||
.map_err(|e| AudioError::StreamConfig(format!("remoteio fmt input: {e}")))?;
|
|
||||||
|
|
||||||
// Shared render-reference buffer (INV_011 / INV_012).
|
|
||||||
let render_ref_buf = RenderReferenceBuffer::new();
|
|
||||||
let render_ref_for_capture = render_ref_buf.clone();
|
|
||||||
|
|
||||||
let mut capture_state = RawCaptureState::new(¶ms, render_ref_for_capture)?;
|
|
||||||
|
|
||||||
unit.set_input_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
|
||||||
capture_state.ingest_i16(args.data.buffer);
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.map_err(|e| AudioError::Backend(format!("remoteio input cb: {e}")))?;
|
|
||||||
|
|
||||||
let mut scratch = [0.0_f32; RAW_RENDER_SCRATCH_FRAMES * 2];
|
|
||||||
let mut mono = [0.0_f32; RAW_RENDER_SCRATCH_FRAMES];
|
|
||||||
let mut render_ref_accum = RenderReferenceFrameAccumulator::new();
|
|
||||||
let handler = params.handler.clone();
|
|
||||||
let output_gain = params.output_gain.clone();
|
|
||||||
let output_muted = params.output_muted.clone();
|
|
||||||
let stats_render = params.audio_processing_stats.clone();
|
|
||||||
|
|
||||||
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
|
||||||
let out = args.data.buffer;
|
|
||||||
let n = out.len();
|
|
||||||
let process_n = n.min(RAW_RENDER_SCRATCH_FRAMES);
|
|
||||||
let stereo_n = process_n * 2;
|
|
||||||
if n > RAW_RENDER_SCRATCH_FRAMES {
|
|
||||||
stats_render.increment_callback_xrun();
|
|
||||||
}
|
|
||||||
scratch[..stereo_n].fill(0.0);
|
|
||||||
|
|
||||||
match handler.try_lock() {
|
|
||||||
Ok(mut h) => {
|
|
||||||
let _ = h.fill_buffer(&mut scratch[..stereo_n]);
|
|
||||||
}
|
|
||||||
Err(std::sync::TryLockError::WouldBlock) => {
|
|
||||||
stats_render.increment_callback_xrun();
|
|
||||||
}
|
|
||||||
Err(std::sync::TryLockError::Poisoned(e)) => {
|
|
||||||
warn!(target: "chanora_audio",
|
|
||||||
"AudioHandler poisoned (raw render): {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// INV_012: copy render reference BEFORE playout.
|
|
||||||
crate::voice_render::downmix_stereo_f32_to_mono_f32(
|
|
||||||
&scratch[..stereo_n],
|
|
||||||
&mut mono[..process_n],
|
|
||||||
);
|
|
||||||
render_ref_accum.push_mono_samples(&mono[..process_n], |frame| {
|
|
||||||
render_ref_buf.write(frame);
|
|
||||||
});
|
|
||||||
|
|
||||||
let gain = f32::from_bits(output_gain.load(Ordering::Relaxed));
|
|
||||||
let muted = output_muted.load(Ordering::Relaxed);
|
|
||||||
let mix_stats = crate::voice_render::downmix_stereo_f32_to_interleaved_i16(
|
|
||||||
&scratch[..stereo_n],
|
|
||||||
&mut out[..process_n],
|
|
||||||
1,
|
|
||||||
gain,
|
|
||||||
muted,
|
|
||||||
);
|
|
||||||
if process_n < n {
|
|
||||||
out[process_n..].fill(0);
|
|
||||||
}
|
|
||||||
if mix_stats.clipped_samples > 0 {
|
|
||||||
stats_render.add_clipped_samples(mix_stats.clipped_samples);
|
|
||||||
}
|
|
||||||
stats_render.update_render(crate::frame::dbfs(&scratch[..stereo_n]), n as u32);
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.map_err(|e| AudioError::Backend(format!("remoteio render cb: {e}")))?;
|
|
||||||
|
|
||||||
unit.initialize()
|
|
||||||
.map_err(|e| AudioError::Backend(format!("remoteio init: {e}")))?;
|
|
||||||
unit.start()
|
|
||||||
.map_err(|e| AudioError::Backend(format!("remoteio start: {e}")))?;
|
|
||||||
|
|
||||||
info!(
|
|
||||||
target: "chanora_audio",
|
|
||||||
sample_rate_hz = SAMPLE_RATE_HZ,
|
|
||||||
"ios RemoteIO (Sonora experimental) started"
|
|
||||||
);
|
|
||||||
Ok(Self { unit })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Restart the unit after a route change (stop → uninit → init → start).
|
|
||||||
pub fn restart(&mut self) -> Result<(), AudioError> {
|
|
||||||
self.unit
|
|
||||||
.stop()
|
|
||||||
.map_err(|e| AudioError::Backend(format!("remoteio restart stop: {e}")))?;
|
|
||||||
self.unit
|
|
||||||
.uninitialize()
|
|
||||||
.map_err(|e| AudioError::Backend(format!("remoteio restart uninit: {e}")))?;
|
|
||||||
self.unit
|
|
||||||
.initialize()
|
|
||||||
.map_err(|e| AudioError::Backend(format!("remoteio restart init: {e}")))?;
|
|
||||||
self.unit
|
|
||||||
.start()
|
|
||||||
.map_err(|e| AudioError::Backend(format!("remoteio restart start: {e}")))?;
|
|
||||||
info!(target: "chanora_audio", "ios RemoteIO restarted");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pause the unit during an AVAudioSession interruption.
|
|
||||||
pub fn pause(&mut self) -> Result<(), AudioError> {
|
|
||||||
self.unit
|
|
||||||
.stop()
|
|
||||||
.map_err(|e| AudioError::Backend(format!("remoteio pause: {e}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resume the unit after an interruption ends.
|
|
||||||
pub fn resume(&mut self) -> Result<(), AudioError> {
|
|
||||||
self.unit
|
|
||||||
.start()
|
|
||||||
.map_err(|e| AudioError::Backend(format!("remoteio resume: {e}")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for IosRawUnit {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
if let Err(e) = self.unit.stop() {
|
|
||||||
warn!(target: "chanora_audio", error = %e,
|
|
||||||
"ios RemoteIO stop on drop failed");
|
|
||||||
} else {
|
|
||||||
info!(target: "chanora_audio", "ios RemoteIO stopped");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -406,38 +406,20 @@ impl IosCaptureState {
|
|||||||
speech: true,
|
speech: true,
|
||||||
}
|
}
|
||||||
} else if vad_backend == crate::VadBackend::SileroOnnx {
|
} else if vad_backend == crate::VadBackend::SileroOnnx {
|
||||||
match crate::vad::callback_vad_worker_policy(
|
if let Some(worker) = self.silero_coreml_worker.as_ref() {
|
||||||
voice_activity_mode,
|
let enqueued = worker.try_send(capture_seq, &frame);
|
||||||
vad_backend,
|
if !worker.is_stale(capture_seq) {
|
||||||
self.silero_coreml_worker.is_some(),
|
let p = worker.latest_probability();
|
||||||
) {
|
crate::vad::VadOutput {
|
||||||
crate::vad::VadWorkerPolicy::UseWorker => {
|
probability: p,
|
||||||
let worker = self
|
speech: p >= 0.5,
|
||||||
.silero_coreml_worker
|
|
||||||
.as_ref()
|
|
||||||
.expect("policy checked worker");
|
|
||||||
let enqueued = worker.try_send(capture_seq, &frame);
|
|
||||||
if !worker.is_stale(capture_seq) {
|
|
||||||
let p = worker.latest_probability();
|
|
||||||
crate::vad::VadOutput {
|
|
||||||
probability: p,
|
|
||||||
speech: p >= 0.5,
|
|
||||||
}
|
|
||||||
} else if enqueued {
|
|
||||||
crate::vad::VadOutput {
|
|
||||||
probability: 0.0,
|
|
||||||
speech: false,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
used_fallback_vad = true;
|
|
||||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
|
||||||
crate::vad::VoiceActivityDetector::process_10ms(
|
|
||||||
&mut self.vad_detector,
|
|
||||||
&frame,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
} else if enqueued {
|
||||||
crate::vad::VadWorkerPolicy::UseFallback => {
|
crate::vad::VadOutput {
|
||||||
|
probability: 0.0,
|
||||||
|
speech: false,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
used_fallback_vad = true;
|
used_fallback_vad = true;
|
||||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||||
crate::vad::VoiceActivityDetector::process_10ms(
|
crate::vad::VoiceActivityDetector::process_10ms(
|
||||||
@@ -445,10 +427,13 @@ impl IosCaptureState {
|
|||||||
&frame,
|
&frame,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
crate::vad::VadWorkerPolicy::NotModelBacked => crate::vad::VadOutput {
|
} else {
|
||||||
probability: 1.0,
|
used_fallback_vad = true;
|
||||||
speech: true,
|
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||||
},
|
crate::vad::VoiceActivityDetector::process_10ms(
|
||||||
|
&mut self.vad_detector,
|
||||||
|
&frame,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||||
@@ -564,7 +549,7 @@ impl IosVoiceUnit {
|
|||||||
let unit_arc2 = Arc::clone(&unit_arc);
|
let unit_arc2 = Arc::clone(&unit_arc);
|
||||||
|
|
||||||
dispatch2::DispatchQueue::main().exec_async(move || {
|
dispatch2::DispatchQueue::main().exec_async(move || {
|
||||||
let mut guard = unit_arc2.lock().unwrap();
|
let mut guard = unit_arc2.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
let unit = guard.as_mut().unwrap();
|
let unit = guard.as_mut().unwrap();
|
||||||
let _ = tx.send(op(unit));
|
let _ = tx.send(op(unit));
|
||||||
});
|
});
|
||||||
@@ -574,7 +559,7 @@ impl IosVoiceUnit {
|
|||||||
Err(_) => Err("vpio lifecycle: main thread channel closed unexpectedly".to_string()),
|
Err(_) => Err("vpio lifecycle: main thread channel closed unexpectedly".to_string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
self.unit = unit_arc.lock().unwrap().take();
|
self.unit = unit_arc.lock().unwrap_or_else(|e| e.into_inner()).take();
|
||||||
result.map_err(AudioError::Backend)
|
result.map_err(AudioError::Backend)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1087,7 +1072,7 @@ impl IosVoiceUnit {
|
|||||||
let unit_arc2 = unit_arc.clone();
|
let unit_arc2 = unit_arc.clone();
|
||||||
|
|
||||||
dispatch2::DispatchQueue::main().exec_async(move || {
|
dispatch2::DispatchQueue::main().exec_async(move || {
|
||||||
let mut guard = unit_arc2.lock().unwrap();
|
let mut guard = unit_arc2.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
let u = guard.as_mut().unwrap();
|
let u = guard.as_mut().unwrap();
|
||||||
let result = u
|
let result = u
|
||||||
.initialize()
|
.initialize()
|
||||||
@@ -1109,7 +1094,7 @@ impl IosVoiceUnit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
unit = unit_arc.lock().unwrap().take().unwrap();
|
unit = unit_arc.lock().unwrap_or_else(|e| e.into_inner()).take().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
|
|||||||
@@ -65,10 +65,7 @@ pub(crate) mod voice_render;
|
|||||||
mod sdl_output;
|
mod sdl_output;
|
||||||
|
|
||||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
mod ios_voice_unit;
|
mod ios_voice_unit;
|
||||||
|
|
||||||
#[cfg(target_os = "ios")]
|
|
||||||
pub mod ios_raw_unit;
|
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
pub mod android_voice_unit;
|
pub mod android_voice_unit;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
//! `cpal` (and SDL on Linux) own desktop capture/playback per the
|
//! `cpal` (and SDL on Linux) own desktop capture/playback per the
|
||||||
//! existing audio engine design.
|
//! existing audio engine design.
|
||||||
|
|
||||||
// TODO(SDD-117): back-fill `IosVoiceUnit` to implement this trait
|
// TRACKED(SDD-117): back-fill `IosVoiceUnit` to implement this trait
|
||||||
// so the engine can hold a single `Box<dyn MobileVoiceAudioBackend>`
|
// so the engine can hold a single `Box<dyn MobileVoiceAudioBackend>`
|
||||||
// across iOS and Android.
|
// across iOS and Android.
|
||||||
|
|
||||||
|
|||||||
@@ -543,7 +543,7 @@ impl DesktopPttBackend for MacOSEventTapBackend {
|
|||||||
}
|
}
|
||||||
let runloop = unsafe { CFRunLoopGetCurrent() };
|
let runloop = unsafe { CFRunLoopGetCurrent() };
|
||||||
{
|
{
|
||||||
let mut g = worker_runloop.lock().unwrap();
|
let mut g = worker_runloop.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
*g = Some(RunLoopHandle(runloop));
|
*g = Some(RunLoopHandle(runloop));
|
||||||
}
|
}
|
||||||
unsafe {
|
unsafe {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ use std::thread;
|
|||||||
|
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
use windows::core::{w, PCWSTR};
|
use windows::core::{w, PCWSTR};
|
||||||
use windows::Win32::Foundation::{HMODULE, HWND, LPARAM, LRESULT, WPARAM};
|
use windows::Win32::Foundation::{HINSTANCE, HMODULE, HWND, LPARAM, LRESULT, WPARAM};
|
||||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||||
use windows::Win32::UI::Input::{
|
use windows::Win32::UI::Input::{
|
||||||
GetRawInputData, RegisterRawInputDevices, HRAWINPUT, RAWINPUT, RAWINPUTDEVICE, RAWINPUTHEADER,
|
GetRawInputData, RegisterRawInputDevices, HRAWINPUT, RAWINPUT, RAWINPUTDEVICE, RAWINPUTHEADER,
|
||||||
@@ -35,7 +35,7 @@ use windows::Win32::UI::Input::{
|
|||||||
use windows::Win32::UI::WindowsAndMessaging::{
|
use windows::Win32::UI::WindowsAndMessaging::{
|
||||||
CallNextHookEx, CreateWindowExW, DefWindowProcW, DispatchMessageW, GetMessageW,
|
CallNextHookEx, CreateWindowExW, DefWindowProcW, DispatchMessageW, GetMessageW,
|
||||||
PostThreadMessageW, RegisterClassExW, SetWindowsHookExW, TranslateMessage, UnhookWindowsHookEx,
|
PostThreadMessageW, RegisterClassExW, SetWindowsHookExW, TranslateMessage, UnhookWindowsHookEx,
|
||||||
HC_ACTION, HHOOK, HOOKPROC, KBDLLHOOKSTRUCT, MSG, MSLLHOOKSTRUCT, WH_KEYBOARD_LL, WH_MOUSE_LL,
|
HC_ACTION, HOOKPROC, KBDLLHOOKSTRUCT, MSG, MSLLHOOKSTRUCT, WH_KEYBOARD_LL, WH_MOUSE_LL,
|
||||||
WINDOW_EX_STYLE, WINDOW_STYLE, WM_INPUT, WM_KEYDOWN, WM_KEYUP, WM_QUIT, WM_SYSKEYDOWN,
|
WINDOW_EX_STYLE, WINDOW_STYLE, WM_INPUT, WM_KEYDOWN, WM_KEYUP, WM_QUIT, WM_SYSKEYDOWN,
|
||||||
WM_SYSKEYUP, WM_XBUTTONDOWN, WM_XBUTTONUP, WNDCLASSEXW, XBUTTON1, XBUTTON2,
|
WM_SYSKEYUP, WM_XBUTTONDOWN, WM_XBUTTONUP, WNDCLASSEXW, XBUTTON1, XBUTTON2,
|
||||||
};
|
};
|
||||||
@@ -423,7 +423,7 @@ unsafe fn run_raw_input_loop(
|
|||||||
// class.
|
// class.
|
||||||
let _atom = RegisterClassExW(&wc);
|
let _atom = RegisterClassExW(&wc);
|
||||||
|
|
||||||
let hwnd = unsafe {
|
let hwnd = match unsafe {
|
||||||
CreateWindowExW(
|
CreateWindowExW(
|
||||||
WINDOW_EX_STYLE(0),
|
WINDOW_EX_STYLE(0),
|
||||||
class_name,
|
class_name,
|
||||||
@@ -433,13 +433,23 @@ unsafe fn run_raw_input_loop(
|
|||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
HWND(HWND_MESSAGE_PTR),
|
Some(HWND(HWND_MESSAGE_PTR as *mut core::ffi::c_void)),
|
||||||
None,
|
None,
|
||||||
h_instance,
|
Some(HINSTANCE(h_instance.0)),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
} {
|
||||||
|
Ok(h) => h,
|
||||||
|
Err(_) => {
|
||||||
|
warn!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
"windows ptt: CreateWindowExW(HWND_MESSAGE) returned null"
|
||||||
|
);
|
||||||
|
report!(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if hwnd.0 == 0 {
|
if hwnd.0.is_null() {
|
||||||
warn!(
|
warn!(
|
||||||
target: "chanora_audio",
|
target: "chanora_audio",
|
||||||
"windows ptt: CreateWindowExW(HWND_MESSAGE) returned null"
|
"windows ptt: CreateWindowExW(HWND_MESSAGE) returned null"
|
||||||
@@ -517,13 +527,13 @@ unsafe fn run_raw_input_loop(
|
|||||||
usUsagePage: 0x01,
|
usUsagePage: 0x01,
|
||||||
usUsage: 0x06,
|
usUsage: 0x06,
|
||||||
dwFlags: RIDEV_REMOVE,
|
dwFlags: RIDEV_REMOVE,
|
||||||
hwndTarget: HWND(0),
|
hwndTarget: HWND(std::ptr::null_mut()),
|
||||||
},
|
},
|
||||||
RAWINPUTDEVICE {
|
RAWINPUTDEVICE {
|
||||||
usUsagePage: 0x01,
|
usUsagePage: 0x01,
|
||||||
usUsage: 0x02,
|
usUsage: 0x02,
|
||||||
dwFlags: RIDEV_REMOVE,
|
dwFlags: RIDEV_REMOVE,
|
||||||
hwndTarget: HWND(0),
|
hwndTarget: HWND(std::ptr::null_mut()),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
let _ = RegisterRawInputDevices(&undo, std::mem::size_of::<RAWINPUTDEVICE>() as u32);
|
let _ = RegisterRawInputDevices(&undo, std::mem::size_of::<RAWINPUTDEVICE>() as u32);
|
||||||
@@ -544,7 +554,7 @@ unsafe extern "system" fn raw_input_wnd_proc(
|
|||||||
}
|
}
|
||||||
|
|
||||||
unsafe fn handle_wm_input(lparam: LPARAM) {
|
unsafe fn handle_wm_input(lparam: LPARAM) {
|
||||||
let h_raw = HRAWINPUT(lparam.0);
|
let h_raw = HRAWINPUT(lparam.0 as *mut core::ffi::c_void);
|
||||||
let mut size: u32 = 0;
|
let mut size: u32 = 0;
|
||||||
let header_sz = std::mem::size_of::<RAWINPUTHEADER>() as u32;
|
let header_sz = std::mem::size_of::<RAWINPUTHEADER>() as u32;
|
||||||
// First call: query buffer size.
|
// First call: query buffer size.
|
||||||
@@ -841,31 +851,33 @@ unsafe fn run_hook_loop(
|
|||||||
let kbd_proc: HOOKPROC = Some(kbd_hook_proc);
|
let kbd_proc: HOOKPROC = Some(kbd_hook_proc);
|
||||||
let mouse_proc: HOOKPROC = Some(mouse_hook_proc);
|
let mouse_proc: HOOKPROC = Some(mouse_hook_proc);
|
||||||
|
|
||||||
let kbd_hook = match SetWindowsHookExW(WH_KEYBOARD_LL, kbd_proc, h_instance, 0) {
|
let kbd_hook =
|
||||||
Ok(h) => h,
|
match SetWindowsHookExW(WH_KEYBOARD_LL, kbd_proc, Some(HINSTANCE(h_instance.0)), 0) {
|
||||||
Err(e) => {
|
Ok(h) => h,
|
||||||
warn!(
|
Err(e) => {
|
||||||
target: "chanora_audio",
|
warn!(
|
||||||
error = %e,
|
target: "chanora_audio",
|
||||||
"windows ptt: SetWindowsHookExW(WH_KEYBOARD_LL) failed"
|
error = %e,
|
||||||
);
|
"windows ptt: SetWindowsHookExW(WH_KEYBOARD_LL) failed"
|
||||||
report!(false);
|
);
|
||||||
return false;
|
report!(false);
|
||||||
}
|
return false;
|
||||||
};
|
}
|
||||||
let mouse_hook = match SetWindowsHookExW(WH_MOUSE_LL, mouse_proc, h_instance, 0) {
|
};
|
||||||
Ok(h) => h,
|
let mouse_hook =
|
||||||
Err(e) => {
|
match SetWindowsHookExW(WH_MOUSE_LL, mouse_proc, Some(HINSTANCE(h_instance.0)), 0) {
|
||||||
warn!(
|
Ok(h) => h,
|
||||||
target: "chanora_audio",
|
Err(e) => {
|
||||||
error = %e,
|
warn!(
|
||||||
"windows ptt: SetWindowsHookExW(WH_MOUSE_LL) failed"
|
target: "chanora_audio",
|
||||||
);
|
error = %e,
|
||||||
let _ = UnhookWindowsHookEx(kbd_hook);
|
"windows ptt: SetWindowsHookExW(WH_MOUSE_LL) failed"
|
||||||
report!(false);
|
);
|
||||||
return false;
|
let _ = UnhookWindowsHookEx(kbd_hook);
|
||||||
}
|
report!(false);
|
||||||
};
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
target: "chanora_audio",
|
target: "chanora_audio",
|
||||||
@@ -908,7 +920,7 @@ unsafe extern "system" fn kbd_hook_proc(code: i32, wparam: WPARAM, lparam: LPARA
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
CallNextHookEx(HHOOK(0), code, wparam, lparam)
|
CallNextHookEx(None, code, wparam, lparam)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pure-logic dispatcher for a low-level keyboard hook event (L0
|
/// Pure-logic dispatcher for a low-level keyboard hook event (L0
|
||||||
@@ -943,7 +955,7 @@ unsafe extern "system" fn mouse_hook_proc(code: i32, wparam: WPARAM, lparam: LPA
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
CallNextHookEx(HHOOK(0), code, wparam, lparam)
|
CallNextHookEx(None, code, wparam, lparam)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pure-logic dispatcher for a low-level mouse hook event (L0
|
/// Pure-logic dispatcher for a low-level mouse hook event (L0
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ impl AudioCallback for TsPlaybackCallback {
|
|||||||
// in the cpal path, but the upstream design has shipped
|
// in the cpal path, but the upstream design has shipped
|
||||||
// this way for years.
|
// this way for years.
|
||||||
{
|
{
|
||||||
let mut data = self.handler.lock().unwrap();
|
let mut data = self.handler.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
let _removed_ids = data.fill_buffer(buffer);
|
let _removed_ids = data.fill_buffer(buffer);
|
||||||
// `_removed_ids` is the list of clients whose stream the
|
// `_removed_ids` is the list of clients whose stream the
|
||||||
// handler just finished draining. We could publish that
|
// handler just finished draining. We could publish that
|
||||||
|
|||||||
@@ -89,7 +89,8 @@ unsafe fn resolve_symbol(name: &'static [u8]) -> Option<*mut c_void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 16 kHz detector backed by Swift `SileroCoreML.SileroVAD`.
|
/// 16 kHz detector backed by Swift Silero CoreML VAD.
|
||||||
|
/// Processes 16 kHz frames and outputs speech probability.
|
||||||
pub struct AppleCoreMlVad {
|
pub struct AppleCoreMlVad {
|
||||||
handle: *mut c_void,
|
handle: *mut c_void,
|
||||||
symbols: AppleSileroSymbols,
|
symbols: AppleSileroSymbols,
|
||||||
|
|||||||
@@ -8,20 +8,21 @@
|
|||||||
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
#[cfg(any(target_os = "ios", target_os = "macos"))]
|
||||||
pub mod apple_coreml;
|
pub mod apple_coreml;
|
||||||
pub mod resampler;
|
pub mod resampler;
|
||||||
#[cfg(not(target_os = "ios"))]
|
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||||
pub mod silero_onnx;
|
pub mod silero_onnx;
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::sync::{OnceLock, RwLock};
|
use std::sync::{OnceLock, RwLock};
|
||||||
|
|
||||||
use crate::frame::{f32_to_i16, i16_to_f32};
|
use crate::frame::{f32_to_i16, i16_to_f32};
|
||||||
use crate::{AudioError, VadBackend};
|
use crate::AudioError;
|
||||||
use resampler::{Downsampler48to16, INPUT_FRAME_10MS};
|
use resampler::{Downsampler48to16, INPUT_FRAME_10MS};
|
||||||
|
|
||||||
#[cfg(not(target_os = "ios"))]
|
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||||
pub use silero_onnx::SileroOnnxVad;
|
pub use silero_onnx::SileroOnnxVad;
|
||||||
|
|
||||||
/// Voice activity detector output for one 10 ms frame.
|
/// Voice activity detector output for one 10 ms frame.
|
||||||
|
/// Contains speech probability and binary decision.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct VadOutput {
|
pub struct VadOutput {
|
||||||
/// Speech confidence in the inclusive range `[0.0, 1.0]`.
|
/// Speech confidence in the inclusive range `[0.0, 1.0]`.
|
||||||
@@ -36,15 +37,19 @@ pub trait VoiceActivityDetector: Send {
|
|||||||
fn process_10ms(&mut self, samples: &[f32]) -> VadOutput;
|
fn process_10ms(&mut self, samples: &[f32]) -> VadOutput;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Realtime-safe WebRTC VAD used when a model runtime is unavailable.
|
/// Realtime-safe WebRTC VAD fallback when ONNX runtime is unavailable.
|
||||||
|
/// Uses aggressive mode at 48 kHz for voice detection.
|
||||||
pub struct WebRtcFallbackVad {
|
pub struct WebRtcFallbackVad {
|
||||||
vad: webrtc_vad::Vad,
|
vad: webrtc_vad::Vad,
|
||||||
frame_i16: [i16; INPUT_FRAME_10MS],
|
frame_i16: [i16; INPUT_FRAME_10MS],
|
||||||
}
|
}
|
||||||
|
|
||||||
// `webrtc_vad::Vad` owns an FFI pointer and is only touched from the
|
// SAFETY: `webrtc_vad::Vad` wraps an opaque FFI pointer to the WebRTC C VAD
|
||||||
// capture thread after construction. Moving the wrapper between threads is
|
// state. The underlying C struct has no interior mutability that would cause
|
||||||
// safe; sharing it concurrently is not required and not implemented.
|
// data races when moved between threads — `WebRtcVad_Process()` reads/writes
|
||||||
|
// the struct exclusively through the passed pointer with no shared static state.
|
||||||
|
// This wrapper is only used from a single capture thread after construction;
|
||||||
|
// we never share `&WebRtcFallbackVad` across threads (no `Sync` impl).
|
||||||
unsafe impl Send for WebRtcFallbackVad {}
|
unsafe impl Send for WebRtcFallbackVad {}
|
||||||
|
|
||||||
impl Default for WebRtcFallbackVad {
|
impl Default for WebRtcFallbackVad {
|
||||||
@@ -72,8 +77,8 @@ impl VoiceActivityDetector for WebRtcFallbackVad {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wraps any `VoiceActivityDetector` that operates at 16 kHz and
|
/// Wraps any `VoiceActivityDetector` operating at 16 kHz,
|
||||||
/// downsamples 48 kHz input before forwarding.
|
/// downsampling 48 kHz input before forwarding to the detector.
|
||||||
pub struct Resampled16kHzVad<D: VoiceActivityDetector> {
|
pub struct Resampled16kHzVad<D: VoiceActivityDetector> {
|
||||||
inner: D,
|
inner: D,
|
||||||
downsampler: Downsampler48to16,
|
downsampler: Downsampler48to16,
|
||||||
@@ -108,39 +113,12 @@ pub fn process_i16_10ms(detector: &mut dyn VoiceActivityDetector, samples: &[i16
|
|||||||
detector.process_10ms(&frame)
|
detector.process_10ms(&frame)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Callback-side policy for optional model-backed VAD workers.
|
|
||||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
|
||||||
pub(crate) enum VadWorkerPolicy {
|
|
||||||
/// Keep using the already-available model worker.
|
|
||||||
UseWorker,
|
|
||||||
/// No worker may be constructed on the callback thread; use WebRTC fallback.
|
|
||||||
UseFallback,
|
|
||||||
/// This backend does not need a model worker.
|
|
||||||
NotModelBacked,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decide whether a realtime callback may use a model-backed VAD worker.
|
|
||||||
///
|
|
||||||
/// Model/worker construction is intentionally absent from this policy: if a
|
|
||||||
/// worker is not already present, callbacks must stay nonblocking and fall back.
|
|
||||||
pub(crate) fn callback_vad_worker_policy(
|
|
||||||
voice_activity_mode: bool,
|
|
||||||
backend: VadBackend,
|
|
||||||
worker_available: bool,
|
|
||||||
) -> VadWorkerPolicy {
|
|
||||||
if !voice_activity_mode || backend != VadBackend::SileroOnnx {
|
|
||||||
return VadWorkerPolicy::NotModelBacked;
|
|
||||||
}
|
|
||||||
if worker_available {
|
|
||||||
VadWorkerPolicy::UseWorker
|
|
||||||
} else {
|
|
||||||
VadWorkerPolicy::UseFallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static SILERO_MODEL_PATH_OVERRIDE: OnceLock<RwLock<Option<String>>> = OnceLock::new();
|
static SILERO_MODEL_PATH_OVERRIDE: OnceLock<RwLock<Option<String>>> = OnceLock::new();
|
||||||
static SILERO_MODEL_EPOCH: AtomicU64 = AtomicU64::new(0);
|
static SILERO_MODEL_EPOCH: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) static SILERO_MODEL_PATH_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||||
|
|
||||||
fn silero_model_path_override() -> &'static RwLock<Option<String>> {
|
fn silero_model_path_override() -> &'static RwLock<Option<String>> {
|
||||||
SILERO_MODEL_PATH_OVERRIDE.get_or_init(|| RwLock::new(None))
|
SILERO_MODEL_PATH_OVERRIDE.get_or_init(|| RwLock::new(None))
|
||||||
}
|
}
|
||||||
@@ -175,6 +153,19 @@ pub fn silero_model_epoch() -> u64 {
|
|||||||
SILERO_MODEL_EPOCH.load(Ordering::Relaxed)
|
SILERO_MODEL_EPOCH.load(Ordering::Relaxed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn clear_silero_model_path_for_test() {
|
||||||
|
set_silero_model_path_for_test(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn set_silero_model_path_for_test(path: Option<String>) {
|
||||||
|
if let Ok(mut guard) = silero_model_path_override().write() {
|
||||||
|
*guard = path;
|
||||||
|
SILERO_MODEL_EPOCH.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Return the expected path of the Silero VAD v6 ONNX model on
|
/// Return the expected path of the Silero VAD v6 ONNX model on
|
||||||
/// supported platforms.
|
/// supported platforms.
|
||||||
/// The model is shipped as a Flutter asset and copied to the app's
|
/// The model is shipped as a Flutter asset and copied to the app's
|
||||||
@@ -264,13 +255,20 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn set_silero_model_path_rejects_missing_file() {
|
fn set_silero_model_path_rejects_missing_file() {
|
||||||
|
let _guard = SILERO_MODEL_PATH_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
clear_silero_model_path_for_test();
|
||||||
|
|
||||||
let result = set_silero_model_path("/definitely/not/a/silero_vad.onnx");
|
let result = set_silero_model_path("/definitely/not/a/silero_vad.onnx");
|
||||||
|
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
|
clear_silero_model_path_for_test();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn set_silero_model_path_updates_override_and_epoch() {
|
fn set_silero_model_path_updates_override_and_epoch() {
|
||||||
|
let _guard = SILERO_MODEL_PATH_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
clear_silero_model_path_for_test();
|
||||||
|
|
||||||
let path =
|
let path =
|
||||||
std::env::temp_dir().join(format!("chanora_test_silero_{}.onnx", std::process::id()));
|
std::env::temp_dir().join(format!("chanora_test_silero_{}.onnx", std::process::id()));
|
||||||
std::fs::write(&path, b"test").unwrap();
|
std::fs::write(&path, b"test").unwrap();
|
||||||
@@ -280,34 +278,7 @@ mod tests {
|
|||||||
|
|
||||||
assert!(silero_model_epoch() > before);
|
assert!(silero_model_epoch() > before);
|
||||||
assert_eq!(silero_model_bundle_path(), path.to_string_lossy());
|
assert_eq!(silero_model_bundle_path(), path.to_string_lossy());
|
||||||
|
clear_silero_model_path_for_test();
|
||||||
let _ = std::fs::remove_file(path);
|
let _ = std::fs::remove_file(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn callback_policy_uses_existing_model_worker_only() {
|
|
||||||
assert_eq!(
|
|
||||||
callback_vad_worker_policy(true, VadBackend::SileroOnnx, true),
|
|
||||||
VadWorkerPolicy::UseWorker
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
callback_vad_worker_policy(true, VadBackend::SileroOnnx, false),
|
|
||||||
VadWorkerPolicy::UseFallback
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn callback_policy_keeps_disabled_and_webrtc_paths_worker_free() {
|
|
||||||
assert_eq!(
|
|
||||||
callback_vad_worker_policy(false, VadBackend::SileroOnnx, false),
|
|
||||||
VadWorkerPolicy::NotModelBacked
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
callback_vad_worker_policy(true, VadBackend::Disabled, false),
|
|
||||||
VadWorkerPolicy::NotModelBacked
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
callback_vad_worker_policy(true, VadBackend::WebrtcVad, false),
|
|
||||||
VadWorkerPolicy::NotModelBacked
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -361,6 +361,31 @@ impl SileroOnnxVadWorker {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn stale_test_worker() -> Self {
|
||||||
|
let (tx, rx) = std::sync::mpsc::sync_channel::<SileroFrameMessage>(64);
|
||||||
|
let alive = Arc::new(AtomicBool::new(true));
|
||||||
|
let alive_for_thread = alive.clone();
|
||||||
|
let handle = std::thread::Builder::new()
|
||||||
|
.name("chanora-silero-vad-stale-test".to_string())
|
||||||
|
.spawn(move || {
|
||||||
|
while alive_for_thread.load(Ordering::Relaxed) {
|
||||||
|
match rx.recv_timeout(std::time::Duration::from_millis(10)) {
|
||||||
|
Ok(_) | Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
|
||||||
|
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
Self {
|
||||||
|
tx: Some(tx),
|
||||||
|
latest_probability: Arc::new(AtomicU32::new(0.0_f32.to_bits())),
|
||||||
|
latest_processed_seq: Arc::new(AtomicU64::new(u64::MAX)),
|
||||||
|
alive,
|
||||||
|
handle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Best-effort enqueue of a 10 ms frame for background inference.
|
/// Best-effort enqueue of a 10 ms frame for background inference.
|
||||||
pub fn try_send(&self, seq: u64, frame: &[f32; super::resampler::INPUT_FRAME_10MS]) -> bool {
|
pub fn try_send(&self, seq: u64, frame: &[f32; super::resampler::INPUT_FRAME_10MS]) -> bool {
|
||||||
let Some(tx) = &self.tx else {
|
let Some(tx) = &self.tx else {
|
||||||
@@ -393,8 +418,15 @@ impl SileroOnnxVadWorker {
|
|||||||
impl Drop for SileroOnnxVadWorker {
|
impl Drop for SileroOnnxVadWorker {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
self.alive.store(false, Ordering::Relaxed);
|
self.alive.store(false, Ordering::Relaxed);
|
||||||
|
// Drop the sender first so the worker thread's rx.recv() returns
|
||||||
|
// Err and the loop exits promptly.
|
||||||
let _ = self.tx.take();
|
let _ = self.tx.take();
|
||||||
let _ = self.handle.take();
|
// Join the thread instead of detaching. The channel close
|
||||||
|
// unblocks rx.recv() so the join is bounded; it waits at most
|
||||||
|
// until the current in-flight inference completes.
|
||||||
|
if let Some(handle) = self.handle.take() {
|
||||||
|
let _ = handle.join();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
//! Poisoned-mutex survival test for audio callbacks (TODO-016).
|
||||||
|
//!
|
||||||
|
//! Verifies that the `unwrap_or_else(|e| e.into_inner())` recovery
|
||||||
|
//! pattern used throughout chanora_audio produces usable values
|
||||||
|
//! rather than panicking when a mutex is poisoned.
|
||||||
|
|
||||||
|
use std::panic;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
/// Simulates a simple config guard behind a mutex, matching the
|
||||||
|
/// pattern used by `audio_processing_config` in the engine.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
struct DummyConfig {
|
||||||
|
gain: f32,
|
||||||
|
muted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DummyConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
gain: 1.0,
|
||||||
|
muted: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poisons a `Mutex<T>` by panicking while holding its lock,
|
||||||
|
/// then catches the panic so the test can continue.
|
||||||
|
fn poison_mutex<T: Default>(mx: &Mutex<T>) {
|
||||||
|
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||||
|
let _guard = mx.lock().unwrap();
|
||||||
|
panic!("deliberate poison");
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper that mirrors the exact recovery pattern used in production:
|
||||||
|
/// `lock().unwrap_or_else(|e| e.into_inner())`.
|
||||||
|
fn recover<T>(mx: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||||
|
mx.lock().unwrap_or_else(|e| e.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test 1: Basic poison recovery returns the inner value.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn poison_recovery_returns_inner_value() {
|
||||||
|
let mx = Mutex::new(DummyConfig::default());
|
||||||
|
{
|
||||||
|
let mut g = mx.lock().unwrap();
|
||||||
|
g.gain = 0.5;
|
||||||
|
g.muted = true;
|
||||||
|
}
|
||||||
|
poison_mutex(&mx);
|
||||||
|
assert!(mx.is_poisoned());
|
||||||
|
|
||||||
|
let guard = recover(&mx);
|
||||||
|
assert_eq!(guard.gain, 0.5);
|
||||||
|
assert!(guard.muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test 2: Recovered guard is mutable and usable (simulates an audio
|
||||||
|
// callback writing silence to the output buffer after recovery).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovered_guard_is_mutable() {
|
||||||
|
let mx: Mutex<Vec<f32>> = Mutex::new(vec![0.0; 256]);
|
||||||
|
{
|
||||||
|
let mut g = mx.lock().unwrap();
|
||||||
|
g.fill(0.75);
|
||||||
|
}
|
||||||
|
poison_mutex(&mx);
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut guard = recover(&mx);
|
||||||
|
guard.fill(0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let guard = recover(&mx);
|
||||||
|
assert!(guard.iter().all(|&s| s == 0.0), "expected silence after recovery");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test 3: Multi-lock scenario — recover from a poisoned mutex, mutate
|
||||||
|
// it, and verify subsequent reads see the updated state.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovered_state_persists_across_locks() {
|
||||||
|
let mx = Mutex::new(42u32);
|
||||||
|
poison_mutex(&mx);
|
||||||
|
|
||||||
|
*recover(&mx) = 99;
|
||||||
|
|
||||||
|
assert_eq!(*recover(&mx), 99);
|
||||||
|
assert!(mx.is_poisoned(), "mutex stays poisoned but remains usable");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test 4: Arc<Mutex<T>> pattern — mirrors the audio engine's shared
|
||||||
|
// state where multiple callbacks hold Arc clones.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shared_arc_mutex_recovery() {
|
||||||
|
let mx = Arc::new(Mutex::new(DummyConfig::default()));
|
||||||
|
{
|
||||||
|
let mut g = mx.lock().unwrap();
|
||||||
|
g.gain = 0.8;
|
||||||
|
}
|
||||||
|
poison_mutex(&mx);
|
||||||
|
|
||||||
|
let mx2 = Arc::clone(&mx);
|
||||||
|
let guard = mx2.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
assert_eq!(guard.gain, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test 5: Snapshot-then-clone pattern — mirrors the engine's
|
||||||
|
// `audio_processing_config_snapshot()` which clones through the guard.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_clone_through_poisoned_mutex() {
|
||||||
|
let mx = Mutex::new(DummyConfig {
|
||||||
|
gain: 0.42,
|
||||||
|
muted: true,
|
||||||
|
});
|
||||||
|
poison_mutex(&mx);
|
||||||
|
|
||||||
|
let snapshot = mx.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||||
|
assert_eq!(snapshot.gain, 0.42);
|
||||||
|
assert!(snapshot.muted);
|
||||||
|
|
||||||
|
let snapshot2 = mx.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||||
|
assert_eq!(snapshot, snapshot2);
|
||||||
|
}
|
||||||
@@ -82,7 +82,7 @@ struct RecordingLayer {
|
|||||||
|
|
||||||
impl RecordingLayer {
|
impl RecordingLayer {
|
||||||
fn snapshot(&self) -> Vec<Captured> {
|
fn snapshot(&self) -> Vec<Captured> {
|
||||||
self.records.lock().unwrap().clone()
|
self.records.lock().unwrap_or_else(|e| e.into_inner()).clone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +118,7 @@ where
|
|||||||
target: event.metadata().target().to_string(),
|
target: event.metadata().target().to_string(),
|
||||||
field_names: names.0,
|
field_names: names.0,
|
||||||
};
|
};
|
||||||
self.records.lock().unwrap().push(captured);
|
self.records.lock().unwrap_or_else(|e| e.into_inner()).push(captured);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,8 +196,8 @@ fn windows_exercise() {
|
|||||||
// start/stop lifecycle through the public trait surface so
|
// start/stop lifecycle through the public trait surface so
|
||||||
// any info!/warn! the factory or the backend's `start` path
|
// any info!/warn! the factory or the backend's `start` path
|
||||||
// emits is captured by the layer.
|
// emits is captured by the layer.
|
||||||
use chanora_audio::ptt_backends::{select_ptt_backend, PttBinding, PttInputClass};
|
use chanora_audio::ptt_backends::{PttBinding, PttInputClass};
|
||||||
use chanora_audio::AudioTransmitGate;
|
use chanora_audio::{select_ptt_backend, AudioTransmitGate};
|
||||||
|
|
||||||
let mut backend = select_ptt_backend();
|
let mut backend = select_ptt_backend();
|
||||||
let gate = AudioTransmitGate::new(false);
|
let gate = AudioTransmitGate::new(false);
|
||||||
|
|||||||
@@ -36,3 +36,6 @@ ndk-context = "0.1"
|
|||||||
|
|
||||||
[lints.rust]
|
[lints.rust]
|
||||||
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(frb_expand)'] }
|
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(frb_expand)'] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json = "1"
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# chanora_bridge
|
||||||
|
|
||||||
|
Typed Flutter/Rust bridge — schema-controlled DTOs for commands, results, and events. Backed by `flutter_rust_bridge` 2.x per DEC-014.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **`api` module** — all public functions exposed to Dart. Each function runs on a shared tokio runtime and delegates to `chanora_core::ChanoraSession`. Input/output types are owned primitives or `String`s — no backend types cross the boundary (SAD-067, SDD-079).
|
||||||
|
- **`frb_generated`** — auto-generated `flutter_rust_bridge` glue. Contains `unsafe` for the FFI boundary; hand-written code must not use `unsafe`.
|
||||||
|
- **`android_init`** (Android only) — NDK context initialization
|
||||||
|
- **`permission_jni`** (Android only) — JNI hook for Android permission state changes (SDD-106)
|
||||||
|
|
||||||
|
### DTO pattern
|
||||||
|
|
||||||
|
Every Dart-facing type is a `Bridge*` DTO with primitive fields. `From` impls convert between bridge DTOs and `chanora_core` types. Most types do not carry `serde` derives — FRB generates its own SSE encoders/decoders.
|
||||||
|
|
||||||
|
### Event streaming
|
||||||
|
|
||||||
|
`BridgeEvent` enum is streamed to Dart via FRB's `StreamSink`. Events include: Connected, Disconnected, Lost, Reconnecting, ChatMessage, VoiceState, AudioStarted/Stopped, ClientJoined/Left/Moved/Updated, ChannelAdded/Removed/Updated, PttCapability, PermissionState, ServerActivity, InterruptionState, AudioRouteChanged.
|
||||||
|
|
||||||
|
## Public API Summary
|
||||||
|
|
||||||
|
### Commands (api.rs)
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---|---|
|
||||||
|
| `bridge_init()` | One-time init: logging, panic hook |
|
||||||
|
| `connect(host, nickname, password)` | Connect to a server |
|
||||||
|
| `disconnect()` | Clean disconnect |
|
||||||
|
| `snapshot()` | Refresh server state |
|
||||||
|
| `client_profile(client_id)` | Rich profile for one client |
|
||||||
|
| `is_connected()` | Connection check |
|
||||||
|
| `prefetch_server(host)` | Warm server resolution |
|
||||||
|
| `voice_join(channel_id, password)` | Join voice channel |
|
||||||
|
| `voice_leave()` | Leave voice channel |
|
||||||
|
| `set_transmit_mode(mode)` | Ptt/Continuous/VoiceActivity |
|
||||||
|
| `get_transmit_mode()` | Read current mode |
|
||||||
|
| `set_hard_mute(muted)` | Hard-mute clamp |
|
||||||
|
| `set_ptt(active)` | Manual PTT press/release |
|
||||||
|
| `set_ptt_binding(input_class, platform_key)` | Bind a PTT key |
|
||||||
|
| `ptt_descriptor()` | Current PTT capability |
|
||||||
|
| `get_ptt_binding()` | Persisted PTT binding |
|
||||||
|
| `set_release_tail_ms(ms)` | Release-tail config |
|
||||||
|
| `get_release_tail_ms()` | Read release-tail |
|
||||||
|
| `move_to_channel(channel_id, password)` | Move to a channel |
|
||||||
|
| `set_input_muted(muted)` / `set_output_muted(muted)` | Server-side mute |
|
||||||
|
| `set_output_gain(gain)` | Master volume |
|
||||||
|
| `set_client_volume(client_id, volume)` | Per-client volume |
|
||||||
|
| `send_chat_message(message, target)` | Send text |
|
||||||
|
| `set_audio_processing_config(config)` | P1 audio processing |
|
||||||
|
| `get_audio_processing_config()` | Read P1 config |
|
||||||
|
| `audio_processing_stats()` | P1 telemetry |
|
||||||
|
| `enable_audio_debug_wav_dump(enabled)` | WAV dump toggle |
|
||||||
|
| `set_vad_model_path(path)` | Silero model path |
|
||||||
|
| `set_input_device(id)` / `set_output_device(id)` | Device selection |
|
||||||
|
| `export_diagnostics()` | Redacted export bundle (includes network stats) |
|
||||||
|
| `audio_stats()` | Audio subsystem statistics |
|
||||||
|
| `input_level_stream()` | Mic level metering stream |
|
||||||
|
| `events_stream()` | Bridge event stream |
|
||||||
|
| `log_file_path_str()` | Log file path for platform |
|
||||||
|
| `init_storage()` / `init_cache()` | Storage/cache initialization |
|
||||||
|
| `set_ios_voice_processing_mode(mode)` | iOS audio processing mode |
|
||||||
|
| `set_audio_output_route(route)` | Audio output route selection |
|
||||||
|
| `list_audio_devices()` | Enumerate audio devices |
|
||||||
|
| `list_bookmarks()` / `add_bookmark` / `update_bookmark` / `delete_bookmark` | Bookmark CRUD |
|
||||||
|
| `download_avatar(hash, uid)` / `download_icon(id)` | Avatar/icon download |
|
||||||
|
| `clear_file_cache()` / `file_cache_size()` | Cache management |
|
||||||
|
| `handle_route_change(route)` | iOS audio route change |
|
||||||
|
| `handle_media_services_reset_with_route(route_class)` | iOS media reset |
|
||||||
|
| `handle_interruption_began()` / `handle_interruption_ended(should_resume)` | iOS interruption |
|
||||||
|
| `lifecycle_event(state)` | Platform lifecycle |
|
||||||
|
|
||||||
|
### Bridge DTOs
|
||||||
|
|
||||||
|
`BridgeSnapshot`, `BridgeChannel`, `BridgeClient`, `BridgeClientProfile`, `BridgeAudioStats`, `BridgeAudioProcessingConfig`, `BridgeAudioProcessingStats`, `BridgeAudioRoute`, `BridgeTransmitMode`, `BridgePttInputClass`, `BridgePttDescriptor`, `BridgePttBinding`, `BridgeMessageTarget`, `BridgeBookmark`, `PermissionStateKind`, `BridgeError`.
|
||||||
|
|
||||||
|
## Platform notes
|
||||||
|
|
||||||
|
- Cannot use `#![forbid(unsafe_code)]` because FRB-generated glue legitimately uses `unsafe` for the FFI boundary.
|
||||||
|
- Android: includes `android_init` and `permission_jni` modules gated behind `cfg(target_os = "android")`.
|
||||||
|
- iOS: route-change and interruption handlers are synchronous (`#[frb(sync)]`), dispatched to the tokio runtime via an ordered channel.
|
||||||
@@ -603,6 +603,8 @@ pub async fn connect(
|
|||||||
nickname: String,
|
nickname: String,
|
||||||
password: String,
|
password: String,
|
||||||
) -> Result<BridgeSnapshot, BridgeError> {
|
) -> Result<BridgeSnapshot, BridgeError> {
|
||||||
|
let nickname = chanora_core::validate_nickname(&nickname)
|
||||||
|
.map_err(|e| BridgeError::InvalidCommand(e.to_string()))?;
|
||||||
let cfg = chanora_core::ConnectConfig {
|
let cfg = chanora_core::ConnectConfig {
|
||||||
address: host,
|
address: host,
|
||||||
nickname,
|
nickname,
|
||||||
@@ -837,6 +839,15 @@ pub async fn set_hard_mute(muted: bool) -> Result<(), BridgeError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read the current hard-mute state. Returns `true` when the audio
|
||||||
|
/// engine is clamped and transmits nothing regardless of mode.
|
||||||
|
pub async fn is_hard_muted() -> bool {
|
||||||
|
runtime()
|
||||||
|
.spawn(async { session().hard_mute() })
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
/// Coarse PTT input class (gen2 v0.9.3 / DEC-026). Stable strings;
|
/// Coarse PTT input class (gen2 v0.9.3 / DEC-026). Stable strings;
|
||||||
/// the bridge never carries raw key codes.
|
/// the bridge never carries raw key codes.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
@@ -999,6 +1010,15 @@ pub async fn set_output_gain(gain: f32) -> Result<(), BridgeError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read the current master output gain. Returns `1.0` (unity) when
|
||||||
|
/// audio is not started.
|
||||||
|
pub async fn get_output_gain() -> f32 {
|
||||||
|
runtime()
|
||||||
|
.spawn(async { session().output_gain().await })
|
||||||
|
.await
|
||||||
|
.unwrap_or(1.0)
|
||||||
|
}
|
||||||
|
|
||||||
/// Set per-client output volume (SRS-075). `1.0` is unity, `0.0`
|
/// Set per-client output volume (SRS-075). `1.0` is unity, `0.0`
|
||||||
/// mutes. No-op when client has no active voice queue. Volume is
|
/// mutes. No-op when client has no active voice queue. Volume is
|
||||||
/// applied directly to the tsclientlib AudioQueue and takes effect
|
/// applied directly to the tsclientlib AudioQueue and takes effect
|
||||||
@@ -1062,10 +1082,8 @@ pub enum BridgeAudioRoute {
|
|||||||
/// Bridge iOS voice-processing mode.
|
/// Bridge iOS voice-processing mode.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum BridgeIosVoiceProcessingMode {
|
pub enum BridgeIosVoiceProcessingMode {
|
||||||
/// Shipping VPIO path.
|
/// Apple VoiceProcessingIO path.
|
||||||
PlatformVoiceProcessing,
|
PlatformVoiceProcessing,
|
||||||
/// Experimental Sonora path.
|
|
||||||
SonoraExperimental,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bridge processing backend.
|
/// Bridge processing backend.
|
||||||
@@ -1223,7 +1241,6 @@ impl From<BridgeIosVoiceProcessingMode> for chanora_core::IosVoiceProcessingMode
|
|||||||
fn from(mode: BridgeIosVoiceProcessingMode) -> Self {
|
fn from(mode: BridgeIosVoiceProcessingMode) -> Self {
|
||||||
match mode {
|
match mode {
|
||||||
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => Self::PlatformVoiceProcessing,
|
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => Self::PlatformVoiceProcessing,
|
||||||
BridgeIosVoiceProcessingMode::SonoraExperimental => Self::SonoraExperimental,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1234,7 +1251,6 @@ impl From<chanora_core::IosVoiceProcessingMode> for BridgeIosVoiceProcessingMode
|
|||||||
chanora_core::IosVoiceProcessingMode::PlatformVoiceProcessing => {
|
chanora_core::IosVoiceProcessingMode::PlatformVoiceProcessing => {
|
||||||
Self::PlatformVoiceProcessing
|
Self::PlatformVoiceProcessing
|
||||||
}
|
}
|
||||||
chanora_core::IosVoiceProcessingMode::SonoraExperimental => Self::SonoraExperimental,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1469,6 +1485,54 @@ pub async fn init_storage(dir: String) -> Result<(), BridgeError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Configure the bridge blob cache root.
|
||||||
|
pub async fn init_cache(dir: String) -> Result<(), BridgeError> {
|
||||||
|
runtime()
|
||||||
|
.spawn(async move { session().init_cache(&dir).await })
|
||||||
|
.await
|
||||||
|
.map_err(|e| task_join_error("init_cache", e))??;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve avatar bytes through the bridge.
|
||||||
|
pub async fn download_avatar(
|
||||||
|
avatar_hash: String,
|
||||||
|
client_uid: String,
|
||||||
|
) -> Result<Option<Vec<u8>>, BridgeError> {
|
||||||
|
runtime()
|
||||||
|
.spawn(async move { session().get_avatar(&avatar_hash, &client_uid).await })
|
||||||
|
.await
|
||||||
|
.map_err(|e| task_join_error("download_avatar", e))?
|
||||||
|
.map_err(BridgeError::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve icon bytes through the bridge.
|
||||||
|
pub async fn download_icon(icon_id: u64) -> Result<Option<Vec<u8>>, BridgeError> {
|
||||||
|
runtime()
|
||||||
|
.spawn(async move { session().get_icon(icon_id).await })
|
||||||
|
.await
|
||||||
|
.map_err(|e| task_join_error("download_icon", e))?
|
||||||
|
.map_err(BridgeError::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Purge cached protocol-owned assets.
|
||||||
|
pub async fn clear_file_cache() -> Result<(), BridgeError> {
|
||||||
|
runtime()
|
||||||
|
.spawn(async move { session().clear_cache().await })
|
||||||
|
.await
|
||||||
|
.map_err(|e| task_join_error("clear_file_cache", e))??;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Report the configured file-cache size.
|
||||||
|
pub async fn file_cache_size() -> Result<u64, BridgeError> {
|
||||||
|
runtime()
|
||||||
|
.spawn(async move { session().cache_size().await })
|
||||||
|
.await
|
||||||
|
.map_err(|e| task_join_error("file_cache_size", e))?
|
||||||
|
.map_err(BridgeError::from)
|
||||||
|
}
|
||||||
|
|
||||||
/// Bookmark DTO mirroring [`chanora_core::Bookmark`].
|
/// Bookmark DTO mirroring [`chanora_core::Bookmark`].
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct BridgeBookmark {
|
pub struct BridgeBookmark {
|
||||||
@@ -2350,7 +2414,7 @@ pub async fn enable_audio_debug_wav_dump(enabled: bool) -> Result<(), BridgeErro
|
|||||||
.spawn(async move { session().set_audio_debug_wav_dump(enabled).await })
|
.spawn(async move { session().set_audio_debug_wav_dump(enabled).await })
|
||||||
.await
|
.await
|
||||||
.map_err(|e| task_join_error("enable_audio_debug_wav_dump", e))?
|
.map_err(|e| task_join_error("enable_audio_debug_wav_dump", e))?
|
||||||
.map_err(|e| BridgeError::Unmapped(format!("enable_audio_debug_wav_dump: {e}")))?;
|
.map_err(|e| BridgeError::unmapped_ctx("enable_audio_debug_wav_dump", e))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2361,25 +2425,11 @@ pub async fn set_ios_voice_processing_mode(
|
|||||||
let config = BridgeAudioProcessingConfig {
|
let config = BridgeAudioProcessingConfig {
|
||||||
route: BridgeAudioRoute::Speaker,
|
route: BridgeAudioRoute::Speaker,
|
||||||
ios_mode: mode,
|
ios_mode: mode,
|
||||||
processing_backend: match mode {
|
processing_backend: BridgeAudioBackend::PlatformVoiceProcessing,
|
||||||
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => {
|
|
||||||
BridgeAudioBackend::PlatformVoiceProcessing
|
|
||||||
}
|
|
||||||
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeAudioBackend::WebrtcApm,
|
|
||||||
},
|
|
||||||
vad_backend: BridgeVadBackend::SileroOnnx,
|
vad_backend: BridgeVadBackend::SileroOnnx,
|
||||||
aec: match mode {
|
aec: BridgeEffectOwner::Platform,
|
||||||
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => BridgeEffectOwner::Platform,
|
ns: BridgeEffectOwner::Platform,
|
||||||
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeEffectOwner::WebrtcApm,
|
agc: BridgeEffectOwner::Platform,
|
||||||
},
|
|
||||||
ns: match mode {
|
|
||||||
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => BridgeEffectOwner::Platform,
|
|
||||||
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeEffectOwner::WebrtcApm,
|
|
||||||
},
|
|
||||||
agc: match mode {
|
|
||||||
BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => BridgeEffectOwner::Platform,
|
|
||||||
BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeEffectOwner::WebrtcApm,
|
|
||||||
},
|
|
||||||
hpf_enabled: true,
|
hpf_enabled: true,
|
||||||
limiter_enabled: true,
|
limiter_enabled: true,
|
||||||
vad_hangover_ms: 500,
|
vad_hangover_ms: 500,
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
|
|||||||
default_rust_auto_opaque = RustAutoOpaqueMoi,
|
default_rust_auto_opaque = RustAutoOpaqueMoi,
|
||||||
);
|
);
|
||||||
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
|
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
|
||||||
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -20394775;
|
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 635684021;
|
||||||
|
|
||||||
// Section: executor
|
// Section: executor
|
||||||
|
|
||||||
@@ -186,6 +186,41 @@ fn wire__crate__api__bridge_init_impl(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
fn wire__crate__api__clear_file_cache_impl(
|
||||||
|
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||||
|
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||||
|
rust_vec_len_: i32,
|
||||||
|
data_len_: i32,
|
||||||
|
) {
|
||||||
|
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
|
||||||
|
flutter_rust_bridge::for_generated::TaskInfo {
|
||||||
|
debug_name: "clear_file_cache",
|
||||||
|
port: Some(port_),
|
||||||
|
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
|
||||||
|
},
|
||||||
|
move || {
|
||||||
|
let message = unsafe {
|
||||||
|
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
|
||||||
|
ptr_,
|
||||||
|
rust_vec_len_,
|
||||||
|
data_len_,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let mut deserializer =
|
||||||
|
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
|
||||||
|
deserializer.end();
|
||||||
|
move |context| async move {
|
||||||
|
transform_result_sse::<_, crate::BridgeError>(
|
||||||
|
(move || async move {
|
||||||
|
let output_ok = crate::api::clear_file_cache().await?;
|
||||||
|
Ok(output_ok)
|
||||||
|
})()
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
fn wire__crate__api__client_profile_impl(
|
fn wire__crate__api__client_profile_impl(
|
||||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||||
@@ -332,6 +367,80 @@ fn wire__crate__api__disconnect_impl(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
fn wire__crate__api__download_avatar_impl(
|
||||||
|
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||||
|
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||||
|
rust_vec_len_: i32,
|
||||||
|
data_len_: i32,
|
||||||
|
) {
|
||||||
|
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
|
||||||
|
flutter_rust_bridge::for_generated::TaskInfo {
|
||||||
|
debug_name: "download_avatar",
|
||||||
|
port: Some(port_),
|
||||||
|
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
|
||||||
|
},
|
||||||
|
move || {
|
||||||
|
let message = unsafe {
|
||||||
|
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
|
||||||
|
ptr_,
|
||||||
|
rust_vec_len_,
|
||||||
|
data_len_,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let mut deserializer =
|
||||||
|
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
|
||||||
|
let api_avatar_hash = <String>::sse_decode(&mut deserializer);
|
||||||
|
let api_client_uid = <String>::sse_decode(&mut deserializer);
|
||||||
|
deserializer.end();
|
||||||
|
move |context| async move {
|
||||||
|
transform_result_sse::<_, crate::BridgeError>(
|
||||||
|
(move || async move {
|
||||||
|
let output_ok =
|
||||||
|
crate::api::download_avatar(api_avatar_hash, api_client_uid).await?;
|
||||||
|
Ok(output_ok)
|
||||||
|
})()
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fn wire__crate__api__download_icon_impl(
|
||||||
|
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||||
|
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||||
|
rust_vec_len_: i32,
|
||||||
|
data_len_: i32,
|
||||||
|
) {
|
||||||
|
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
|
||||||
|
flutter_rust_bridge::for_generated::TaskInfo {
|
||||||
|
debug_name: "download_icon",
|
||||||
|
port: Some(port_),
|
||||||
|
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
|
||||||
|
},
|
||||||
|
move || {
|
||||||
|
let message = unsafe {
|
||||||
|
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
|
||||||
|
ptr_,
|
||||||
|
rust_vec_len_,
|
||||||
|
data_len_,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let mut deserializer =
|
||||||
|
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
|
||||||
|
let api_icon_id = <u64>::sse_decode(&mut deserializer);
|
||||||
|
deserializer.end();
|
||||||
|
move |context| async move {
|
||||||
|
transform_result_sse::<_, crate::BridgeError>(
|
||||||
|
(move || async move {
|
||||||
|
let output_ok = crate::api::download_icon(api_icon_id).await?;
|
||||||
|
Ok(output_ok)
|
||||||
|
})()
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
fn wire__crate__api__enable_audio_debug_wav_dump_impl(
|
fn wire__crate__api__enable_audio_debug_wav_dump_impl(
|
||||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||||
@@ -434,6 +543,41 @@ fn wire__crate__api__export_diagnostics_impl(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
fn wire__crate__api__file_cache_size_impl(
|
||||||
|
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||||
|
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||||
|
rust_vec_len_: i32,
|
||||||
|
data_len_: i32,
|
||||||
|
) {
|
||||||
|
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
|
||||||
|
flutter_rust_bridge::for_generated::TaskInfo {
|
||||||
|
debug_name: "file_cache_size",
|
||||||
|
port: Some(port_),
|
||||||
|
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
|
||||||
|
},
|
||||||
|
move || {
|
||||||
|
let message = unsafe {
|
||||||
|
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
|
||||||
|
ptr_,
|
||||||
|
rust_vec_len_,
|
||||||
|
data_len_,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let mut deserializer =
|
||||||
|
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
|
||||||
|
deserializer.end();
|
||||||
|
move |context| async move {
|
||||||
|
transform_result_sse::<_, crate::BridgeError>(
|
||||||
|
(move || async move {
|
||||||
|
let output_ok = crate::api::file_cache_size().await?;
|
||||||
|
Ok(output_ok)
|
||||||
|
})()
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
fn wire__crate__api__get_audio_processing_config_impl(
|
fn wire__crate__api__get_audio_processing_config_impl(
|
||||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||||
@@ -702,6 +846,42 @@ fn wire__crate__api__handle_route_change_impl(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
fn wire__crate__api__init_cache_impl(
|
||||||
|
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||||
|
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||||
|
rust_vec_len_: i32,
|
||||||
|
data_len_: i32,
|
||||||
|
) {
|
||||||
|
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
|
||||||
|
flutter_rust_bridge::for_generated::TaskInfo {
|
||||||
|
debug_name: "init_cache",
|
||||||
|
port: Some(port_),
|
||||||
|
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
|
||||||
|
},
|
||||||
|
move || {
|
||||||
|
let message = unsafe {
|
||||||
|
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
|
||||||
|
ptr_,
|
||||||
|
rust_vec_len_,
|
||||||
|
data_len_,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let mut deserializer =
|
||||||
|
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
|
||||||
|
let api_dir = <String>::sse_decode(&mut deserializer);
|
||||||
|
deserializer.end();
|
||||||
|
move |context| async move {
|
||||||
|
transform_result_sse::<_, crate::BridgeError>(
|
||||||
|
(move || async move {
|
||||||
|
let output_ok = crate::api::init_cache(api_dir).await?;
|
||||||
|
Ok(output_ok)
|
||||||
|
})()
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
fn wire__crate__api__init_storage_impl(
|
fn wire__crate__api__init_storage_impl(
|
||||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||||
@@ -2409,7 +2589,6 @@ impl SseDecode for crate::api::BridgeIosVoiceProcessingMode {
|
|||||||
let mut inner = <i32>::sse_decode(deserializer);
|
let mut inner = <i32>::sse_decode(deserializer);
|
||||||
return match inner {
|
return match inner {
|
||||||
0 => crate::api::BridgeIosVoiceProcessingMode::PlatformVoiceProcessing,
|
0 => crate::api::BridgeIosVoiceProcessingMode::PlatformVoiceProcessing,
|
||||||
1 => crate::api::BridgeIosVoiceProcessingMode::SonoraExperimental,
|
|
||||||
_ => unreachable!(
|
_ => unreachable!(
|
||||||
"Invalid variant for BridgeIosVoiceProcessingMode: {}",
|
"Invalid variant for BridgeIosVoiceProcessingMode: {}",
|
||||||
inner
|
inner
|
||||||
@@ -2764,6 +2943,17 @@ impl SseDecode for Option<u64> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SseDecode for Option<Vec<u8>> {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||||
|
if (<bool>::sse_decode(deserializer)) {
|
||||||
|
return Some(<Vec<u8>>::sse_decode(deserializer));
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl SseDecode for crate::api::PermissionStateKind {
|
impl SseDecode for crate::api::PermissionStateKind {
|
||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||||
@@ -2817,45 +3007,50 @@ fn pde_ffi_dispatcher_primary_impl(
|
|||||||
2 => wire__crate__api__audio_processing_stats_impl(port, ptr, rust_vec_len, data_len),
|
2 => wire__crate__api__audio_processing_stats_impl(port, ptr, rust_vec_len, data_len),
|
||||||
3 => wire__crate__api__audio_stats_impl(port, ptr, rust_vec_len, data_len),
|
3 => wire__crate__api__audio_stats_impl(port, ptr, rust_vec_len, data_len),
|
||||||
4 => wire__crate__api__bridge_init_impl(port, ptr, rust_vec_len, data_len),
|
4 => wire__crate__api__bridge_init_impl(port, ptr, rust_vec_len, data_len),
|
||||||
5 => wire__crate__api__client_profile_impl(port, ptr, rust_vec_len, data_len),
|
5 => wire__crate__api__clear_file_cache_impl(port, ptr, rust_vec_len, data_len),
|
||||||
6 => wire__crate__api__connect_impl(port, ptr, rust_vec_len, data_len),
|
6 => wire__crate__api__client_profile_impl(port, ptr, rust_vec_len, data_len),
|
||||||
7 => wire__crate__api__delete_bookmark_impl(port, ptr, rust_vec_len, data_len),
|
7 => wire__crate__api__connect_impl(port, ptr, rust_vec_len, data_len),
|
||||||
8 => wire__crate__api__disconnect_impl(port, ptr, rust_vec_len, data_len),
|
8 => wire__crate__api__delete_bookmark_impl(port, ptr, rust_vec_len, data_len),
|
||||||
9 => wire__crate__api__enable_audio_debug_wav_dump_impl(port, ptr, rust_vec_len, data_len),
|
9 => wire__crate__api__disconnect_impl(port, ptr, rust_vec_len, data_len),
|
||||||
10 => wire__crate__api__events_stream_impl(port, ptr, rust_vec_len, data_len),
|
10 => wire__crate__api__download_avatar_impl(port, ptr, rust_vec_len, data_len),
|
||||||
12 => wire__crate__api__get_audio_processing_config_impl(port, ptr, rust_vec_len, data_len),
|
11 => wire__crate__api__download_icon_impl(port, ptr, rust_vec_len, data_len),
|
||||||
13 => wire__crate__api__get_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
|
12 => wire__crate__api__enable_audio_debug_wav_dump_impl(port, ptr, rust_vec_len, data_len),
|
||||||
14 => wire__crate__api__get_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
|
13 => wire__crate__api__events_stream_impl(port, ptr, rust_vec_len, data_len),
|
||||||
15 => wire__crate__api__get_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
|
15 => wire__crate__api__file_cache_size_impl(port, ptr, rust_vec_len, data_len),
|
||||||
20 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len),
|
16 => wire__crate__api__get_audio_processing_config_impl(port, ptr, rust_vec_len, data_len),
|
||||||
21 => wire__crate__api__input_level_stream_impl(port, ptr, rust_vec_len, data_len),
|
17 => wire__crate__api__get_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
|
||||||
22 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
|
18 => wire__crate__api__get_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
|
||||||
23 => wire__crate__api__list_audio_devices_impl(port, ptr, rust_vec_len, data_len),
|
19 => wire__crate__api__get_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
|
||||||
24 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len),
|
24 => wire__crate__api__init_cache_impl(port, ptr, rust_vec_len, data_len),
|
||||||
26 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len),
|
25 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len),
|
||||||
27 => wire__crate__api__prefetch_server_impl(port, ptr, rust_vec_len, data_len),
|
26 => wire__crate__api__input_level_stream_impl(port, ptr, rust_vec_len, data_len),
|
||||||
28 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len),
|
27 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
|
||||||
30 => wire__crate__api__send_chat_message_impl(port, ptr, rust_vec_len, data_len),
|
28 => wire__crate__api__list_audio_devices_impl(port, ptr, rust_vec_len, data_len),
|
||||||
32 => wire__crate__api__set_audio_processing_config_impl(port, ptr, rust_vec_len, data_len),
|
29 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len),
|
||||||
33 => wire__crate__api__set_client_volume_impl(port, ptr, rust_vec_len, data_len),
|
31 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len),
|
||||||
34 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len),
|
32 => wire__crate__api__prefetch_server_impl(port, ptr, rust_vec_len, data_len),
|
||||||
35 => wire__crate__api__set_input_device_impl(port, ptr, rust_vec_len, data_len),
|
33 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len),
|
||||||
36 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len),
|
35 => wire__crate__api__send_chat_message_impl(port, ptr, rust_vec_len, data_len),
|
||||||
37 => {
|
37 => wire__crate__api__set_audio_processing_config_impl(port, ptr, rust_vec_len, data_len),
|
||||||
|
38 => wire__crate__api__set_client_volume_impl(port, ptr, rust_vec_len, data_len),
|
||||||
|
39 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len),
|
||||||
|
40 => wire__crate__api__set_input_device_impl(port, ptr, rust_vec_len, data_len),
|
||||||
|
41 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len),
|
||||||
|
42 => {
|
||||||
wire__crate__api__set_ios_voice_processing_mode_impl(port, ptr, rust_vec_len, data_len)
|
wire__crate__api__set_ios_voice_processing_mode_impl(port, ptr, rust_vec_len, data_len)
|
||||||
}
|
}
|
||||||
39 => wire__crate__api__set_output_device_impl(port, ptr, rust_vec_len, data_len),
|
44 => wire__crate__api__set_output_device_impl(port, ptr, rust_vec_len, data_len),
|
||||||
40 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len),
|
45 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len),
|
||||||
41 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len),
|
46 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len),
|
||||||
42 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
|
47 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
|
||||||
43 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
|
48 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
|
||||||
44 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
|
49 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
|
||||||
45 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
|
50 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
|
||||||
46 => wire__crate__api__set_vad_model_path_impl(port, ptr, rust_vec_len, data_len),
|
51 => wire__crate__api__set_vad_model_path_impl(port, ptr, rust_vec_len, data_len),
|
||||||
47 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
|
52 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
|
||||||
48 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len),
|
53 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len),
|
||||||
49 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len),
|
54 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len),
|
||||||
50 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len),
|
55 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len),
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2868,19 +3063,19 @@ fn pde_ffi_dispatcher_sync_impl(
|
|||||||
) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse {
|
) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse {
|
||||||
// Codec=Pde (Serialization + dispatch), see doc to use other codecs
|
// Codec=Pde (Serialization + dispatch), see doc to use other codecs
|
||||||
match func_id {
|
match func_id {
|
||||||
11 => wire__crate__api__export_diagnostics_impl(ptr, rust_vec_len, data_len),
|
14 => wire__crate__api__export_diagnostics_impl(ptr, rust_vec_len, data_len),
|
||||||
16 => wire__crate__api__handle_interruption_began_impl(ptr, rust_vec_len, data_len),
|
20 => wire__crate__api__handle_interruption_began_impl(ptr, rust_vec_len, data_len),
|
||||||
17 => wire__crate__api__handle_interruption_ended_impl(ptr, rust_vec_len, data_len),
|
21 => wire__crate__api__handle_interruption_ended_impl(ptr, rust_vec_len, data_len),
|
||||||
18 => wire__crate__api__handle_media_services_reset_with_route_impl(
|
22 => wire__crate__api__handle_media_services_reset_with_route_impl(
|
||||||
ptr,
|
ptr,
|
||||||
rust_vec_len,
|
rust_vec_len,
|
||||||
data_len,
|
data_len,
|
||||||
),
|
),
|
||||||
19 => wire__crate__api__handle_route_change_impl(ptr, rust_vec_len, data_len),
|
23 => wire__crate__api__handle_route_change_impl(ptr, rust_vec_len, data_len),
|
||||||
25 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len),
|
30 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len),
|
||||||
29 => wire__crate__api__record_lifecycle_event_impl(ptr, rust_vec_len, data_len),
|
34 => wire__crate__api__record_lifecycle_event_impl(ptr, rust_vec_len, data_len),
|
||||||
31 => wire__crate__api__set_audio_output_route_impl(ptr, rust_vec_len, data_len),
|
36 => wire__crate__api__set_audio_output_route_impl(ptr, rust_vec_len, data_len),
|
||||||
38 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
|
43 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3445,7 +3640,6 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeIosVoiceProcessingMode
|
|||||||
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
||||||
match self {
|
match self {
|
||||||
Self::PlatformVoiceProcessing => 0.into_dart(),
|
Self::PlatformVoiceProcessing => 0.into_dart(),
|
||||||
Self::SonoraExperimental => 1.into_dart(),
|
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4215,7 +4409,6 @@ impl SseEncode for crate::api::BridgeIosVoiceProcessingMode {
|
|||||||
<i32>::sse_encode(
|
<i32>::sse_encode(
|
||||||
match self {
|
match self {
|
||||||
crate::api::BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => 0,
|
crate::api::BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => 0,
|
||||||
crate::api::BridgeIosVoiceProcessingMode::SonoraExperimental => 1,
|
|
||||||
_ => {
|
_ => {
|
||||||
unimplemented!("");
|
unimplemented!("");
|
||||||
}
|
}
|
||||||
@@ -4559,6 +4752,16 @@ impl SseEncode for Option<u64> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SseEncode for Option<Vec<u8>> {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||||
|
<bool>::sse_encode(self.is_some(), serializer);
|
||||||
|
if let Some(value) = self {
|
||||||
|
<Vec<u8>>::sse_encode(value, serializer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl SseEncode for crate::api::PermissionStateKind {
|
impl SseEncode for crate::api::PermissionStateKind {
|
||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||||
|
|||||||
@@ -51,11 +51,15 @@ use thiserror::Error;
|
|||||||
/// Errors raised at the bridge boundary. Production code must keep
|
/// Errors raised at the bridge boundary. Production code must keep
|
||||||
/// these user-safe — no secrets, no protocol details, no path
|
/// these user-safe — no secrets, no protocol details, no path
|
||||||
/// information beyond what the redaction policy permits.
|
/// information beyond what the redaction policy permits.
|
||||||
#[derive(Debug, Error, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Error, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
pub enum BridgeError {
|
pub enum BridgeError {
|
||||||
/// The caller submitted a malformed command DTO.
|
/// The caller submitted a malformed command DTO.
|
||||||
#[error("invalid command: {0}")]
|
#[error("invalid command: {0}")]
|
||||||
InvalidCommand(String),
|
InvalidCommand(String),
|
||||||
|
// TODO(refactor): DnsFailed and ServerRejected mirror ProtocolError variants
|
||||||
|
// in chanora_protocol. These cannot be unified without changing the public FFI
|
||||||
|
// API (flutter_rust_bridge generates Dart types from these). Revisit only if
|
||||||
|
// the bridge error types are being reworked.
|
||||||
/// Hostname resolution failed. Distinct from `Connection` so the
|
/// Hostname resolution failed. Distinct from `Connection` so the
|
||||||
/// UI can show a meaningful "Server not found" message.
|
/// UI can show a meaningful "Server not found" message.
|
||||||
#[error("dns: could not resolve '{host}': {reason}")]
|
#[error("dns: could not resolve '{host}': {reason}")]
|
||||||
@@ -94,6 +98,12 @@ pub enum BridgeError {
|
|||||||
Unmapped(String),
|
Unmapped(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl BridgeError {
|
||||||
|
fn unmapped_ctx(ctx: impl std::fmt::Display, e: impl std::fmt::Display) -> Self {
|
||||||
|
BridgeError::Unmapped(format!("{ctx}: {e}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<chanora_core::CoreError> for BridgeError {
|
impl From<chanora_core::CoreError> for BridgeError {
|
||||||
fn from(e: chanora_core::CoreError) -> Self {
|
fn from(e: chanora_core::CoreError) -> Self {
|
||||||
match e {
|
match e {
|
||||||
@@ -110,10 +120,319 @@ impl From<chanora_core::CoreError> for BridgeError {
|
|||||||
code,
|
code,
|
||||||
message,
|
message,
|
||||||
}) => BridgeError::ServerRejected { code, message },
|
}) => BridgeError::ServerRejected { code, message },
|
||||||
|
chanora_core::CoreError::Protocol(chanora_core::ProtocolError::FileTransfer(p)) => {
|
||||||
|
BridgeError::Connection(format!("file transfer: {p}"))
|
||||||
|
}
|
||||||
chanora_core::CoreError::Protocol(p) => BridgeError::Connection(format!("{p}")),
|
chanora_core::CoreError::Protocol(p) => BridgeError::Connection(format!("{p}")),
|
||||||
chanora_core::CoreError::Audio(a) => BridgeError::Connection(format!("audio: {a}")),
|
chanora_core::CoreError::Audio(a) => BridgeError::Connection(format!("audio: {a}")),
|
||||||
chanora_core::CoreError::Storage(s) => BridgeError::Connection(format!("storage: {s}")),
|
chanora_core::CoreError::Storage(s) => BridgeError::Connection(format!("storage: {s}")),
|
||||||
|
chanora_core::CoreError::Cache(c) => BridgeError::Connection(format!("cache: {c}")),
|
||||||
other => BridgeError::Unmapped(format!("{other}")),
|
other => BridgeError::Unmapped(format!("{other}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn roundtrip_json<
|
||||||
|
T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug,
|
||||||
|
>(
|
||||||
|
value: &T,
|
||||||
|
) {
|
||||||
|
let json = serde_json::to_string(value).expect("serialize");
|
||||||
|
let back: T = serde_json::from_str(&json).expect("deserialize");
|
||||||
|
assert_eq!(&back, value, "roundtrip failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bridge_error_invalid_command() {
|
||||||
|
let err = BridgeError::InvalidCommand("bad".to_string());
|
||||||
|
assert_eq!(err.to_string(), "invalid command: bad");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bridge_error_dns_failed() {
|
||||||
|
let err = BridgeError::DnsFailed {
|
||||||
|
host: "example.com".to_string(),
|
||||||
|
reason: "timeout".to_string(),
|
||||||
|
};
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(msg.contains("example.com"));
|
||||||
|
assert!(msg.contains("timeout"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bridge_error_connection() {
|
||||||
|
let err = BridgeError::Connection("refused".to_string());
|
||||||
|
assert_eq!(err.to_string(), "connection: refused");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bridge_error_not_connected() {
|
||||||
|
let err = BridgeError::NotConnected;
|
||||||
|
assert_eq!(err.to_string(), "not connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bridge_error_already_connected() {
|
||||||
|
let err = BridgeError::AlreadyConnected;
|
||||||
|
assert_eq!(err.to_string(), "already connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bridge_error_server_rejected() {
|
||||||
|
let err = BridgeError::ServerRejected {
|
||||||
|
code: 2568,
|
||||||
|
message: "insufficient permissions".to_string(),
|
||||||
|
};
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(msg.contains("2568"));
|
||||||
|
assert!(msg.contains("insufficient permissions"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bridge_error_unmapped() {
|
||||||
|
let err = BridgeError::Unmapped("mystery".to_string());
|
||||||
|
assert_eq!(err.to_string(), "unmapped: mystery");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bridge_error_serde_roundtrip() {
|
||||||
|
roundtrip_json(&BridgeError::InvalidCommand("test".to_string()));
|
||||||
|
roundtrip_json(&BridgeError::NotConnected);
|
||||||
|
roundtrip_json(&BridgeError::AlreadyConnected);
|
||||||
|
roundtrip_json(&BridgeError::Connection("fail".to_string()));
|
||||||
|
roundtrip_json(&BridgeError::Unmapped("x".to_string()));
|
||||||
|
roundtrip_json(&BridgeError::DnsFailed {
|
||||||
|
host: "h".to_string(),
|
||||||
|
reason: "r".to_string(),
|
||||||
|
});
|
||||||
|
roundtrip_json(&BridgeError::ServerRejected {
|
||||||
|
code: 42,
|
||||||
|
message: "nope".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bridge_error_clone_preserves() {
|
||||||
|
let err = BridgeError::InvalidCommand("orig".to_string());
|
||||||
|
let cloned = err.clone();
|
||||||
|
assert_eq!(cloned.to_string(), err.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_core_error_not_connected() {
|
||||||
|
let core_err = chanora_core::CoreError::NotConnected;
|
||||||
|
let bridge_err: BridgeError = core_err.into();
|
||||||
|
assert!(matches!(bridge_err, BridgeError::NotConnected));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_core_error_already_connected() {
|
||||||
|
let core_err = chanora_core::CoreError::AlreadyConnected;
|
||||||
|
let bridge_err: BridgeError = core_err.into();
|
||||||
|
assert!(matches!(bridge_err, BridgeError::AlreadyConnected));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_core_error_audio_not_started() {
|
||||||
|
let core_err = chanora_core::CoreError::AudioNotStarted;
|
||||||
|
let bridge_err: BridgeError = core_err.into();
|
||||||
|
match bridge_err {
|
||||||
|
BridgeError::InvalidCommand(msg) => {
|
||||||
|
assert!(msg.contains("audio not started"));
|
||||||
|
}
|
||||||
|
other => panic!("expected InvalidCommand, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_core_error_protocol_dns_failed() {
|
||||||
|
let core_err = chanora_core::CoreError::Protocol(
|
||||||
|
chanora_core::ProtocolError::DnsFailed {
|
||||||
|
host: "bad.host".to_string(),
|
||||||
|
reason: "no address".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let bridge_err: BridgeError = core_err.into();
|
||||||
|
match bridge_err {
|
||||||
|
BridgeError::DnsFailed { host, reason } => {
|
||||||
|
assert_eq!(host, "bad.host");
|
||||||
|
assert_eq!(reason, "no address");
|
||||||
|
}
|
||||||
|
other => panic!("expected DnsFailed, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_core_error_protocol_server_rejected() {
|
||||||
|
let core_err = chanora_core::CoreError::Protocol(
|
||||||
|
chanora_core::ProtocolError::ServerRejected {
|
||||||
|
code: 0x0501,
|
||||||
|
message: "channel password wrong".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let bridge_err: BridgeError = core_err.into();
|
||||||
|
match bridge_err {
|
||||||
|
BridgeError::ServerRejected { code, message } => {
|
||||||
|
assert_eq!(code, 0x0501);
|
||||||
|
assert_eq!(message, "channel password wrong");
|
||||||
|
}
|
||||||
|
other => panic!("expected ServerRejected, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_core_error_protocol_file_transfer() {
|
||||||
|
let core_err = chanora_core::CoreError::Protocol(
|
||||||
|
chanora_core::ProtocolError::FileTransfer("disk full".to_string()),
|
||||||
|
);
|
||||||
|
let bridge_err: BridgeError = core_err.into();
|
||||||
|
match bridge_err {
|
||||||
|
BridgeError::Connection(msg) => {
|
||||||
|
assert!(msg.contains("file transfer"));
|
||||||
|
assert!(msg.contains("disk full"));
|
||||||
|
}
|
||||||
|
other => panic!("expected Connection, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_core_error_protocol_generic() {
|
||||||
|
let core_err = chanora_core::CoreError::Protocol(
|
||||||
|
chanora_core::ProtocolError::Connect("refused".to_string()),
|
||||||
|
);
|
||||||
|
let bridge_err: BridgeError = core_err.into();
|
||||||
|
match bridge_err {
|
||||||
|
BridgeError::Connection(msg) => {
|
||||||
|
assert!(msg.contains("refused"));
|
||||||
|
}
|
||||||
|
other => panic!("expected Connection, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_core_error_protocol_lost() {
|
||||||
|
let core_err = chanora_core::CoreError::Protocol(
|
||||||
|
chanora_core::ProtocolError::Lost("timeout".to_string()),
|
||||||
|
);
|
||||||
|
let bridge_err: BridgeError = core_err.into();
|
||||||
|
match bridge_err {
|
||||||
|
BridgeError::Connection(msg) => {
|
||||||
|
assert!(msg.contains("timeout"));
|
||||||
|
}
|
||||||
|
other => panic!("expected Connection, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_core_error_protocol_invalid() {
|
||||||
|
let core_err = chanora_core::CoreError::Protocol(
|
||||||
|
chanora_core::ProtocolError::Invalid("bad config".to_string()),
|
||||||
|
);
|
||||||
|
let bridge_err: BridgeError = core_err.into();
|
||||||
|
match bridge_err {
|
||||||
|
BridgeError::Connection(msg) => {
|
||||||
|
assert!(msg.contains("bad config"));
|
||||||
|
}
|
||||||
|
other => panic!("expected Connection, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_core_error_protocol_disconnected_early() {
|
||||||
|
let core_err = chanora_core::CoreError::Protocol(
|
||||||
|
chanora_core::ProtocolError::DisconnectedEarly("premature".to_string()),
|
||||||
|
);
|
||||||
|
let bridge_err: BridgeError = core_err.into();
|
||||||
|
match bridge_err {
|
||||||
|
BridgeError::Connection(msg) => {
|
||||||
|
assert!(msg.contains("premature"));
|
||||||
|
}
|
||||||
|
other => panic!("expected Connection, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_core_error_protocol_identity() {
|
||||||
|
let core_err = chanora_core::CoreError::Protocol(
|
||||||
|
chanora_core::ProtocolError::Identity("parse error".to_string()),
|
||||||
|
);
|
||||||
|
let bridge_err: BridgeError = core_err.into();
|
||||||
|
match bridge_err {
|
||||||
|
BridgeError::Connection(msg) => {
|
||||||
|
assert!(msg.contains("parse error"));
|
||||||
|
}
|
||||||
|
other => panic!("expected Connection, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_core_error_protocol_timeout() {
|
||||||
|
let core_err =
|
||||||
|
chanora_core::CoreError::Protocol(chanora_core::ProtocolError::Timeout);
|
||||||
|
let bridge_err: BridgeError = core_err.into();
|
||||||
|
match bridge_err {
|
||||||
|
BridgeError::Connection(msg) => {
|
||||||
|
assert!(msg.contains("timeout"));
|
||||||
|
}
|
||||||
|
other => panic!("expected Connection, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_core_error_protocol_backend() {
|
||||||
|
let core_err = chanora_core::CoreError::Protocol(
|
||||||
|
chanora_core::ProtocolError::Backend("raw".to_string()),
|
||||||
|
);
|
||||||
|
let bridge_err: BridgeError = core_err.into();
|
||||||
|
match bridge_err {
|
||||||
|
BridgeError::Connection(msg) => {
|
||||||
|
assert!(msg.contains("raw"));
|
||||||
|
}
|
||||||
|
other => panic!("expected Connection, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_core_error_invariant() {
|
||||||
|
let core_err = chanora_core::CoreError::Invariant("broken");
|
||||||
|
let bridge_err: BridgeError = core_err.into();
|
||||||
|
match bridge_err {
|
||||||
|
BridgeError::Unmapped(msg) => {
|
||||||
|
assert!(msg.contains("broken"));
|
||||||
|
}
|
||||||
|
other => panic!("expected Unmapped, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn error_surfaces_all_protocol_error_variants() {
|
||||||
|
let protocol_errors: Vec<chanora_core::ProtocolError> = vec![
|
||||||
|
chanora_core::ProtocolError::Invalid("x".into()),
|
||||||
|
chanora_core::ProtocolError::DnsFailed {
|
||||||
|
host: "h".into(),
|
||||||
|
reason: "r".into(),
|
||||||
|
},
|
||||||
|
chanora_core::ProtocolError::Connect("c".into()),
|
||||||
|
chanora_core::ProtocolError::DisconnectedEarly("d".into()),
|
||||||
|
chanora_core::ProtocolError::Lost("l".into()),
|
||||||
|
chanora_core::ProtocolError::Identity("i".into()),
|
||||||
|
chanora_core::ProtocolError::Timeout,
|
||||||
|
chanora_core::ProtocolError::ServerRejected {
|
||||||
|
code: 1,
|
||||||
|
message: "m".into(),
|
||||||
|
},
|
||||||
|
chanora_core::ProtocolError::Backend("b".into()),
|
||||||
|
chanora_core::ProtocolError::FileTransfer("f".into()),
|
||||||
|
];
|
||||||
|
for p_err in protocol_errors {
|
||||||
|
let core_err = chanora_core::CoreError::Protocol(p_err);
|
||||||
|
let bridge_err: BridgeError = core_err.into();
|
||||||
|
let msg = bridge_err.to_string();
|
||||||
|
assert!(!msg.is_empty(), "BridgeError message must not be empty");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "chanora_cache"
|
||||||
|
description = "Chanora disposable content-addressed blob cache for avatars and icons"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
cacache = "13"
|
||||||
|
thiserror.workspace = true
|
||||||
|
tokio = { version = "1", features = ["fs", "rt"] }
|
||||||
|
tracing.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
tokio = { version = "1", features = ["rt", "macros", "time"] }
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# chanora_cache
|
||||||
|
|
||||||
|
Disposable content-addressed blob cache for avatar and icon files. Wraps `cacache` for crash safety and integrity verification. Separated from `chanora_storage` because cache owns reconstructible, disposable blob data with different durability and backup semantics.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **`BlobCache`** — async blob store backed by cacache's content-v2 / index-v2 on-disk layout.
|
||||||
|
- Keys are protocol identifiers prefixed by type: `av_<32-char-hex>` for avatars (MD5), `ic_<decimal>` for icons (CRC32).
|
||||||
|
- Cacache handles dedup and SSRI integrity verification on every read.
|
||||||
|
- Corrupt entries are automatically removed on read failure.
|
||||||
|
- LRU eviction by timestamp when total size exceeds the configured cap.
|
||||||
|
|
||||||
|
## Public API Summary
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
| Type | Role |
|
||||||
|
|---|---|
|
||||||
|
| `BlobCache` | Content-addressed blob cache |
|
||||||
|
| `BlobCacheError` | Io, InvalidKey |
|
||||||
|
|
||||||
|
### Key methods on `BlobCache`
|
||||||
|
|
||||||
|
- `new(cache_dir, max_bytes)` — create or open the cache. `max_bytes = 0` disables eviction.
|
||||||
|
- `put(prefix, key, data)` — store a blob (async)
|
||||||
|
- `get(prefix, key)` → `Option<Vec<u8>>` — read a blob, with integrity check (async)
|
||||||
|
- `remove(prefix, key)` — delete a specific blob (async)
|
||||||
|
- `clear()` — delete all blobs (async)
|
||||||
|
- `total_size()` → `u64` — sum of all blob sizes (async)
|
||||||
|
- `evict()` — remove oldest entries until under `max_bytes` cap (async)
|
||||||
|
|
||||||
|
### Constants
|
||||||
|
|
||||||
|
- `PREFIX_AVATAR` = `"av_"` — avatar key prefix
|
||||||
|
- `PREFIX_ICON` = `"ic_"` — icon key prefix
|
||||||
|
|
||||||
|
## Key validation
|
||||||
|
|
||||||
|
Avatar keys must be exactly 32 hex characters. Icon keys must be non-empty decimal digits. Unknown prefixes are rejected. This prevents malformed entries from polluting the cache.
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
//! Disposable content-addressed blob cache for avatar and icon files.
|
||||||
|
//!
|
||||||
|
//! Wraps [`cacache`] for production-tested crash safety and integrity
|
||||||
|
//! verification. The on-disk layout is managed by cacache (content-v2,
|
||||||
|
//! index-v2). Chanora maps protocol keys (`av_<md5>`, `ic_<crc32>`) to
|
||||||
|
//! cacache string keys.
|
||||||
|
//!
|
||||||
|
//! This crate is intentionally separate from `chanora_storage`:
|
||||||
|
//! storage owns persistent identity/bookmark data; cache owns
|
||||||
|
//! reconstructible, disposable blob data with different durability
|
||||||
|
//! and backup semantics.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// Errors raised by the blob cache.
|
||||||
|
// TODO(refactor): Io(String) variant is duplicated across chanora_cache,
|
||||||
|
// chanora_storage, and chanora_diagnostics. Could use a shared error type
|
||||||
|
// or derive From<std::io::Error> instead of manually wrapping.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum BlobCacheError {
|
||||||
|
/// Filesystem I/O error.
|
||||||
|
#[error("io: {0}")]
|
||||||
|
Io(String),
|
||||||
|
/// Key validation error.
|
||||||
|
#[error("invalid key: {0}")]
|
||||||
|
InvalidKey(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BlobCacheError {
|
||||||
|
fn io_ctx(ctx: impl std::fmt::Display, e: impl std::fmt::Display) -> Self {
|
||||||
|
BlobCacheError::Io(format!("{ctx}: {e}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Content-addressed blob cache backed by cacache.
|
||||||
|
pub struct BlobCache {
|
||||||
|
cache_dir: PathBuf,
|
||||||
|
/// Maximum total cache size in bytes. 0 = no limit.
|
||||||
|
max_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Avatar blob prefix.
|
||||||
|
pub const PREFIX_AVATAR: &str = "av_";
|
||||||
|
/// Icon blob prefix.
|
||||||
|
pub const PREFIX_ICON: &str = "ic_";
|
||||||
|
|
||||||
|
impl BlobCache {
|
||||||
|
/// Create or open a [`BlobCache`] rooted at `cache_dir/chanora/`.
|
||||||
|
///
|
||||||
|
/// Creates the cacache directory. `max_bytes` sets the eviction
|
||||||
|
/// threshold; 0 means no automatic eviction.
|
||||||
|
pub fn new(cache_dir: impl AsRef<Path>, max_bytes: u64) -> Result<Self, BlobCacheError> {
|
||||||
|
let cache_dir = cache_dir.as_ref().join("chanora").join("blobs");
|
||||||
|
// cacache creates the directory on first write, but we create
|
||||||
|
// it eagerly so total_size() works before any writes.
|
||||||
|
std::fs::create_dir_all(&cache_dir)
|
||||||
|
.map_err(|e| BlobCacheError::io_ctx("mkdir cache", e))?;
|
||||||
|
Ok(Self {
|
||||||
|
cache_dir,
|
||||||
|
max_bytes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store a blob. `prefix` is [`PREFIX_AVATAR`] or [`PREFIX_ICON`].
|
||||||
|
/// `key` is the content hash (MD5 hex for avatars, unsigned
|
||||||
|
/// decimal CRC32 for icons).
|
||||||
|
///
|
||||||
|
/// Cacache handles dedup and integrity internally.
|
||||||
|
pub async fn put(
|
||||||
|
&self,
|
||||||
|
prefix: &str,
|
||||||
|
key: &str,
|
||||||
|
data: &[u8],
|
||||||
|
) -> Result<(), BlobCacheError> {
|
||||||
|
validate_key(prefix, key)?;
|
||||||
|
let cache_key = format!("{prefix}{key}");
|
||||||
|
cacache::write(&self.cache_dir, &cache_key, data)
|
||||||
|
.await
|
||||||
|
.map_err(|e| BlobCacheError::io_ctx("cacache write", e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a blob. Returns `None` if not cached.
|
||||||
|
///
|
||||||
|
/// Cacache verifies SSRI integrity on every read.
|
||||||
|
pub async fn get(&self, prefix: &str, key: &str) -> Result<Option<Vec<u8>>, BlobCacheError> {
|
||||||
|
validate_key(prefix, key)?;
|
||||||
|
let cache_key = format!("{prefix}{key}");
|
||||||
|
match cacache::read(&self.cache_dir, &cache_key).await {
|
||||||
|
Ok(data) => Ok(Some(data)),
|
||||||
|
Err(cacache::Error::EntryNotFound(_, _)) => Ok(None),
|
||||||
|
Err(e) => {
|
||||||
|
// Integrity failure or I/O error — remove corrupt entry.
|
||||||
|
tracing::warn!(
|
||||||
|
target: "chanora_cache",
|
||||||
|
key = %cache_key,
|
||||||
|
error = %e,
|
||||||
|
"cache read failed; removing entry"
|
||||||
|
);
|
||||||
|
let _ = cacache::remove(&self.cache_dir, &cache_key).await;
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a specific blob.
|
||||||
|
pub async fn remove(&self, prefix: &str, key: &str) -> Result<(), BlobCacheError> {
|
||||||
|
validate_key(prefix, key)?;
|
||||||
|
let cache_key = format!("{prefix}{key}");
|
||||||
|
cacache::remove(&self.cache_dir, &cache_key)
|
||||||
|
.await
|
||||||
|
.map_err(|e| BlobCacheError::io_ctx("cacache remove", e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete all blobs.
|
||||||
|
pub async fn clear(&self) -> Result<(), BlobCacheError> {
|
||||||
|
let path = self.cache_dir.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
if path.exists() {
|
||||||
|
std::fs::remove_dir_all(&path)
|
||||||
|
.map_err(|e| BlobCacheError::io_ctx("clear cache", e))?;
|
||||||
|
std::fs::create_dir_all(&path)
|
||||||
|
.map_err(|e| BlobCacheError::io_ctx("recreate cache dir", e))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| BlobCacheError::io_ctx("clear task", e))?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return total bytes used by all blobs.
|
||||||
|
///
|
||||||
|
/// Walks cacache entries and sums sizes.
|
||||||
|
pub async fn total_size(&self) -> Result<u64, BlobCacheError> {
|
||||||
|
let cache_dir = self.cache_dir.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let mut total: u64 = 0;
|
||||||
|
for entry in cacache::list_sync(&cache_dir) {
|
||||||
|
match entry {
|
||||||
|
Ok(meta) => total += meta.size as u64,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "chanora_cache",
|
||||||
|
error = %e,
|
||||||
|
"skipping bad entry during size scan"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(total)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| BlobCacheError::io_ctx("total_size task", e))?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evict oldest entries by timestamp until total size is under
|
||||||
|
/// `max_bytes`. Call on startup or periodically. No-op if
|
||||||
|
/// `max_bytes` is 0.
|
||||||
|
pub async fn evict(&self) -> Result<(), BlobCacheError> {
|
||||||
|
if self.max_bytes == 0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let cache_dir = self.cache_dir.clone();
|
||||||
|
let max_bytes = self.max_bytes;
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let mut entries: Vec<(String, usize, u128)> = Vec::new();
|
||||||
|
for entry in cacache::list_sync(&cache_dir) {
|
||||||
|
match entry {
|
||||||
|
Ok(meta) => {
|
||||||
|
entries.push((meta.key, meta.size, meta.time));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "chanora_cache",
|
||||||
|
error = %e,
|
||||||
|
"skipping bad entry during eviction scan"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let total: usize = entries.iter().map(|(_, s, _)| *s).sum();
|
||||||
|
if total as u64 <= max_bytes {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
entries.sort_by_key(|(_, _, t)| *t);
|
||||||
|
let mut freed: usize = 0;
|
||||||
|
let target = total - max_bytes as usize;
|
||||||
|
for (key, size, _) in entries {
|
||||||
|
if freed >= target {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let _ = cacache::remove_sync(&cache_dir, &key);
|
||||||
|
freed += size;
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
target: "chanora_cache",
|
||||||
|
freed_bytes = freed,
|
||||||
|
"evicted oldest blobs"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| BlobCacheError::io_ctx("evict task", e))?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate key format to prevent malformed entries.
|
||||||
|
fn validate_key(prefix: &str, key: &str) -> Result<(), BlobCacheError> {
|
||||||
|
if !matches!(prefix, PREFIX_AVATAR | PREFIX_ICON) {
|
||||||
|
return Err(BlobCacheError::InvalidKey(format!("bad prefix: {prefix}")));
|
||||||
|
}
|
||||||
|
match prefix {
|
||||||
|
PREFIX_AVATAR => {
|
||||||
|
// MD5 hex = exactly 32 hex chars.
|
||||||
|
if key.len() != 32 || !key.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||||
|
return Err(BlobCacheError::InvalidKey(format!(
|
||||||
|
"avatar key must be 32 hex chars, got: {key}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PREFIX_ICON => {
|
||||||
|
// Unsigned CRC32 = decimal digits.
|
||||||
|
if key.is_empty() || !key.chars().all(|c| c.is_ascii_digit()) {
|
||||||
|
return Err(BlobCacheError::InvalidKey(format!(
|
||||||
|
"icon key must be decimal digits, got: {key}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn tempdir() -> tempfile::TempDir {
|
||||||
|
tempfile::Builder::new()
|
||||||
|
.prefix("chanora_cache_test_")
|
||||||
|
.tempdir()
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn put_get_roundtrip() {
|
||||||
|
let tmp = tempdir();
|
||||||
|
let cache = BlobCache::new(&tmp, 0).unwrap();
|
||||||
|
assert!(cache
|
||||||
|
.get(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_none());
|
||||||
|
cache
|
||||||
|
.put(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", b"avatar-bytes")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let data = cache
|
||||||
|
.get(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(data.as_deref(), Some(b"avatar-bytes".as_slice()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_missing_returns_none() {
|
||||||
|
let tmp = tempdir();
|
||||||
|
let cache = BlobCache::new(&tmp, 0).unwrap();
|
||||||
|
assert!(cache
|
||||||
|
.get(PREFIX_AVATAR, "00000000000000000000000000000000")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn clear_removes_all() {
|
||||||
|
let tmp = tempdir();
|
||||||
|
let cache = BlobCache::new(&tmp, 0).unwrap();
|
||||||
|
cache
|
||||||
|
.put(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", b"data")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
cache.put(PREFIX_ICON, "12345", b"icon").await.unwrap();
|
||||||
|
cache.clear().await.unwrap();
|
||||||
|
assert_eq!(cache.total_size().await.unwrap(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn total_size_accounts_for_all_entries() {
|
||||||
|
let tmp = tempdir();
|
||||||
|
let cache = BlobCache::new(&tmp, 0).unwrap();
|
||||||
|
cache
|
||||||
|
.put(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", b"12345")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
cache.put(PREFIX_ICON, "99999", b"12").await.unwrap();
|
||||||
|
assert_eq!(cache.total_size().await.unwrap(), 5 + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn invalid_key_rejected() {
|
||||||
|
let tmp = tempdir();
|
||||||
|
let cache = BlobCache::new(&tmp, 0).unwrap();
|
||||||
|
// Too short for MD5.
|
||||||
|
assert!(cache.put(PREFIX_AVATAR, "abc", b"data").await.is_err());
|
||||||
|
// Non-hex in MD5.
|
||||||
|
assert!(cache
|
||||||
|
.put(PREFIX_AVATAR, "g".repeat(32).as_str(), b"data")
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
// Non-digit in icon key.
|
||||||
|
assert!(cache.put(PREFIX_ICON, "12a45", b"data").await.is_err());
|
||||||
|
// Bad prefix.
|
||||||
|
assert!(cache.put("xx_", "abc", b"data").await.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn evict_deletes_oldest_until_under_cap() {
|
||||||
|
let tmp = tempdir();
|
||||||
|
// 10 byte cap.
|
||||||
|
let cache = BlobCache::new(&tmp, 10).unwrap();
|
||||||
|
cache
|
||||||
|
.put(
|
||||||
|
PREFIX_AVATAR,
|
||||||
|
"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
|
||||||
|
b"12345678",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap(); // 8 bytes
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||||
|
cache
|
||||||
|
.put(PREFIX_ICON, "11111", b"12345")
|
||||||
|
.await
|
||||||
|
.unwrap(); // 5 bytes → total 13, over cap
|
||||||
|
cache.evict().await.unwrap();
|
||||||
|
// Oldest (avatar) should be evicted.
|
||||||
|
assert!(cache
|
||||||
|
.get(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_none());
|
||||||
|
assert!(cache.get(PREFIX_ICON, "11111").await.unwrap().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn remove_deletes_entry() {
|
||||||
|
let tmp = tempdir();
|
||||||
|
let cache = BlobCache::new(&tmp, 0).unwrap();
|
||||||
|
cache
|
||||||
|
.put(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", b"data")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
cache
|
||||||
|
.remove(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(cache
|
||||||
|
.get(PREFIX_AVATAR, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# chanora_diagnostics
|
||||||
|
|
||||||
|
Application diagnostics: log redaction, in-memory log capture, and user-initiated diagnostic export. Per DEC-016, export is **user-initiated only**; there is no automatic upload.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Redaction policy
|
||||||
|
|
||||||
|
`Redactor` applies the production policy (REDACT-TC-001..010):
|
||||||
|
|
||||||
|
1. Known-secret registry — substring match → `[REDACTED]`
|
||||||
|
2. `$HOME` prefix → `[home]`
|
||||||
|
3. IPv4 addresses → `[ip]`
|
||||||
|
4. IPv6 addresses → `[ip]`
|
||||||
|
5. Email-shaped strings → `[email]`
|
||||||
|
6. Long opaque tokens (base64 ≥32 chars, ≥75% alnum) → `[token]`
|
||||||
|
|
||||||
|
### PTT sanitiser
|
||||||
|
|
||||||
|
`PttSanitizer<L>` — a `tracing-subscriber` Layer decorator that drops any record containing field names from the banned list (`key_code`, `scan_code`, `virtual_key`, `keysym`, etc.) per DEC-027 / REDACT-PTT-001..006. Allocation-free on the success path.
|
||||||
|
|
||||||
|
### Log capture
|
||||||
|
|
||||||
|
`InMemoryLogSink` — bounded ring buffer that passes every line through the redactor before storing. Capacity differs by build: 4096 lines (debug), 256 lines (release) per SRS-122.
|
||||||
|
|
||||||
|
### Event recorder
|
||||||
|
|
||||||
|
`ProtocolEventRecorder` — ring buffer of protocol-level events (connect, disconnect, reconnect, snapshot changes, channel joins) for diagnostic export and state-sync replay verification (SRS-097/098).
|
||||||
|
|
||||||
|
### Export
|
||||||
|
|
||||||
|
`DiagnosticExport` — serialisable bundle containing:
|
||||||
|
- Client metadata (version, platform)
|
||||||
|
- Redacted recent logs
|
||||||
|
- Known-secret count (values never exported)
|
||||||
|
- Optional Android audio diagnostics YAML
|
||||||
|
- Optional network diagnostics summary
|
||||||
|
- Protocol event trace
|
||||||
|
|
||||||
|
## Public API Summary
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
| Type | Role |
|
||||||
|
|---|---|
|
||||||
|
| `Redactor` | Production redaction policy (cheap to clone) |
|
||||||
|
| `KnownSecretRegistry` | Cross-spike secret registry for defence in depth (SS-AUD-003) |
|
||||||
|
| `InMemoryLogSink` | Bounded ring buffer of redacted log lines |
|
||||||
|
| `RedactingLogLayer` | `tracing-subscriber` Layer feeding `InMemoryLogSink` |
|
||||||
|
| `PttSanitizer<L>` | Layer decorator dropping PTT-sensitive records |
|
||||||
|
| `DiagnosticExport` | User-facing export bundle |
|
||||||
|
| `ProtocolEventRecorder` | Protocol event ring buffer (SRS-097) |
|
||||||
|
| `DiagnosticsError` | Export, Io |
|
||||||
|
| `REDACTION_MARKER` | `"[REDACTED]"` |
|
||||||
|
|
||||||
|
### Key methods
|
||||||
|
|
||||||
|
**Redactor:**
|
||||||
|
- `with_default_policy()` / `with_secrets(registry)` — construct
|
||||||
|
- `redact(s)` → `String` — apply policy
|
||||||
|
- `secrets()` → `&KnownSecretRegistry` — register secrets
|
||||||
|
|
||||||
|
**KnownSecretRegistry:**
|
||||||
|
- `register(secret)` — add a known-secret value (≥4 chars)
|
||||||
|
- `contains_substr(haystack)` → `bool` — substring check
|
||||||
|
|
||||||
|
**InMemoryLogSink:**
|
||||||
|
- `new(capacity, redactor)` — construct
|
||||||
|
- `push(raw)` — redact and store a line
|
||||||
|
- `snapshot()` → `Vec<String>` — current buffer contents
|
||||||
|
|
||||||
|
**DiagnosticExport:**
|
||||||
|
- `from_sink(sink, metadata)` — build from log sink
|
||||||
|
- `with_android_audio(yaml)` / `with_network_info(info)` / `with_protocol_events(events)` — attach optional sections
|
||||||
|
- `to_text()` → `String` — render as multi-line plaintext
|
||||||
|
|
||||||
|
**ProtocolEventRecorder:**
|
||||||
|
- `new(capacity)` — construct
|
||||||
|
- `record_connected(server_name)` / `record_disconnected(reason)` / `record_reconnecting(attempt, delay)`
|
||||||
|
- `drain()` → `Vec<String>` / `snapshot()` → `Vec<String>`
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# chanora_prefetch
|
||||||
|
|
||||||
|
Server-address prefetch cache and policy. Owns speculative server-resolution warming so that when the user presses Connect, a fresh DNS/SRV result may already be available, reducing perceived join latency.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Cache model
|
||||||
|
|
||||||
|
`ServerPrefetchCache` holds at most one entry — the latest prefetched resolution. A generation counter prevents stale async completions from overwriting newer results. TTL is 120 seconds.
|
||||||
|
|
||||||
|
### Flow
|
||||||
|
|
||||||
|
1. Flutter typing triggers `prefetch_server(host)` via the bridge.
|
||||||
|
2. `ServerPrefetcher::prefetch()` normalizes the host, bumps the generation, and spawns a fire-and-forget tokio task that calls `chanora_resolver::ChanoraResolver::resolve_client_address()`.
|
||||||
|
3. On success, the result is stored if its generation is still current.
|
||||||
|
4. When `chanora_core::connect()` is called, it checks `fresh_match(host)`. If a fresh (non-expired) entry matches, it's used as the `resolved_address` in `ConnectConfig`, bypassing a second DNS round trip.
|
||||||
|
|
||||||
|
### Generation guard
|
||||||
|
|
||||||
|
If the user types another host while the first prefetch is in flight, the generation advances. The slower completion is discarded because its generation no longer matches. The most recent entry always wins.
|
||||||
|
|
||||||
|
## Public API Summary
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
| Type | Role |
|
||||||
|
|---|---|
|
||||||
|
| `ServerPrefetcher` | Public API: schedule prefetches, query fresh matches |
|
||||||
|
| `ServerPrefetchError` | ResolverInit, Resolution, InvalidSocketAddress |
|
||||||
|
|
||||||
|
### Key methods on `ServerPrefetcher`
|
||||||
|
|
||||||
|
- `new()` — construct with empty cache
|
||||||
|
- `prefetch(host)` — schedule a fire-and-forget resolution (async). Only reports synchronous setup failures; DNS failures are logged.
|
||||||
|
- `fresh_match(host)` → `Option<SocketAddr>` — return a cached address if it matches and is within TTL (async)
|
||||||
|
|
||||||
|
### Test-only methods (behind `cfg(test)` or `feature = "test-support"`)
|
||||||
|
|
||||||
|
- `begin_for_test(host)` — bump generation
|
||||||
|
- `store_success_for_test(generation, host, addr, instant)` — inject a result
|
||||||
|
- `latest_generation_for_test()` — read current generation
|
||||||
|
- `fail_next_prefetch_setup_for_test(error)` — inject a setup failure
|
||||||
|
|
||||||
|
## Design notes
|
||||||
|
|
||||||
|
- Blank/whitespace-only hosts are silently skipped.
|
||||||
|
- Hosts are normalized to lowercase trimmed strings before matching.
|
||||||
|
- A fresh entry remains usable while a newer prefetch is in flight; stale completions are rejected by the generation guard.
|
||||||
@@ -194,6 +194,9 @@ impl ServerPrefetcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO(refactor): `normalize_host` duplicates `chanora_resolver::normalize_args`
|
||||||
|
// host trimming. Both do `host.trim().to_lowercase()`. Could move to a shared
|
||||||
|
// utility in chanora_protocol or a tiny chanora_common crate if more crates need it.
|
||||||
fn normalize_host(host: &str) -> String {
|
fn normalize_host(host: &str) -> String {
|
||||||
host.trim().to_lowercase()
|
host.trim().to_lowercase()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,3 +42,6 @@ reqwest = { version = "0.13", default-features = false, features = ["charset", "
|
|||||||
# Android cross-builds should not pull OpenSSL. Use rustls here while keeping
|
# Android cross-builds should not pull OpenSSL. Use rustls here while keeping
|
||||||
# native-tls for Apple targets where aws-lc/rustls is problematic for iOS.
|
# native-tls for Apple targets where aws-lc/rustls is problematic for iOS.
|
||||||
reqwest = { version = "0.13", default-features = false, features = ["charset", "http2", "rustls"] }
|
reqwest = { version = "0.13", default-features = false, features = ["charset", "http2", "rustls"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json = "1"
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# chanora_protocol
|
||||||
|
|
||||||
|
TeamSpeak-compatible protocol adapter. Isolates `tsclientlib` behind a typed boundary so the rest of Chanora is decoupled from the upstream library's types (SAD-067).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **`adapter` module** — wraps `tsclientlib::Connection` into an async `ProtocolClient` handle. Owns the connection task, loss notifier, snapshot probe, and voice channel endpoints.
|
||||||
|
- **`dto` module** — plain-data types (`ServerSnapshot`, `ChannelInfo`, `ClientInfo`, `ClientProfile`, `ChatMessage`) containing only `String`s and primitives. No `tsclientlib` types leak out.
|
||||||
|
- **`poke_limiter`** — rate-limiter for poke messages to prevent spam.
|
||||||
|
|
||||||
|
## Public API Summary
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
| Type | Role |
|
||||||
|
|---|---|
|
||||||
|
| `ProtocolClient` | Async handle owning the TS3 connection task |
|
||||||
|
| `ConnectConfig` | Connection parameters: address, nickname, password, identity, timeout, resolved_address |
|
||||||
|
| `ServerSnapshot` | Full server state: channels, clients, metadata |
|
||||||
|
| `ChannelInfo` / `ClientInfo` | Channel and client DTOs |
|
||||||
|
| `ClientProfile` | Rich per-client profile (unique_id, country, ping, groups, etc.) |
|
||||||
|
| `ChatMessage` | Inbound text message with target enum |
|
||||||
|
| `MessageTarget` | Server / Channel / Client(id) / Poke(id) |
|
||||||
|
| `ProtocolDelta` | Live state changes: client joined/left/moved/updated, channel added/removed/updated |
|
||||||
|
| `ServerActivity` | Server-wide broadcast messages |
|
||||||
|
| `DisconnectReason` | UserRequested / StreamEnded / Error |
|
||||||
|
| `ProtocolError` | Typed error catalogue: Invalid, DnsFailed, Connect, Lost, Identity, Timeout, ServerRejected, FileTransfer |
|
||||||
|
| `PokeLimiter` | Rate-limiting poke sends |
|
||||||
|
|
||||||
|
### Key methods on `ProtocolClient`
|
||||||
|
|
||||||
|
- `connect(cfg)` — dial a server and return a connected client
|
||||||
|
- `snapshot()` — fetch current server state
|
||||||
|
- `client_profile(id)` — rich profile for one client
|
||||||
|
- `send_text_message(msg, target)` — send chat
|
||||||
|
- `move_to_channel(id, password)` — move to a channel
|
||||||
|
- `queue_move_to_channel(id, password)` — async move with typed error reply
|
||||||
|
- `set_muted(input, output)` — server-side mute
|
||||||
|
- `download_avatar(uid)` / `download_icon(id)` — fetch protocol-owned assets
|
||||||
|
- `voice_out()` / `take_voice_in()` — voice packet endpoints
|
||||||
|
- `take_loss_notifier()` — oneshot channel that fires on connection loss
|
||||||
|
- `snapshot_probe()` — watchdog probe handle
|
||||||
|
- `generate_identity()` — create a fresh TS3 identity string
|
||||||
|
- `disconnect()` — clean shutdown
|
||||||
|
|
||||||
|
### Re-exports
|
||||||
|
|
||||||
|
The crate deliberately re-exports `tsproto_packets::packets::{AudioData, CodecType, Direction, InAudioBuf, OutAudio, OutPacket}` — the single permitted exception so `chanora_audio` can build voice packets without a direct `tsclientlib` dependency (SAD-067 performance carve-out).
|
||||||
|
|
||||||
|
## Address resolution
|
||||||
|
|
||||||
|
`chanora_resolver` owns TeamSpeak client address resolution (SRV, TSDNS, DNS fallback). This crate feeds the resulting `SocketAddr` to `tsclientlib::Connection::build`, bypassing tsclientlib's own resolver.
|
||||||
@@ -24,6 +24,7 @@ use base64::prelude::*;
|
|||||||
use chanora_resolver::ChanoraResolver;
|
use chanora_resolver::ChanoraResolver;
|
||||||
use futures::prelude::*;
|
use futures::prelude::*;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
use tokio::sync::{mpsc, oneshot};
|
use tokio::sync::{mpsc, oneshot};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
@@ -32,14 +33,15 @@ use tsclientlib::messages::s2c::{InClientDbInfoPart, InMessage};
|
|||||||
use tsclientlib::prelude::*;
|
use tsclientlib::prelude::*;
|
||||||
use tsclientlib::{
|
use tsclientlib::{
|
||||||
ChannelId as TsChannelId, ClientId as TsClientId, Connection, ConnectionStats,
|
ChannelId as TsChannelId, ClientId as TsClientId, Connection, ConnectionStats,
|
||||||
DisconnectOptions, Identity, MessageHandle, OutCommandExt, StreamItem, Version,
|
DisconnectOptions, FileDownloadResult, FiletransferHandle, Identity, MessageHandle,
|
||||||
|
OutCommandExt, StreamItem, Version,
|
||||||
};
|
};
|
||||||
use tsproto_packets::packets::{Direction, Flags, InAudioBuf, OutCommand, OutPacket, PacketType};
|
use tsproto_packets::packets::{Direction, Flags, InAudioBuf, OutCommand, OutPacket, PacketType};
|
||||||
use tsproto_types::ClientType;
|
use tsproto_types::ClientType;
|
||||||
|
|
||||||
use crate::dto::{
|
use crate::dto::{
|
||||||
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, ClientProfile, MessageTarget,
|
validate_nickname, ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, ClientProfile,
|
||||||
ProtocolDelta, ServerActivity, ServerSnapshot,
|
MessageTarget, ProtocolDelta, ServerActivity, ServerSnapshot,
|
||||||
};
|
};
|
||||||
use crate::poke_limiter::PokeLimiter;
|
use crate::poke_limiter::PokeLimiter;
|
||||||
use crate::ProtocolError;
|
use crate::ProtocolError;
|
||||||
@@ -60,6 +62,9 @@ type PendingMoves = HashMap<
|
|||||||
),
|
),
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
type PendingDownloads =
|
||||||
|
HashMap<FiletransferHandle, oneshot::Sender<Result<Vec<u8>, ProtocolError>>>;
|
||||||
|
|
||||||
struct EventChannels {
|
struct EventChannels {
|
||||||
voice_in: mpsc::Sender<InboundVoice>,
|
voice_in: mpsc::Sender<InboundVoice>,
|
||||||
chat: mpsc::Sender<ChatMessage>,
|
chat: mpsc::Sender<ChatMessage>,
|
||||||
@@ -133,7 +138,26 @@ where
|
|||||||
/// `Version` enum at compile time; if upstream rotates the CSV the
|
/// `Version` enum at compile time; if upstream rotates the CSV the
|
||||||
/// build will fail loudly here rather than silently fall back.
|
/// build will fail loudly here rather than silently fall back.
|
||||||
fn pick_client_version() -> Version {
|
fn pick_client_version() -> Version {
|
||||||
Version::Windows_3_X_X__1
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
Version::Windows_5_0_0_beta51
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
Version::Linux_5_0_0_beta51
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
Version::macOS_5_0_0_beta51
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
Version::Android_3_5_0__7
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "ios")]
|
||||||
|
{
|
||||||
|
Version::iOS_3_5_6
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Typed configuration for a connection attempt.
|
/// Typed configuration for a connection attempt.
|
||||||
@@ -207,6 +231,10 @@ enum Request {
|
|||||||
client_id: u64,
|
client_id: u64,
|
||||||
reply: oneshot::Sender<Result<ClientProfile, ProtocolError>>,
|
reply: oneshot::Sender<Result<ClientProfile, ProtocolError>>,
|
||||||
},
|
},
|
||||||
|
DownloadFile {
|
||||||
|
path: String,
|
||||||
|
reply: oneshot::Sender<Result<Vec<u8>, ProtocolError>>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Why a [`ProtocolClient`] task ended. Distinguishes a user-driven
|
/// Why a [`ProtocolClient`] task ended. Distinguishes a user-driven
|
||||||
@@ -271,9 +299,9 @@ impl SnapshotProbe {
|
|||||||
self.tx
|
self.tx
|
||||||
.send(Request::Snapshot(tx))
|
.send(Request::Snapshot(tx))
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))?;
|
.map_err(|_| ProtocolError::lost("connection task is gone"))?;
|
||||||
rx.await
|
rx.await
|
||||||
.map_err(|_| ProtocolError::Lost("snapshot reply dropped".to_string()))?
|
.map_err(|_| ProtocolError::lost("snapshot reply dropped"))?
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,9 +324,10 @@ impl ProtocolClient {
|
|||||||
if cfg.address.trim().is_empty() {
|
if cfg.address.trim().is_empty() {
|
||||||
return Err(ProtocolError::Invalid("address is empty".to_string()));
|
return Err(ProtocolError::Invalid("address is empty".to_string()));
|
||||||
}
|
}
|
||||||
if cfg.nickname.trim().is_empty() {
|
let validated_nick = validate_nickname(&cfg.nickname)
|
||||||
return Err(ProtocolError::Invalid("nickname is empty".to_string()));
|
.map_err(|e| ProtocolError::Invalid(e.to_string()))?;
|
||||||
}
|
let mut cfg = cfg;
|
||||||
|
cfg.nickname = validated_nick;
|
||||||
|
|
||||||
let (tx, rx) = mpsc::channel::<Request>(8);
|
let (tx, rx) = mpsc::channel::<Request>(8);
|
||||||
let (voice_out_tx, voice_out_rx) = mpsc::channel::<OutPacket>(64);
|
let (voice_out_tx, voice_out_rx) = mpsc::channel::<OutPacket>(64);
|
||||||
@@ -347,9 +376,9 @@ impl ProtocolClient {
|
|||||||
self.tx
|
self.tx
|
||||||
.send(Request::Snapshot(tx))
|
.send(Request::Snapshot(tx))
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))?;
|
.map_err(|_| ProtocolError::lost("connection task is gone"))?;
|
||||||
rx.await
|
rx.await
|
||||||
.map_err(|_| ProtocolError::Lost("snapshot reply dropped".to_string()))?
|
.map_err(|_| ProtocolError::lost("snapshot reply dropped"))?
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch richer profile and live connection details for one online client.
|
/// Fetch richer profile and live connection details for one online client.
|
||||||
@@ -361,9 +390,29 @@ impl ProtocolClient {
|
|||||||
reply: tx,
|
reply: tx,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))?;
|
.map_err(|_| ProtocolError::lost("connection task is gone"))?;
|
||||||
rx.await
|
rx.await
|
||||||
.map_err(|_| ProtocolError::Lost("client_profile reply dropped".to_string()))?
|
.map_err(|_| ProtocolError::lost("client_profile reply dropped"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download_file(&self, path: String) -> Result<Vec<u8>, ProtocolError> {
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
self.tx
|
||||||
|
.send(Request::DownloadFile { path, reply: tx })
|
||||||
|
.await
|
||||||
|
.map_err(|_| ProtocolError::lost("connection task is gone"))?;
|
||||||
|
rx.await
|
||||||
|
.map_err(|_| ProtocolError::lost("download_file reply dropped"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download the current avatar bytes for a TeamSpeak client UID.
|
||||||
|
pub async fn download_avatar(&self, client_uid: &str) -> Result<Vec<u8>, ProtocolError> {
|
||||||
|
self.download_file(avatar_download_path(client_uid)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download the current channel/server icon bytes for an icon id.
|
||||||
|
pub async fn download_icon(&self, icon_id: u64) -> Result<Vec<u8>, ProtocolError> {
|
||||||
|
self.download_file(icon_download_path(icon_id)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Disconnect cleanly. Blocks until the task exits.
|
/// Disconnect cleanly. Blocks until the task exits.
|
||||||
@@ -402,9 +451,9 @@ impl ProtocolClient {
|
|||||||
reply: tx,
|
reply: tx,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))?;
|
.map_err(|_| ProtocolError::lost("connection task is gone"))?;
|
||||||
rx.await
|
rx.await
|
||||||
.map_err(|_| ProtocolError::Lost("move_to_channel reply dropped".to_string()))?
|
.map_err(|_| ProtocolError::lost("move_to_channel reply dropped"))?
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Queue a move command and return once it has been accepted by
|
/// Queue a move command and return once it has been accepted by
|
||||||
@@ -421,7 +470,7 @@ impl ProtocolClient {
|
|||||||
password,
|
password,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))
|
.map_err(|_| ProtocolError::lost("connection task is gone"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update mute state on our own client. Pass `Some(_)` for the
|
/// Update mute state on our own client. Pass `Some(_)` for the
|
||||||
@@ -439,9 +488,9 @@ impl ProtocolClient {
|
|||||||
reply: tx,
|
reply: tx,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))?;
|
.map_err(|_| ProtocolError::lost("connection task is gone"))?;
|
||||||
rx.await
|
rx.await
|
||||||
.map_err(|_| ProtocolError::Lost("set_muted reply dropped".to_string()))?
|
.map_err(|_| ProtocolError::lost("set_muted reply dropped"))?
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sender for outbound voice packets. Clone freely.
|
/// Sender for outbound voice packets. Clone freely.
|
||||||
@@ -534,17 +583,21 @@ impl ProtocolClient {
|
|||||||
message: String,
|
message: String,
|
||||||
target: MessageTarget,
|
target: MessageTarget,
|
||||||
) -> Result<(), ProtocolError> {
|
) -> Result<(), ProtocolError> {
|
||||||
|
let validated = match target {
|
||||||
|
MessageTarget::Poke(_) => crate::dto::validate_poke_message(&message),
|
||||||
|
_ => crate::dto::validate_message(&message),
|
||||||
|
};
|
||||||
let (tx, rx) = oneshot::channel();
|
let (tx, rx) = oneshot::channel();
|
||||||
self.tx
|
self.tx
|
||||||
.send(Request::SendTextMessage {
|
.send(Request::SendTextMessage {
|
||||||
message,
|
message: validated,
|
||||||
target,
|
target,
|
||||||
reply: tx,
|
reply: tx,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))?;
|
.map_err(|_| ProtocolError::lost("connection task is gone"))?;
|
||||||
rx.await
|
rx.await
|
||||||
.map_err(|_| ProtocolError::Lost("send_text_message reply dropped".to_string()))?
|
.map_err(|_| ProtocolError::lost("send_text_message reply dropped"))?
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -713,6 +766,7 @@ async fn connection_task(
|
|||||||
// deadline so a server that never replies doesn't leak the
|
// deadline so a server that never replies doesn't leak the
|
||||||
// reply channel — at most 3 s of pending state per move.
|
// reply channel — at most 3 s of pending state per move.
|
||||||
let mut pending_moves: PendingMoves = HashMap::new();
|
let mut pending_moves: PendingMoves = HashMap::new();
|
||||||
|
let mut pending_downloads: PendingDownloads = HashMap::new();
|
||||||
let mut voice_activity: HashMap<u64, Instant> = HashMap::new();
|
let mut voice_activity: HashMap<u64, Instant> = HashMap::new();
|
||||||
let mut poke_limiter = PokeLimiter::new();
|
let mut poke_limiter = PokeLimiter::new();
|
||||||
|
|
||||||
@@ -737,6 +791,12 @@ async fn connection_task(
|
|||||||
StreamItem::Audio(buf) => {
|
StreamItem::Audio(buf) => {
|
||||||
handle_audio_stream_item(&channels.voice_in, &mut voice_activity, buf).await;
|
handle_audio_stream_item(&channels.voice_in, &mut voice_activity, buf).await;
|
||||||
}
|
}
|
||||||
|
StreamItem::FileDownload(handle, result) => {
|
||||||
|
handle_download_stream_item(&mut pending_downloads, handle, result).await;
|
||||||
|
}
|
||||||
|
StreamItem::FiletransferFailed(handle, error) => {
|
||||||
|
handle_download_failure(&mut pending_downloads, handle, error);
|
||||||
|
}
|
||||||
other => handle_non_audio_stream_item(
|
other => handle_non_audio_stream_item(
|
||||||
&con,
|
&con,
|
||||||
other,
|
other,
|
||||||
@@ -843,12 +903,25 @@ async fn connection_task(
|
|||||||
client_id,
|
client_id,
|
||||||
&channels,
|
&channels,
|
||||||
&mut pending_moves,
|
&mut pending_moves,
|
||||||
|
&mut pending_downloads,
|
||||||
&mut voice_activity,
|
&mut voice_activity,
|
||||||
&mut poke_limiter,
|
&mut poke_limiter,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let _ = reply.send(r);
|
let _ = reply.send(r);
|
||||||
}
|
}
|
||||||
|
Ok(Request::DownloadFile { path, reply }) => {
|
||||||
|
match con.download_file(TsChannelId(0), &path, None, None) {
|
||||||
|
Ok(handle) => {
|
||||||
|
pending_downloads.insert(handle, reply);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = reply.send(Err(ProtocolError::FileTransfer(format!(
|
||||||
|
"start download {path}: {e}"
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(Request::Disconnect(reply)) => {
|
Ok(Request::Disconnect(reply)) => {
|
||||||
let _ = con.disconnect(DisconnectOptions::new());
|
let _ = con.disconnect(DisconnectOptions::new());
|
||||||
bounded_drain_stream(con.events(), DISCONNECT_EVENT_DRAIN_TIMEOUT).await;
|
bounded_drain_stream(con.events(), DISCONNECT_EVENT_DRAIN_TIMEOUT).await;
|
||||||
@@ -1024,6 +1097,47 @@ fn handle_non_audio_stream_item(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn handle_download_stream_item(
|
||||||
|
pending_downloads: &mut PendingDownloads,
|
||||||
|
handle: FiletransferHandle,
|
||||||
|
result: FileDownloadResult,
|
||||||
|
) {
|
||||||
|
if let Some(reply) = pending_downloads.remove(&handle) {
|
||||||
|
let _ = reply.send(read_download_bytes(result).await);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_download_failure(
|
||||||
|
pending_downloads: &mut PendingDownloads,
|
||||||
|
handle: FiletransferHandle,
|
||||||
|
error: tsclientlib::Error,
|
||||||
|
) {
|
||||||
|
if let Some(reply) = pending_downloads.remove(&handle) {
|
||||||
|
let _ = reply.send(Err(ProtocolError::FileTransfer(error.to_string())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_DOWNLOAD_SIZE: u64 = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
async fn read_download_bytes(result: FileDownloadResult) -> Result<Vec<u8>, ProtocolError> {
|
||||||
|
if result.size > MAX_DOWNLOAD_SIZE {
|
||||||
|
return Err(ProtocolError::FileTransfer(format!(
|
||||||
|
"download too large: {} bytes (max {})",
|
||||||
|
result.size, MAX_DOWNLOAD_SIZE
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let size = usize::try_from(result.size).map_err(|_| {
|
||||||
|
ProtocolError::FileTransfer(format!("download too large to buffer: {} bytes", result.size))
|
||||||
|
})?;
|
||||||
|
let mut stream = result.stream;
|
||||||
|
let mut bytes = vec![0_u8; size];
|
||||||
|
stream
|
||||||
|
.read_exact(&mut bytes)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ProtocolError::FileTransfer(e.to_string()))?;
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
async fn resolve_server_socket(address: &str) -> Result<SocketAddr, ProtocolError> {
|
async fn resolve_server_socket(address: &str) -> Result<SocketAddr, ProtocolError> {
|
||||||
let resolver = ChanoraResolver::new().map_err(|err| ProtocolError::DnsFailed {
|
let resolver = ChanoraResolver::new().map_err(|err| ProtocolError::DnsFailed {
|
||||||
host: address.to_string(),
|
host: address.to_string(),
|
||||||
@@ -1062,7 +1176,7 @@ fn move_self_to(
|
|||||||
) -> Result<MessageHandle, ProtocolError> {
|
) -> Result<MessageHandle, ProtocolError> {
|
||||||
let state = con
|
let state = con
|
||||||
.get_state()
|
.get_state()
|
||||||
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
|
.map_err(|e| ProtocolError::backend_ctx("get_state", e))?;
|
||||||
let own_id = state.own_client;
|
let own_id = state.own_client;
|
||||||
let own_client = state
|
let own_client = state
|
||||||
.clients
|
.clients
|
||||||
@@ -1075,7 +1189,7 @@ fn move_self_to(
|
|||||||
}
|
}
|
||||||
let handle = part
|
let handle = part
|
||||||
.send_with_result(con)
|
.send_with_result(con)
|
||||||
.map_err(|e| ProtocolError::Backend(format!("client_move send: {e}")))?;
|
.map_err(|e| ProtocolError::backend_ctx("client_move send", e))?;
|
||||||
info!(target: "chanora_protocol", channel_id, "client_move sent");
|
info!(target: "chanora_protocol", channel_id, "client_move sent");
|
||||||
Ok(handle)
|
Ok(handle)
|
||||||
}
|
}
|
||||||
@@ -1093,7 +1207,7 @@ fn set_self_muted(
|
|||||||
let part = {
|
let part = {
|
||||||
let state = con
|
let state = con
|
||||||
.get_state()
|
.get_state()
|
||||||
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
|
.map_err(|e| ProtocolError::backend_ctx("get_state", e))?;
|
||||||
let mut p = state.client_update();
|
let mut p = state.client_update();
|
||||||
if let Some(v) = input {
|
if let Some(v) = input {
|
||||||
p = p.set_input_muted(v);
|
p = p.set_input_muted(v);
|
||||||
@@ -1104,7 +1218,7 @@ fn set_self_muted(
|
|||||||
p
|
p
|
||||||
};
|
};
|
||||||
part.send(con)
|
part.send(con)
|
||||||
.map_err(|e| ProtocolError::Backend(format!("client_update send: {e}")))?;
|
.map_err(|e| ProtocolError::backend_ctx("client_update send", e))?;
|
||||||
info!(target: "chanora_protocol", ?input, ?output, "client_update sent");
|
info!(target: "chanora_protocol", ?input, ?output, "client_update sent");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1130,22 +1244,22 @@ fn send_text_message(
|
|||||||
MessageTarget::Client(client_id) => {
|
MessageTarget::Client(client_id) => {
|
||||||
let state = con
|
let state = con
|
||||||
.get_state()
|
.get_state()
|
||||||
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
|
.map_err(|e| ProtocolError::backend_ctx("get_state", e))?;
|
||||||
let client = find_client_by_id(state.clients.values(), client_id)?;
|
let client = find_client_by_id(state.clients.values(), client_id)?;
|
||||||
client
|
client
|
||||||
.send_textmessage(message)
|
.send_textmessage(message)
|
||||||
.send(con)
|
.send(con)
|
||||||
.map_err(|e| ProtocolError::Backend(format!("send_textmessage(client): {e}")))?;
|
.map_err(|e| ProtocolError::backend_ctx("send_textmessage(client)", e))?;
|
||||||
}
|
}
|
||||||
MessageTarget::Poke(client_id) => {
|
MessageTarget::Poke(client_id) => {
|
||||||
let state = con
|
let state = con
|
||||||
.get_state()
|
.get_state()
|
||||||
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
|
.map_err(|e| ProtocolError::backend_ctx("get_state", e))?;
|
||||||
let client = find_client_by_id(state.clients.values(), client_id)?;
|
let client = find_client_by_id(state.clients.values(), client_id)?;
|
||||||
client
|
client
|
||||||
.poke(message)
|
.poke(message)
|
||||||
.send(con)
|
.send(con)
|
||||||
.map_err(|e| ProtocolError::Backend(format!("poke: {e}")))?;
|
.map_err(|e| ProtocolError::backend_ctx("poke", e))?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
info!(target: "chanora_protocol", len = message.len(), ?target, "text message sent");
|
info!(target: "chanora_protocol", len = message.len(), ?target, "text message sent");
|
||||||
@@ -1166,7 +1280,7 @@ fn send_text_to_mode(
|
|||||||
message: message.into(),
|
message: message.into(),
|
||||||
}))
|
}))
|
||||||
.send(con)
|
.send(con)
|
||||||
.map_err(|e| ProtocolError::Backend(format!("send_textmessage({label}): {e}")))
|
.map_err(|e| ProtocolError::backend_ctx(format!("send_textmessage({label})"), e))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_client_profile(
|
async fn fetch_client_profile(
|
||||||
@@ -1174,6 +1288,7 @@ async fn fetch_client_profile(
|
|||||||
client_id: u64,
|
client_id: u64,
|
||||||
channels: &EventChannels,
|
channels: &EventChannels,
|
||||||
pending_moves: &mut PendingMoves,
|
pending_moves: &mut PendingMoves,
|
||||||
|
pending_downloads: &mut PendingDownloads,
|
||||||
voice_activity: &mut HashMap<u64, Instant>,
|
voice_activity: &mut HashMap<u64, Instant>,
|
||||||
poke_limiter: &mut PokeLimiter,
|
poke_limiter: &mut PokeLimiter,
|
||||||
) -> Result<ClientProfile, ProtocolError> {
|
) -> Result<ClientProfile, ProtocolError> {
|
||||||
@@ -1190,7 +1305,7 @@ async fn fetch_client_profile(
|
|||||||
) = {
|
) = {
|
||||||
let state = con
|
let state = con
|
||||||
.get_state()
|
.get_state()
|
||||||
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
|
.map_err(|e| ProtocolError::backend_ctx("get_state", e))?;
|
||||||
let client = state
|
let client = state
|
||||||
.clients
|
.clients
|
||||||
.get(&target_id)
|
.get(&target_id)
|
||||||
@@ -1220,6 +1335,7 @@ async fn fetch_client_profile(
|
|||||||
build_command("servergrouplist", &[], &[]),
|
build_command("servergrouplist", &[], &[]),
|
||||||
channels,
|
channels,
|
||||||
pending_moves,
|
pending_moves,
|
||||||
|
pending_downloads,
|
||||||
voice_activity,
|
voice_activity,
|
||||||
poke_limiter,
|
poke_limiter,
|
||||||
)
|
)
|
||||||
@@ -1231,6 +1347,7 @@ async fn fetch_client_profile(
|
|||||||
build_command("channelgrouplist", &[], &[]),
|
build_command("channelgrouplist", &[], &[]),
|
||||||
channels,
|
channels,
|
||||||
pending_moves,
|
pending_moves,
|
||||||
|
pending_downloads,
|
||||||
voice_activity,
|
voice_activity,
|
||||||
poke_limiter,
|
poke_limiter,
|
||||||
)
|
)
|
||||||
@@ -1246,6 +1363,7 @@ async fn fetch_client_profile(
|
|||||||
),
|
),
|
||||||
channels,
|
channels,
|
||||||
pending_moves,
|
pending_moves,
|
||||||
|
pending_downloads,
|
||||||
voice_activity,
|
voice_activity,
|
||||||
poke_limiter,
|
poke_limiter,
|
||||||
)
|
)
|
||||||
@@ -1265,6 +1383,7 @@ async fn fetch_client_profile(
|
|||||||
build_command("getconnectioninfo", &[("clid", client_id.to_string())], &[]),
|
build_command("getconnectioninfo", &[("clid", client_id.to_string())], &[]),
|
||||||
channels,
|
channels,
|
||||||
pending_moves,
|
pending_moves,
|
||||||
|
pending_downloads,
|
||||||
voice_activity,
|
voice_activity,
|
||||||
poke_limiter,
|
poke_limiter,
|
||||||
)
|
)
|
||||||
@@ -1285,6 +1404,7 @@ async fn fetch_client_profile(
|
|||||||
database_id,
|
database_id,
|
||||||
channels,
|
channels,
|
||||||
pending_moves,
|
pending_moves,
|
||||||
|
pending_downloads,
|
||||||
voice_activity,
|
voice_activity,
|
||||||
poke_limiter,
|
poke_limiter,
|
||||||
)
|
)
|
||||||
@@ -1296,7 +1416,7 @@ async fn fetch_client_profile(
|
|||||||
|
|
||||||
let state = con
|
let state = con
|
||||||
.get_state()
|
.get_state()
|
||||||
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
|
.map_err(|e| ProtocolError::backend_ctx("get_state", e))?;
|
||||||
let client = state
|
let client = state
|
||||||
.clients
|
.clients
|
||||||
.get(&target_id)
|
.get(&target_id)
|
||||||
@@ -1448,12 +1568,13 @@ async fn request_messages(
|
|||||||
command: OutCommand,
|
command: OutCommand,
|
||||||
channels: &EventChannels,
|
channels: &EventChannels,
|
||||||
pending_moves: &mut PendingMoves,
|
pending_moves: &mut PendingMoves,
|
||||||
|
pending_downloads: &mut PendingDownloads,
|
||||||
voice_activity: &mut HashMap<u64, Instant>,
|
voice_activity: &mut HashMap<u64, Instant>,
|
||||||
poke_limiter: &mut PokeLimiter,
|
poke_limiter: &mut PokeLimiter,
|
||||||
) -> Result<Vec<InMessage>, ProtocolError> {
|
) -> Result<Vec<InMessage>, ProtocolError> {
|
||||||
let handle = command
|
let handle = command
|
||||||
.send_with_result(con)
|
.send_with_result(con)
|
||||||
.map_err(|e| ProtocolError::Backend(format!("send command: {e}")))?;
|
.map_err(|e| ProtocolError::backend_ctx("send command", e))?;
|
||||||
let mut messages = Vec::new();
|
let mut messages = Vec::new();
|
||||||
let deadline = Instant::now() + PROFILE_REFRESH_RESULT_TIMEOUT;
|
let deadline = Instant::now() + PROFILE_REFRESH_RESULT_TIMEOUT;
|
||||||
loop {
|
loop {
|
||||||
@@ -1485,6 +1606,12 @@ async fn request_messages(
|
|||||||
StreamItem::Audio(buf) => {
|
StreamItem::Audio(buf) => {
|
||||||
handle_audio_stream_item(&channels.voice_in, voice_activity, buf).await;
|
handle_audio_stream_item(&channels.voice_in, voice_activity, buf).await;
|
||||||
}
|
}
|
||||||
|
StreamItem::FileDownload(handle, result) => {
|
||||||
|
handle_download_stream_item(pending_downloads, handle, result).await;
|
||||||
|
}
|
||||||
|
StreamItem::FiletransferFailed(handle, error) => {
|
||||||
|
handle_download_failure(pending_downloads, handle, error);
|
||||||
|
}
|
||||||
other => handle_non_audio_stream_item(
|
other => handle_non_audio_stream_item(
|
||||||
con,
|
con,
|
||||||
other,
|
other,
|
||||||
@@ -1503,6 +1630,7 @@ async fn request_client_db_info(
|
|||||||
dbid: tsclientlib::ClientDbId,
|
dbid: tsclientlib::ClientDbId,
|
||||||
channels: &EventChannels,
|
channels: &EventChannels,
|
||||||
pending_moves: &mut PendingMoves,
|
pending_moves: &mut PendingMoves,
|
||||||
|
pending_downloads: &mut PendingDownloads,
|
||||||
voice_activity: &mut HashMap<u64, Instant>,
|
voice_activity: &mut HashMap<u64, Instant>,
|
||||||
poke_limiter: &mut PokeLimiter,
|
poke_limiter: &mut PokeLimiter,
|
||||||
) -> Result<InClientDbInfoPart, ProtocolError> {
|
) -> Result<InClientDbInfoPart, ProtocolError> {
|
||||||
@@ -1511,6 +1639,7 @@ async fn request_client_db_info(
|
|||||||
build_command("clientdbinfo", &[("cldbid", dbid.0.to_string())], &[]),
|
build_command("clientdbinfo", &[("cldbid", dbid.0.to_string())], &[]),
|
||||||
channels,
|
channels,
|
||||||
pending_moves,
|
pending_moves,
|
||||||
|
pending_downloads,
|
||||||
voice_activity,
|
voice_activity,
|
||||||
poke_limiter,
|
poke_limiter,
|
||||||
)
|
)
|
||||||
@@ -1568,6 +1697,14 @@ fn uid_to_avatar_path(uid_b64: &str) -> String {
|
|||||||
rendered
|
rendered
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn avatar_download_path(client_uid: &str) -> String {
|
||||||
|
format!("/avatar_{}", uid_to_avatar_path(client_uid))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn icon_download_path(icon_id: u64) -> String {
|
||||||
|
format!("/icon_{icon_id}")
|
||||||
|
}
|
||||||
|
|
||||||
fn find_client_by_id<'a>(
|
fn find_client_by_id<'a>(
|
||||||
clients: impl IntoIterator<Item = &'a Client>,
|
clients: impl IntoIterator<Item = &'a Client>,
|
||||||
client_id: u64,
|
client_id: u64,
|
||||||
@@ -1691,7 +1828,7 @@ fn build_snapshot(
|
|||||||
) -> Result<ServerSnapshot, ProtocolError> {
|
) -> Result<ServerSnapshot, ProtocolError> {
|
||||||
let state: &data::Connection = con
|
let state: &data::Connection = con
|
||||||
.get_state()
|
.get_state()
|
||||||
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
|
.map_err(|e| ProtocolError::backend_ctx("get_state", e))?;
|
||||||
|
|
||||||
// TeamSpeak channel ordering: the `order` field on a channel is
|
// TeamSpeak channel ordering: the `order` field on a channel is
|
||||||
// NOT a numeric rank but the id of the channel that should
|
// NOT a numeric rank but the id of the channel that should
|
||||||
@@ -1951,10 +2088,11 @@ const _: () = {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
bounded_drain_stream, client_profile_refresh_plan, drain_voice_packets_for_tick,
|
avatar_download_path, bounded_drain_stream, client_profile_refresh_plan,
|
||||||
is_server_query_client_type, send_with_timeout, server_socket_from_config,
|
drain_voice_packets_for_tick, icon_download_path, is_server_query_client_type,
|
||||||
sort_channels_tree_by, std_duration_millis, ConnectConfig, ProtocolClient, Request,
|
send_with_timeout, server_socket_from_config, sort_channels_tree_by,
|
||||||
SendTimeoutError, DISCONNECT_REPLY_TIMEOUT,
|
std_duration_millis, ConnectConfig, ProtocolClient, Request, SendTimeoutError,
|
||||||
|
DISCONNECT_REPLY_TIMEOUT,
|
||||||
};
|
};
|
||||||
use futures::stream;
|
use futures::stream;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -2052,6 +2190,16 @@ mod tests {
|
|||||||
assert!(plan.needs_channel_groups);
|
assert!(plan.needs_channel_groups);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn avatar_download_path_uses_uid_hex_encoding() {
|
||||||
|
assert_eq!(avatar_download_path("AQID"), "/avatar_abacad");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn icon_download_path_uses_unsigned_icon_id() {
|
||||||
|
assert_eq!(icon_download_path(42), "/icon_42");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn channel_sort_linked_list_under_one_parent() {
|
fn channel_sort_linked_list_under_one_parent() {
|
||||||
// Server emits four root-level channels in arbitrary HashMap
|
// Server emits four root-level channels in arbitrary HashMap
|
||||||
|
|||||||
@@ -5,6 +5,58 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
pub use crate::poke_limiter::PokeStrength;
|
pub use crate::poke_limiter::PokeStrength;
|
||||||
|
|
||||||
|
/// TS3 protocol limits for outbound fields.
|
||||||
|
pub const MAX_NICKNAME_LEN: usize = 30;
|
||||||
|
pub const MAX_MESSAGE_LEN: usize = 1024;
|
||||||
|
pub const MAX_POKE_LEN: usize = 100;
|
||||||
|
pub const MAX_CHANNEL_NAME_LEN: usize = 40;
|
||||||
|
|
||||||
|
/// Truncate `s` to at most `max_len` UTF-8 characters, splitting at a
|
||||||
|
/// char boundary if needed. Returns the (possibly shortened) string.
|
||||||
|
pub fn validate_and_truncate(s: &str, max_len: usize) -> String {
|
||||||
|
if s.len() <= max_len {
|
||||||
|
return s.to_string();
|
||||||
|
}
|
||||||
|
// Find the last char boundary at or before max_len.
|
||||||
|
let mut end = max_len;
|
||||||
|
while !s.is_char_boundary(end) {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
s[..end].to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a nickname: trim whitespace, reject empty, truncate to
|
||||||
|
/// [`MAX_NICKNAME_LEN`].
|
||||||
|
pub fn validate_nickname(nick: &str) -> Result<String, &'static str> {
|
||||||
|
let trimmed = nick.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err("nickname must not be empty");
|
||||||
|
}
|
||||||
|
Ok(validate_and_truncate(trimmed, MAX_NICKNAME_LEN))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a chat message: truncate to [`MAX_MESSAGE_LEN`]. Empty
|
||||||
|
/// messages are allowed (poke-without-message is valid per DEC-037).
|
||||||
|
pub fn validate_message(msg: &str) -> String {
|
||||||
|
validate_and_truncate(msg, MAX_MESSAGE_LEN)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a poke message: truncate to [`MAX_POKE_LEN`]. Empty
|
||||||
|
/// messages are allowed.
|
||||||
|
pub fn validate_poke_message(msg: &str) -> String {
|
||||||
|
validate_and_truncate(msg, MAX_POKE_LEN)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a channel name: trim whitespace, reject empty, truncate
|
||||||
|
/// to [`MAX_CHANNEL_NAME_LEN`].
|
||||||
|
pub fn validate_channel_name(name: &str) -> Result<String, &'static str> {
|
||||||
|
let trimmed = name.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err("channel name must not be empty");
|
||||||
|
}
|
||||||
|
Ok(validate_and_truncate(trimmed, MAX_CHANNEL_NAME_LEN))
|
||||||
|
}
|
||||||
|
|
||||||
/// Opaque server-side channel identifier. Internal representation is
|
/// Opaque server-side channel identifier. Internal representation is
|
||||||
/// the upstream u64 but callers must treat it as opaque.
|
/// the upstream u64 but callers must treat it as opaque.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
@@ -262,3 +314,550 @@ pub enum ProtocolDelta {
|
|||||||
needed_talk_power: Option<i32>,
|
needed_talk_power: Option<i32>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn roundtrip_json<T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug>(
|
||||||
|
value: &T,
|
||||||
|
) {
|
||||||
|
let json = serde_json::to_string(value).expect("serialize");
|
||||||
|
let back: T = serde_json::from_str(&json).expect("deserialize");
|
||||||
|
assert_eq!(&back, value, "roundtrip failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn channel_id_serde_roundtrip() {
|
||||||
|
roundtrip_json(&ChannelId(0));
|
||||||
|
roundtrip_json(&ChannelId(1));
|
||||||
|
roundtrip_json(&ChannelId(u64::MAX));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn client_id_serde_roundtrip() {
|
||||||
|
roundtrip_json(&ClientId(0));
|
||||||
|
roundtrip_json(&ClientId(42));
|
||||||
|
roundtrip_json(&ClientId(u64::MAX));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn channel_id_root_is_zero() {
|
||||||
|
assert_eq!(ChannelId::ROOT, ChannelId(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn channel_info_serde_roundtrip() {
|
||||||
|
let info = ChannelInfo {
|
||||||
|
id: ChannelId(1),
|
||||||
|
parent: ChannelId(0),
|
||||||
|
name: "General".to_string(),
|
||||||
|
order: 0,
|
||||||
|
has_password: false,
|
||||||
|
needed_talk_power: None,
|
||||||
|
};
|
||||||
|
roundtrip_json(&info);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn channel_info_with_all_fields() {
|
||||||
|
let info = ChannelInfo {
|
||||||
|
id: ChannelId(99),
|
||||||
|
parent: ChannelId(5),
|
||||||
|
name: "AFK".to_string(),
|
||||||
|
order: -1,
|
||||||
|
has_password: true,
|
||||||
|
needed_talk_power: Some(75),
|
||||||
|
};
|
||||||
|
roundtrip_json(&info);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn channel_info_empty_name() {
|
||||||
|
let info = ChannelInfo {
|
||||||
|
id: ChannelId(1),
|
||||||
|
parent: ChannelId(0),
|
||||||
|
name: String::new(),
|
||||||
|
order: 0,
|
||||||
|
has_password: false,
|
||||||
|
needed_talk_power: None,
|
||||||
|
};
|
||||||
|
roundtrip_json(&info);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn channel_info_unicode_name() {
|
||||||
|
let info = ChannelInfo {
|
||||||
|
id: ChannelId(1),
|
||||||
|
parent: ChannelId(0),
|
||||||
|
name: "🎮 Spielsaal 🎮".to_string(),
|
||||||
|
order: 0,
|
||||||
|
has_password: false,
|
||||||
|
needed_talk_power: None,
|
||||||
|
};
|
||||||
|
roundtrip_json(&info);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn message_target_serde_roundtrip() {
|
||||||
|
roundtrip_json(&MessageTarget::Server);
|
||||||
|
roundtrip_json(&MessageTarget::Channel);
|
||||||
|
roundtrip_json(&MessageTarget::Client(12345));
|
||||||
|
roundtrip_json(&MessageTarget::Poke(67890));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn message_target_json_shape() {
|
||||||
|
let json = serde_json::to_string(&MessageTarget::Server).unwrap();
|
||||||
|
assert_eq!(json, "\"Server\"");
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&MessageTarget::Client(42)).unwrap();
|
||||||
|
assert!(json.contains("\"Client\""));
|
||||||
|
assert!(json.contains("42"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chat_message_serde_roundtrip() {
|
||||||
|
let msg = ChatMessage {
|
||||||
|
sender_id: ClientId(1),
|
||||||
|
sender_name: "Alice".to_string(),
|
||||||
|
message: "Hello world".to_string(),
|
||||||
|
target: MessageTarget::Channel,
|
||||||
|
poke_strength: None,
|
||||||
|
};
|
||||||
|
roundtrip_json(&msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chat_message_with_poke_strength() {
|
||||||
|
let msg = ChatMessage {
|
||||||
|
sender_id: ClientId(5),
|
||||||
|
sender_name: "Bob".to_string(),
|
||||||
|
message: "".to_string(),
|
||||||
|
target: MessageTarget::Poke(99),
|
||||||
|
poke_strength: Some(PokeStrength::Strong),
|
||||||
|
};
|
||||||
|
roundtrip_json(&msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chat_message_unicode_content() {
|
||||||
|
let msg = ChatMessage {
|
||||||
|
sender_id: ClientId(1),
|
||||||
|
sender_name: "日本語ネーム".to_string(),
|
||||||
|
message: "🎉 こんにちは世界 🌍".to_string(),
|
||||||
|
target: MessageTarget::Server,
|
||||||
|
poke_strength: None,
|
||||||
|
};
|
||||||
|
roundtrip_json(&msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_activity_serde_roundtrip() {
|
||||||
|
let act = ServerActivity {
|
||||||
|
message: "User joined".to_string(),
|
||||||
|
};
|
||||||
|
roundtrip_json(&act);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_activity_empty_message() {
|
||||||
|
let act = ServerActivity {
|
||||||
|
message: String::new(),
|
||||||
|
};
|
||||||
|
roundtrip_json(&act);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn client_info_serde_roundtrip() {
|
||||||
|
let info = ClientInfo {
|
||||||
|
id: ClientId(1),
|
||||||
|
channel: ChannelId(2),
|
||||||
|
name: "Player".to_string(),
|
||||||
|
input_muted: false,
|
||||||
|
output_muted: true,
|
||||||
|
is_speaking: false,
|
||||||
|
is_server_query: false,
|
||||||
|
talk_power: 0,
|
||||||
|
talk_power_granted: false,
|
||||||
|
};
|
||||||
|
roundtrip_json(&info);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn client_info_server_query_with_talk_power() {
|
||||||
|
let info = ClientInfo {
|
||||||
|
id: ClientId(100),
|
||||||
|
channel: ChannelId(3),
|
||||||
|
name: "Bot".to_string(),
|
||||||
|
input_muted: true,
|
||||||
|
output_muted: true,
|
||||||
|
is_speaking: false,
|
||||||
|
is_server_query: true,
|
||||||
|
talk_power: 75,
|
||||||
|
talk_power_granted: true,
|
||||||
|
};
|
||||||
|
roundtrip_json(&info);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_snapshot_serde_roundtrip() {
|
||||||
|
let snap = ServerSnapshot {
|
||||||
|
server_name: "Test Server".to_string(),
|
||||||
|
welcome_message: "Welcome!".to_string(),
|
||||||
|
platform: "Linux".to_string(),
|
||||||
|
version: "3.13.7".to_string(),
|
||||||
|
channels: vec![
|
||||||
|
ChannelInfo {
|
||||||
|
id: ChannelId(1),
|
||||||
|
parent: ChannelId(0),
|
||||||
|
name: "Root".to_string(),
|
||||||
|
order: 0,
|
||||||
|
has_password: false,
|
||||||
|
needed_talk_power: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
clients: vec![
|
||||||
|
ClientInfo {
|
||||||
|
id: ClientId(1),
|
||||||
|
channel: ChannelId(1),
|
||||||
|
name: "User1".to_string(),
|
||||||
|
input_muted: false,
|
||||||
|
output_muted: false,
|
||||||
|
is_speaking: false,
|
||||||
|
is_server_query: false,
|
||||||
|
talk_power: 0,
|
||||||
|
talk_power_granted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
own_client_id: 1,
|
||||||
|
};
|
||||||
|
roundtrip_json(&snap);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_snapshot_empty_channels_and_clients() {
|
||||||
|
let snap = ServerSnapshot {
|
||||||
|
server_name: String::new(),
|
||||||
|
welcome_message: String::new(),
|
||||||
|
platform: String::new(),
|
||||||
|
version: String::new(),
|
||||||
|
channels: vec![],
|
||||||
|
clients: vec![],
|
||||||
|
own_client_id: 0,
|
||||||
|
};
|
||||||
|
roundtrip_json(&snap);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn client_profile_serde_roundtrip() {
|
||||||
|
let profile = ClientProfile {
|
||||||
|
id: ClientId(1),
|
||||||
|
channel: ChannelId(2),
|
||||||
|
name: "Player".to_string(),
|
||||||
|
unique_id: "abc123".to_string(),
|
||||||
|
database_id: Some(42),
|
||||||
|
country_code: "DE".to_string(),
|
||||||
|
description: String::new(),
|
||||||
|
version: "3.5.0".to_string(),
|
||||||
|
platform: "Windows".to_string(),
|
||||||
|
created_unix_seconds: Some(1609459200),
|
||||||
|
last_connected_unix_seconds: Some(1700000000),
|
||||||
|
connections_total: Some(100),
|
||||||
|
online_seconds: Some(3600),
|
||||||
|
idle_milliseconds: Some(500),
|
||||||
|
ping_milliseconds: Some(42),
|
||||||
|
ping_deviation_milliseconds: Some(5),
|
||||||
|
client_address: String::new(),
|
||||||
|
server_groups: vec!["Admin".to_string(), "Mod".to_string()],
|
||||||
|
channel_group: "Channel Admin".to_string(),
|
||||||
|
avatar_path: String::new(),
|
||||||
|
bytes_downloaded_month: Some(1024),
|
||||||
|
bytes_uploaded_month: Some(512),
|
||||||
|
bytes_downloaded_total: Some(4096),
|
||||||
|
bytes_uploaded_total: Some(2048),
|
||||||
|
packet_loss_client_to_server_total: Some(0.01),
|
||||||
|
packet_loss_server_to_client_total: Some(0.02),
|
||||||
|
};
|
||||||
|
roundtrip_json(&profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn client_profile_minimal_fields() {
|
||||||
|
let profile = ClientProfile {
|
||||||
|
id: ClientId(1),
|
||||||
|
channel: ChannelId(0),
|
||||||
|
name: String::new(),
|
||||||
|
unique_id: String::new(),
|
||||||
|
database_id: None,
|
||||||
|
country_code: String::new(),
|
||||||
|
description: String::new(),
|
||||||
|
version: String::new(),
|
||||||
|
platform: String::new(),
|
||||||
|
created_unix_seconds: None,
|
||||||
|
last_connected_unix_seconds: None,
|
||||||
|
connections_total: None,
|
||||||
|
online_seconds: None,
|
||||||
|
idle_milliseconds: None,
|
||||||
|
ping_milliseconds: None,
|
||||||
|
ping_deviation_milliseconds: None,
|
||||||
|
client_address: String::new(),
|
||||||
|
server_groups: vec![],
|
||||||
|
channel_group: String::new(),
|
||||||
|
avatar_path: String::new(),
|
||||||
|
bytes_downloaded_month: None,
|
||||||
|
bytes_uploaded_month: None,
|
||||||
|
bytes_downloaded_total: None,
|
||||||
|
bytes_uploaded_total: None,
|
||||||
|
packet_loss_client_to_server_total: None,
|
||||||
|
packet_loss_server_to_client_total: None,
|
||||||
|
};
|
||||||
|
roundtrip_json(&profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn protocol_delta_client_moved() {
|
||||||
|
let delta = ProtocolDelta::ClientMoved {
|
||||||
|
client_id: 1,
|
||||||
|
new_channel_id: 2,
|
||||||
|
};
|
||||||
|
roundtrip_json(&delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn protocol_delta_client_joined() {
|
||||||
|
let delta = ProtocolDelta::ClientJoined {
|
||||||
|
client_id: 5,
|
||||||
|
channel_id: 3,
|
||||||
|
name: "NewUser".to_string(),
|
||||||
|
input_muted: false,
|
||||||
|
output_muted: false,
|
||||||
|
is_server_query: false,
|
||||||
|
talk_power: 0,
|
||||||
|
talk_power_granted: false,
|
||||||
|
};
|
||||||
|
roundtrip_json(&delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn protocol_delta_client_left() {
|
||||||
|
let delta = ProtocolDelta::ClientLeft {
|
||||||
|
client_id: 5,
|
||||||
|
name: "Departing".to_string(),
|
||||||
|
};
|
||||||
|
roundtrip_json(&delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn protocol_delta_client_updated() {
|
||||||
|
let delta = ProtocolDelta::ClientUpdated {
|
||||||
|
client_id: 10,
|
||||||
|
input_muted: true,
|
||||||
|
output_muted: false,
|
||||||
|
is_server_query: false,
|
||||||
|
talk_power: 50,
|
||||||
|
talk_power_granted: true,
|
||||||
|
};
|
||||||
|
roundtrip_json(&delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn protocol_delta_channel_added() {
|
||||||
|
let delta = ProtocolDelta::ChannelAdded {
|
||||||
|
id: 7,
|
||||||
|
parent: 1,
|
||||||
|
name: "New Channel".to_string(),
|
||||||
|
order: 5,
|
||||||
|
has_password: true,
|
||||||
|
needed_talk_power: Some(25),
|
||||||
|
};
|
||||||
|
roundtrip_json(&delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn protocol_delta_channel_removed() {
|
||||||
|
let delta = ProtocolDelta::ChannelRemoved { id: 7 };
|
||||||
|
roundtrip_json(&delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn protocol_delta_channel_updated() {
|
||||||
|
let delta = ProtocolDelta::ChannelUpdated {
|
||||||
|
id: 7,
|
||||||
|
name: "Renamed".to_string(),
|
||||||
|
has_password: false,
|
||||||
|
needed_talk_power: None,
|
||||||
|
};
|
||||||
|
roundtrip_json(&delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn protocol_delta_unicode_names() {
|
||||||
|
let delta = ProtocolDelta::ClientJoined {
|
||||||
|
client_id: 1,
|
||||||
|
channel_id: 1,
|
||||||
|
name: "ユーザー".to_string(),
|
||||||
|
input_muted: false,
|
||||||
|
output_muted: false,
|
||||||
|
is_server_query: false,
|
||||||
|
talk_power: 0,
|
||||||
|
talk_power_granted: false,
|
||||||
|
};
|
||||||
|
roundtrip_json(&delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn protocol_delta_boundary_values() {
|
||||||
|
let delta = ProtocolDelta::ChannelAdded {
|
||||||
|
id: u64::MAX,
|
||||||
|
parent: u64::MAX,
|
||||||
|
name: String::new(),
|
||||||
|
order: i64::MIN,
|
||||||
|
has_password: true,
|
||||||
|
needed_talk_power: Some(i32::MAX),
|
||||||
|
};
|
||||||
|
roundtrip_json(&delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn poke_strength_serde_roundtrip() {
|
||||||
|
roundtrip_json(&PokeStrength::Strong);
|
||||||
|
roundtrip_json(&PokeStrength::Suppressed);
|
||||||
|
roundtrip_json(&PokeStrength::SuppressedOverflow);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── validation tests ───────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_and_truncate_short_string_unchanged() {
|
||||||
|
assert_eq!(validate_and_truncate("hello", 10), "hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_and_truncate_exact_boundary() {
|
||||||
|
assert_eq!(validate_and_truncate("12345", 5), "12345");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_and_truncate_truncates_ascii() {
|
||||||
|
assert_eq!(validate_and_truncate("hello world", 5), "hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_and_truncate_respects_char_boundary() {
|
||||||
|
// é is 2 bytes; truncating at byte 1 would panic without
|
||||||
|
// char-boundary logic.
|
||||||
|
let s = "aé";
|
||||||
|
// s.len() == 3 (a=1, é=2). max_len=2 → must drop é.
|
||||||
|
assert_eq!(validate_and_truncate(s, 2), "a");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_and_truncate_emoji_multibyte() {
|
||||||
|
let s = "🎮🎮🎮";
|
||||||
|
// Each emoji is 4 bytes. max_len=5 → only first emoji (4 bytes).
|
||||||
|
assert_eq!(validate_and_truncate(s, 5), "🎮");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_and_truncate_empty() {
|
||||||
|
assert_eq!(validate_and_truncate("", 10), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_nickname_ok() {
|
||||||
|
assert_eq!(validate_nickname("Alice").unwrap(), "Alice");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_nickname_trims_whitespace() {
|
||||||
|
assert_eq!(validate_nickname(" Bob ").unwrap(), "Bob");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_nickname_rejects_empty() {
|
||||||
|
assert!(validate_nickname("").is_err());
|
||||||
|
assert!(validate_nickname(" ").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_nickname_truncates_long() {
|
||||||
|
let long = "A".repeat(100);
|
||||||
|
let result = validate_nickname(&long).unwrap();
|
||||||
|
assert!(result.len() <= MAX_NICKNAME_LEN);
|
||||||
|
assert_eq!(result.len(), MAX_NICKNAME_LEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_message_short_unchanged() {
|
||||||
|
assert_eq!(validate_message("hi"), "hi");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_message_empty_allowed() {
|
||||||
|
assert_eq!(validate_message(""), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_message_truncates_long() {
|
||||||
|
let long = "x".repeat(2000);
|
||||||
|
let result = validate_message(&long);
|
||||||
|
assert!(result.len() <= MAX_MESSAGE_LEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_poke_message_truncates_to_shorter_limit() {
|
||||||
|
let long = "y".repeat(200);
|
||||||
|
let result = validate_poke_message(&long);
|
||||||
|
assert!(result.len() <= MAX_POKE_LEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_poke_message_empty_allowed() {
|
||||||
|
assert_eq!(validate_poke_message(""), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_channel_name_ok() {
|
||||||
|
assert_eq!(validate_channel_name("General").unwrap(), "General");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_channel_name_trims_whitespace() {
|
||||||
|
assert_eq!(validate_channel_name(" AFK ").unwrap(), "AFK");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_channel_name_rejects_empty() {
|
||||||
|
assert!(validate_channel_name("").is_err());
|
||||||
|
assert!(validate_channel_name(" ").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_channel_name_truncates_long() {
|
||||||
|
let long = "C".repeat(100);
|
||||||
|
let result = validate_channel_name(&long).unwrap();
|
||||||
|
assert!(result.len() <= MAX_CHANNEL_NAME_LEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn client_info_equality_and_clone() {
|
||||||
|
let a = ClientId(42);
|
||||||
|
let b = a;
|
||||||
|
assert_eq!(a, b);
|
||||||
|
let c = ClientId(42);
|
||||||
|
assert_eq!(a, c);
|
||||||
|
let d = ClientId(43);
|
||||||
|
assert_ne!(a, d);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn channel_id_hash_consistency() {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
let mut set = HashSet::new();
|
||||||
|
set.insert(ChannelId(1));
|
||||||
|
set.insert(ChannelId(1));
|
||||||
|
set.insert(ChannelId(2));
|
||||||
|
assert_eq!(set.len(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
//! Client-side anti-flood awareness per YaTQA §5.1.
|
||||||
|
//!
|
||||||
|
//! TS3 servers enforce a tick-based point system. Points accumulate
|
||||||
|
//! per operation and decay over time. This tracker provides client-side
|
||||||
|
//! awareness to avoid accidental server bans.
|
||||||
|
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
/// Point costs per operation (from YaTQA §5.2).
|
||||||
|
///
|
||||||
|
/// These are client-side estimates. Server may differ slightly.
|
||||||
|
/// Zero-cost operations are listed for completeness.
|
||||||
|
pub struct FloodCosts;
|
||||||
|
|
||||||
|
impl FloodCosts {
|
||||||
|
// Zero-cost
|
||||||
|
/// Client disconnect (0 points).
|
||||||
|
pub const CLIENT_DISCONNECT: u32 = 0;
|
||||||
|
/// Get client variables (0 points).
|
||||||
|
pub const CLIENT_GET_VARIABLES: u32 = 0;
|
||||||
|
/// Set whisper list (0 points).
|
||||||
|
pub const SET_WHISPER_LIST: u32 = 0;
|
||||||
|
/// File transfer get file list (0 points).
|
||||||
|
pub const FT_GET_FILE_LIST: u32 = 0;
|
||||||
|
/// File transfer init upload (0 points).
|
||||||
|
pub const FT_INIT_UPLOAD: u32 = 0;
|
||||||
|
/// File transfer init download (0 points).
|
||||||
|
pub const FT_INIT_DOWNLOAD: u32 = 0;
|
||||||
|
|
||||||
|
// Low-cost (5)
|
||||||
|
/// Add permission (5 points).
|
||||||
|
pub const PERMISSION_ADD: u32 = 5;
|
||||||
|
/// Remove permission (5 points).
|
||||||
|
pub const PERMISSION_REMOVE: u32 = 5;
|
||||||
|
/// Add server group (5 points).
|
||||||
|
pub const SERVER_GROUP_ADD: u32 = 5;
|
||||||
|
/// Delete server group (5 points).
|
||||||
|
pub const SERVER_GROUP_DELETE: u32 = 5;
|
||||||
|
|
||||||
|
// Medium-cost (10-20)
|
||||||
|
/// Move client to another channel (10 points).
|
||||||
|
pub const CLIENT_MOVE: u32 = 10;
|
||||||
|
/// Send text message (15 points).
|
||||||
|
pub const TEXT_MESSAGE_SEND: u32 = 15;
|
||||||
|
/// Subscribe to channel (158 points).
|
||||||
|
pub const CHANNEL_SUBSCRIBE: u32 = 158;
|
||||||
|
/// Set badges on connect (15 points).
|
||||||
|
pub const SET_BADGES: u32 = 15;
|
||||||
|
|
||||||
|
// High-cost (25)
|
||||||
|
/// Add ban (25 points).
|
||||||
|
pub const BAN_ADD: u32 = 25;
|
||||||
|
/// Ban client (25 points).
|
||||||
|
pub const BAN_CLIENT: u32 = 25;
|
||||||
|
/// Add complain (25 points).
|
||||||
|
pub const COMPLAIN_ADD: u32 = 25;
|
||||||
|
/// Delete all complains (25 points).
|
||||||
|
pub const COMPLAIN_DEL_ALL: u32 = 25;
|
||||||
|
/// Create channel (25 points).
|
||||||
|
pub const CHANNEL_CREATE: u32 = 25;
|
||||||
|
/// Delete channel (25 points).
|
||||||
|
pub const CHANNEL_DELETE: u32 = 25;
|
||||||
|
/// Move channel (25 points).
|
||||||
|
pub const CHANNEL_MOVE: u32 = 25;
|
||||||
|
/// Edit channel (25 points).
|
||||||
|
pub const CHANNEL_EDIT: u32 = 25;
|
||||||
|
/// Kick client (25 points).
|
||||||
|
pub const CLIENT_KICK: u32 = 25;
|
||||||
|
/// Poke client (25 points).
|
||||||
|
pub const CLIENT_POKE: u32 = 25;
|
||||||
|
/// Edit client (25 points).
|
||||||
|
pub const CLIENT_EDIT: u32 = 25;
|
||||||
|
/// Add client to server group (25 points).
|
||||||
|
pub const SERVER_GROUP_ADD_CLIENT: u32 = 25;
|
||||||
|
/// Remove client from server group (25 points).
|
||||||
|
pub const SERVER_GROUP_DEL_CLIENT: u32 = 25;
|
||||||
|
/// Set client channel group (25 points).
|
||||||
|
pub const SET_CLIENT_CHANNEL_GROUP: u32 = 25;
|
||||||
|
|
||||||
|
// Very high-cost (50)
|
||||||
|
/// Delete client from database (50 points).
|
||||||
|
pub const CLIENT_DB_DELETE: u32 = 50;
|
||||||
|
/// Edit client in database (50 points).
|
||||||
|
pub const CLIENT_DB_EDIT: u32 = 50;
|
||||||
|
/// Find client in database (50 points).
|
||||||
|
pub const CLIENT_DB_FIND: u32 = 50;
|
||||||
|
/// View server log (50 points).
|
||||||
|
pub const LOG_VIEW: u32 = 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server-configured anti-flood parameters.
|
||||||
|
///
|
||||||
|
/// Obtained from `serverinfo` response. If unavailable,
|
||||||
|
/// conservative defaults are used.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct FloodConfig {
|
||||||
|
/// Points deducted per 0.5-second tick.
|
||||||
|
pub points_tick_reduce: u32,
|
||||||
|
/// Points before command block (at equality).
|
||||||
|
pub points_to_command_block: u32,
|
||||||
|
/// Points before IP block.
|
||||||
|
pub points_to_ip_block: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FloodConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
points_tick_reduce: 25,
|
||||||
|
points_to_command_block: 150,
|
||||||
|
points_to_ip_block: 300,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current flood risk level.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub enum FloodRisk {
|
||||||
|
/// Well below thresholds.
|
||||||
|
Safe,
|
||||||
|
/// Approaching command block (>= 80% of threshold).
|
||||||
|
NearLimit,
|
||||||
|
/// At or above command block threshold. Commands will be dropped.
|
||||||
|
CommandBlocked,
|
||||||
|
/// At or above IP block threshold. Connection may be terminated.
|
||||||
|
IpBlocked,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client-side flood tracker following YaTQA §5.1 model.
|
||||||
|
///
|
||||||
|
/// Tick interval: 0.5 seconds. Points decay by `config.points_tick_reduce`
|
||||||
|
/// per tick. Thresholds are server-configurable.
|
||||||
|
///
|
||||||
|
/// # Usage
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// use chanora_protocol::flood_tracker::{FloodTracker, FloodCosts, FloodConfig, FloodRisk};
|
||||||
|
///
|
||||||
|
/// let mut tracker = FloodTracker::new(FloodConfig::default());
|
||||||
|
/// let risk = tracker.record(FloodCosts::TEXT_MESSAGE_SEND);
|
||||||
|
/// if risk >= FloodRisk::NearLimit {
|
||||||
|
/// // Warn user or throttle operations
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
pub struct FloodTracker {
|
||||||
|
points: u32,
|
||||||
|
last_tick: Instant,
|
||||||
|
config: FloodConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FloodTracker {
|
||||||
|
/// Tick interval in milliseconds (0.5 seconds per YaTQA §5.1).
|
||||||
|
const TICK_INTERVAL_MS: u128 = 500;
|
||||||
|
|
||||||
|
/// Create a new tracker with the given server configuration.
|
||||||
|
pub fn new(config: FloodConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
points: 0,
|
||||||
|
last_tick: Instant::now(),
|
||||||
|
config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record an operation and return the current flood risk.
|
||||||
|
pub fn record(&mut self, cost: u32) -> FloodRisk {
|
||||||
|
self.tick();
|
||||||
|
self.points = self.points.saturating_add(cost);
|
||||||
|
self.risk_level()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply time-based decay (0.5-second ticks).
|
||||||
|
fn tick(&mut self) {
|
||||||
|
let elapsed = self.last_tick.elapsed();
|
||||||
|
let ticks = (elapsed.as_millis() / Self::TICK_INTERVAL_MS) as u32;
|
||||||
|
if ticks > 0 {
|
||||||
|
let decay = ticks.saturating_mul(self.config.points_tick_reduce);
|
||||||
|
self.points = self.points.saturating_sub(decay);
|
||||||
|
self.last_tick = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current risk level based on accumulated points.
|
||||||
|
pub fn risk_level(&self) -> FloodRisk {
|
||||||
|
if self.points >= self.config.points_to_ip_block {
|
||||||
|
FloodRisk::IpBlocked
|
||||||
|
} else if self.points >= self.config.points_to_command_block {
|
||||||
|
FloodRisk::CommandBlocked
|
||||||
|
} else if self.points >= (self.config.points_to_command_block * 80 / 100) {
|
||||||
|
FloodRisk::NearLimit
|
||||||
|
} else {
|
||||||
|
FloodRisk::Safe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current accumulated points.
|
||||||
|
pub fn points(&self) -> u32 {
|
||||||
|
self.points
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update configuration from serverinfo response.
|
||||||
|
pub fn update_config(&mut self, config: FloodConfig) {
|
||||||
|
self.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset points (e.g., after successful reconnect).
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.points = 0;
|
||||||
|
self.last_tick = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_tracker_is_safe() {
|
||||||
|
let tracker = FloodTracker::new(FloodConfig::default());
|
||||||
|
assert_eq!(tracker.risk_level(), FloodRisk::Safe);
|
||||||
|
assert_eq!(tracker.points(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn point_accumulation() {
|
||||||
|
let mut tracker = FloodTracker::new(FloodConfig::default());
|
||||||
|
tracker.record(FloodCosts::TEXT_MESSAGE_SEND);
|
||||||
|
assert_eq!(tracker.points(), 15);
|
||||||
|
assert_eq!(tracker.risk_level(), FloodRisk::Safe);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn near_limit_detection() {
|
||||||
|
let mut tracker = FloodTracker::new(FloodConfig::default());
|
||||||
|
// 80% of 150 = 120
|
||||||
|
for _ in 0..8 {
|
||||||
|
tracker.record(FloodCosts::TEXT_MESSAGE_SEND); // 8 * 15 = 120
|
||||||
|
}
|
||||||
|
assert_eq!(tracker.risk_level(), FloodRisk::NearLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_blocked_detection() {
|
||||||
|
let mut tracker = FloodTracker::new(FloodConfig::default());
|
||||||
|
// 150 / 15 = 10 messages
|
||||||
|
for _ in 0..10 {
|
||||||
|
tracker.record(FloodCosts::TEXT_MESSAGE_SEND);
|
||||||
|
}
|
||||||
|
assert_eq!(tracker.risk_level(), FloodRisk::CommandBlocked);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extreme_cost_channel_subscribe() {
|
||||||
|
let mut tracker = FloodTracker::new(FloodConfig::default());
|
||||||
|
let risk = tracker.record(FloodCosts::CHANNEL_SUBSCRIBE);
|
||||||
|
assert_eq!(tracker.points(), 158);
|
||||||
|
assert_eq!(risk, FloodRisk::CommandBlocked);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_update() {
|
||||||
|
let mut tracker = FloodTracker::new(FloodConfig::default());
|
||||||
|
tracker.update_config(FloodConfig {
|
||||||
|
points_tick_reduce: 10,
|
||||||
|
points_to_command_block: 200,
|
||||||
|
points_to_ip_block: 400,
|
||||||
|
});
|
||||||
|
// With higher threshold, same points should be safe
|
||||||
|
for _ in 0..10 {
|
||||||
|
tracker.record(FloodCosts::TEXT_MESSAGE_SEND); // 150
|
||||||
|
}
|
||||||
|
assert_eq!(tracker.risk_level(), FloodRisk::Safe);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_clears_points() {
|
||||||
|
let mut tracker = FloodTracker::new(FloodConfig::default());
|
||||||
|
tracker.record(FloodCosts::CHANNEL_SUBSCRIBE);
|
||||||
|
assert!(tracker.points() > 0);
|
||||||
|
tracker.reset();
|
||||||
|
assert_eq!(tracker.points(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,12 +35,13 @@
|
|||||||
|
|
||||||
mod adapter;
|
mod adapter;
|
||||||
mod dto;
|
mod dto;
|
||||||
|
pub mod flood_tracker;
|
||||||
pub mod poke_limiter;
|
pub mod poke_limiter;
|
||||||
|
|
||||||
pub use adapter::{ConnectConfig, DisconnectReason, InboundVoice, ProtocolClient, SnapshotProbe};
|
pub use adapter::{ConnectConfig, DisconnectReason, InboundVoice, ProtocolClient, SnapshotProbe};
|
||||||
pub use dto::{
|
pub use dto::{
|
||||||
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, ClientProfile, MessageTarget,
|
validate_nickname, ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, ClientProfile,
|
||||||
PokeStrength, ProtocolDelta, ServerActivity, ServerSnapshot,
|
MessageTarget, PokeStrength, ProtocolDelta, ServerActivity, ServerSnapshot,
|
||||||
};
|
};
|
||||||
pub use poke_limiter::PokeLimiter;
|
pub use poke_limiter::PokeLimiter;
|
||||||
|
|
||||||
@@ -115,4 +116,18 @@ pub enum ProtocolError {
|
|||||||
/// should never see this; if they do, it is a mapping bug here.
|
/// should never see this; if they do, it is a mapping bug here.
|
||||||
#[error("protocol backend: {0}")]
|
#[error("protocol backend: {0}")]
|
||||||
Backend(String),
|
Backend(String),
|
||||||
|
|
||||||
|
/// A file transfer failed while downloading protocol-owned assets.
|
||||||
|
#[error("file transfer failed: {0}")]
|
||||||
|
FileTransfer(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProtocolError {
|
||||||
|
fn lost(msg: impl Into<String>) -> Self {
|
||||||
|
ProtocolError::Lost(msg.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backend_ctx(ctx: impl std::fmt::Display, e: impl std::fmt::Display) -> Self {
|
||||||
|
ProtocolError::Backend(format!("{ctx}: {e}"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# chanora_state
|
||||||
|
|
||||||
|
Authoritative client-side mirror of server state: channel tree, client list, and connection lifecycle. Owns the reducers that fold protocol events into state and produce deltas for the bridge (per SAD §7.2 and SDD §5).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Core reducer pattern
|
||||||
|
|
||||||
|
The crate exposes a single `reduce(state: &mut Option<ServerState>, event: StateEvent) -> Reduction` function. Callers own state storage and pass it by `&mut`. The reducer returns a `Reduction` containing only the emitted `Delta` values. This satisfies:
|
||||||
|
|
||||||
|
- **SRS-056** — deterministic deltas: the same `(state, event)` always produces the same `Reduction`
|
||||||
|
- **SRS-057** — per-connection ordering
|
||||||
|
- **SRS-058** — reducer functions are pure
|
||||||
|
|
||||||
|
### Module: `channel_join`
|
||||||
|
|
||||||
|
A more specialized reducer for voice-channel join/leave state tracking with:
|
||||||
|
- Optimistic `UserJoinRequested` events
|
||||||
|
- `AuthoritativeSelfMove` confirmation from live deltas
|
||||||
|
- `SnapshotReady` reconciliation after connects/reconnects
|
||||||
|
- `ChannelJoinProjection` for UI rendering (in_channel, can_join, can_leave, sync_state)
|
||||||
|
- `ConnectionEpoch` tracking to disambiguate stale events across reconnects
|
||||||
|
|
||||||
|
### State model
|
||||||
|
|
||||||
|
- `ServerState` — owned `HashMap<u64, ChannelInfo>` and `HashMap<u64, ClientInfo>` with stable ordering vectors. Built from `ServerSnapshot`, updated incrementally via `StateEvent`s.
|
||||||
|
- `ConnectionState` — enum: Idle / Connecting / Ready / Reconnecting / Lost
|
||||||
|
|
||||||
|
## Public API Summary
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
| Type | Role |
|
||||||
|
|---|---|
|
||||||
|
| `ServerState` | Authoritative mirror of connected server state |
|
||||||
|
| `ConnectionState` | Lifecycle enum (Idle, Connecting, Ready, Reconnecting, Lost) |
|
||||||
|
| `StateEvent` | Protocol-layer input events (Snapshot, ChannelChanged, ClientChanged, etc.) |
|
||||||
|
| `Delta` | Bridge output events (SnapshotApplied, ChannelUpserted, ClientRemoved, etc.) |
|
||||||
|
| `Reduction` | Result of `reduce()`: a `Vec<Delta>` |
|
||||||
|
| `StateError` | Reducer errors (Unknown entity, invariant violation) |
|
||||||
|
|
||||||
|
### Key functions
|
||||||
|
|
||||||
|
- `reduce(state, event)` → `Reduction` — apply a protocol event, return deltas
|
||||||
|
- `reduce_reconnect_snapshot(state, snap)` → `Reduction` — replace all state on reconnect (SRS-059)
|
||||||
|
|
||||||
|
### `ServerState` methods
|
||||||
|
|
||||||
|
- `channel(id)` / `client(id)` — lookup by id
|
||||||
|
- `channels()` / `clients()` — ordered iterators
|
||||||
|
- `own_channel()` — the channel our client is in
|
||||||
|
- `clients_in_channel(channel_id)` — filtered iterator
|
||||||
|
|
||||||
|
### `channel_join` module
|
||||||
|
|
||||||
|
- `reduce(state, event)` → `JoinReduction` — channel-join state machine
|
||||||
|
- `project(state)` → `ChannelJoinProjection` — UI-ready snapshot
|
||||||
|
- `ChannelJoinEvent`, `ChannelJoinState`, `ChannelJoinProjection` — state machine types
|
||||||
|
|
||||||
|
## Design notes
|
||||||
|
|
||||||
|
- Events are ignored when state is `None` (disconnected), except `Snapshot` (creates state) and `ConnectionChanged`.
|
||||||
|
- Deleting a channel also removes all clients in that channel.
|
||||||
|
- Duplicate IDs in snapshots are deduplicated deterministically.
|
||||||
@@ -110,11 +110,6 @@ impl ServerState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace state with a fresh snapshot (post-reconnect). Satisfies SRS-059.
|
|
||||||
pub fn replace_from_snapshot(&mut self, snapshot: ServerSnapshot) {
|
|
||||||
*self = Self::from_snapshot(snapshot);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Look up a channel by id.
|
/// Look up a channel by id.
|
||||||
pub fn channel(&self, id: ChannelId) -> Option<&ChannelInfo> {
|
pub fn channel(&self, id: ChannelId) -> Option<&ChannelInfo> {
|
||||||
self.channels.get(&id.0)
|
self.channels.get(&id.0)
|
||||||
@@ -144,16 +139,6 @@ impl ServerState {
|
|||||||
.filter_map(|id| self.clients.get(id))
|
.filter_map(|id| self.clients.get(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Number of channels.
|
|
||||||
pub fn channel_count(&self) -> usize {
|
|
||||||
self.channels.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of clients.
|
|
||||||
pub fn client_count(&self) -> usize {
|
|
||||||
self.clients.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The channel our own client is currently in.
|
/// The channel our own client is currently in.
|
||||||
pub fn own_channel(&self) -> Option<&ChannelInfo> {
|
pub fn own_channel(&self) -> Option<&ChannelInfo> {
|
||||||
self.client(ClientId(self.own_client_id))
|
self.client(ClientId(self.own_client_id))
|
||||||
@@ -202,6 +187,10 @@ fn normalize_snapshot(snapshot: ServerSnapshot) -> ServerSnapshot {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO(refactor): StateEvent and Delta have mirrored variants (e.g.
|
||||||
|
// StateEvent::ChannelChanged/ChannelDeleted vs Delta::ChannelUpserted/ChannelRemoved).
|
||||||
|
// A proc-macro or macro_rules could generate the Delta-from-StateEvent mapping, but
|
||||||
|
// the manual match is currently clear and the types serve different roles (input vs output).
|
||||||
/// A change to the server state that the bridge should publish to
|
/// A change to the server state that the bridge should publish to
|
||||||
/// Flutter. Deltas are cheap to construct and carry only the
|
/// Flutter. Deltas are cheap to construct and carry only the
|
||||||
/// information that changed.
|
/// information that changed.
|
||||||
@@ -463,8 +452,8 @@ mod tests {
|
|||||||
assert!(state.is_some());
|
assert!(state.is_some());
|
||||||
let s = state.as_ref().unwrap();
|
let s = state.as_ref().unwrap();
|
||||||
assert_eq!(s.connection_state, ConnectionState::Ready);
|
assert_eq!(s.connection_state, ConnectionState::Ready);
|
||||||
assert_eq!(s.channel_count(), 2);
|
assert_eq!(s.channels().count(), 2);
|
||||||
assert_eq!(s.client_count(), 1);
|
assert_eq!(s.clients().count(), 1);
|
||||||
assert_eq!(s.own_client_id, 10);
|
assert_eq!(s.own_client_id, 10);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
reduction.deltas,
|
reduction.deltas,
|
||||||
@@ -489,7 +478,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let reduction = reduce(&mut state, StateEvent::ChannelChanged(ch.clone()));
|
let reduction = reduce(&mut state, StateEvent::ChannelChanged(ch.clone()));
|
||||||
let s = state.as_ref().unwrap();
|
let s = state.as_ref().unwrap();
|
||||||
assert_eq!(s.channel_count(), 3);
|
assert_eq!(s.channels().count(), 3);
|
||||||
assert!(s.channel(ChannelId(3)).is_some());
|
assert!(s.channel(ChannelId(3)).is_some());
|
||||||
assert!(matches!(&reduction.deltas[..], [Delta::ChannelUpserted(_)]));
|
assert!(matches!(&reduction.deltas[..], [Delta::ChannelUpserted(_)]));
|
||||||
let updated = ChannelInfo {
|
let updated = ChannelInfo {
|
||||||
@@ -501,7 +490,7 @@ mod tests {
|
|||||||
state.as_ref().unwrap().channel(ChannelId(3)).unwrap().name,
|
state.as_ref().unwrap().channel(ChannelId(3)).unwrap().name,
|
||||||
"renamed"
|
"renamed"
|
||||||
);
|
);
|
||||||
assert_eq!(state.as_ref().unwrap().channel_count(), 3);
|
assert_eq!(state.as_ref().unwrap().channels().count(), 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -510,7 +499,7 @@ mod tests {
|
|||||||
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
|
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
|
||||||
let reduction = reduce(&mut state, StateEvent::ChannelDeleted(ChannelId(2)));
|
let reduction = reduce(&mut state, StateEvent::ChannelDeleted(ChannelId(2)));
|
||||||
let s = state.as_ref().unwrap();
|
let s = state.as_ref().unwrap();
|
||||||
assert_eq!(s.channel_count(), 1);
|
assert_eq!(s.channels().count(), 1);
|
||||||
assert!(s.channel(ChannelId(2)).is_none());
|
assert!(s.channel(ChannelId(2)).is_none());
|
||||||
assert!(matches!(&reduction.deltas[..], [Delta::ChannelRemoved(_)]));
|
assert!(matches!(&reduction.deltas[..], [Delta::ChannelRemoved(_)]));
|
||||||
}
|
}
|
||||||
@@ -528,7 +517,7 @@ mod tests {
|
|||||||
assert!(s.channel(ChannelId(2)).is_none());
|
assert!(s.channel(ChannelId(2)).is_none());
|
||||||
assert!(s.client(ClientId(20)).is_none());
|
assert!(s.client(ClientId(20)).is_none());
|
||||||
assert!(s.client(ClientId(30)).is_none());
|
assert!(s.client(ClientId(30)).is_none());
|
||||||
assert_eq!(s.client_count(), 1);
|
assert_eq!(s.clients().count(), 1);
|
||||||
assert_eq!(s.clients_in_channel(ChannelId(2)).count(), 0);
|
assert_eq!(s.clients_in_channel(ChannelId(2)).count(), 0);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
reduction.deltas,
|
reduction.deltas,
|
||||||
@@ -547,7 +536,7 @@ mod tests {
|
|||||||
let new_client = sample_client(20, 2);
|
let new_client = sample_client(20, 2);
|
||||||
let reduction = reduce(&mut state, StateEvent::ClientChanged(new_client));
|
let reduction = reduce(&mut state, StateEvent::ClientChanged(new_client));
|
||||||
let s = state.as_ref().unwrap();
|
let s = state.as_ref().unwrap();
|
||||||
assert_eq!(s.client_count(), 2);
|
assert_eq!(s.clients().count(), 2);
|
||||||
assert!(matches!(&reduction.deltas[..], [Delta::ClientUpserted(_)]));
|
assert!(matches!(&reduction.deltas[..], [Delta::ClientUpserted(_)]));
|
||||||
let moved = ClientInfo {
|
let moved = ClientInfo {
|
||||||
channel: ChannelId(2),
|
channel: ChannelId(2),
|
||||||
@@ -570,7 +559,7 @@ mod tests {
|
|||||||
let mut state = None;
|
let mut state = None;
|
||||||
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
|
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
|
||||||
let reduction = reduce(&mut state, StateEvent::ClientLeft(ClientId(10)));
|
let reduction = reduce(&mut state, StateEvent::ClientLeft(ClientId(10)));
|
||||||
assert_eq!(state.as_ref().unwrap().client_count(), 0);
|
assert_eq!(state.as_ref().unwrap().clients().count(), 0);
|
||||||
assert!(matches!(&reduction.deltas[..], [Delta::ClientRemoved(_)]));
|
assert!(matches!(&reduction.deltas[..], [Delta::ClientRemoved(_)]));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -578,7 +567,7 @@ mod tests {
|
|||||||
fn reconnect_discards_stale_state() {
|
fn reconnect_discards_stale_state() {
|
||||||
let mut state = None;
|
let mut state = None;
|
||||||
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
|
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
|
||||||
assert_eq!(state.as_ref().unwrap().channel_count(), 2);
|
assert_eq!(state.as_ref().unwrap().channels().count(), 2);
|
||||||
let reduction = reduce(&mut state, StateEvent::ReconnectStarted);
|
let reduction = reduce(&mut state, StateEvent::ReconnectStarted);
|
||||||
assert!(state.is_none());
|
assert!(state.is_none());
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
@@ -594,8 +583,8 @@ mod tests {
|
|||||||
reduce_reconnect_snapshot(&mut state, snap2);
|
reduce_reconnect_snapshot(&mut state, snap2);
|
||||||
let s = state.as_ref().unwrap();
|
let s = state.as_ref().unwrap();
|
||||||
assert_eq!(s.server_name, "New Server");
|
assert_eq!(s.server_name, "New Server");
|
||||||
assert_eq!(s.channel_count(), 1);
|
assert_eq!(s.channels().count(), 1);
|
||||||
assert_eq!(s.client_count(), 1);
|
assert_eq!(s.clients().count(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -685,9 +674,7 @@ mod tests {
|
|||||||
let reduction = reduce(&mut state, StateEvent::Snapshot(snapshot));
|
let reduction = reduce(&mut state, StateEvent::Snapshot(snapshot));
|
||||||
let s = state.as_ref().unwrap();
|
let s = state.as_ref().unwrap();
|
||||||
|
|
||||||
assert_eq!(s.channel_count(), 2);
|
|
||||||
assert_eq!(s.channels().count(), 2);
|
assert_eq!(s.channels().count(), 2);
|
||||||
assert_eq!(s.client_count(), 1);
|
|
||||||
assert_eq!(s.clients().count(), 1);
|
assert_eq!(s.clients().count(), 1);
|
||||||
assert_eq!(s.channel(ChannelId(1)).unwrap().name, "duplicate");
|
assert_eq!(s.channel(ChannelId(1)).unwrap().name, "duplicate");
|
||||||
assert_eq!(s.client(ClientId(10)).unwrap().channel, ChannelId(2));
|
assert_eq!(s.client(ClientId(10)).unwrap().channel, ChannelId(2));
|
||||||
@@ -766,7 +753,7 @@ mod tests {
|
|||||||
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
|
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
|
||||||
let reduction = reduce(&mut state, StateEvent::ChannelDeleted(ChannelId(999)));
|
let reduction = reduce(&mut state, StateEvent::ChannelDeleted(ChannelId(999)));
|
||||||
assert!(reduction.deltas.is_empty());
|
assert!(reduction.deltas.is_empty());
|
||||||
assert_eq!(state.as_ref().unwrap().channel_count(), 2);
|
assert_eq!(state.as_ref().unwrap().channels().count(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -795,12 +782,12 @@ mod tests {
|
|||||||
deltas_b.extend(reduce(&mut b, e.clone()).deltas);
|
deltas_b.extend(reduce(&mut b, e.clone()).deltas);
|
||||||
}
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
a.as_ref().unwrap().channel_count(),
|
a.as_ref().unwrap().channels().count(),
|
||||||
b.as_ref().unwrap().channel_count()
|
b.as_ref().unwrap().channels().count()
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
a.as_ref().unwrap().client_count(),
|
a.as_ref().unwrap().clients().count(),
|
||||||
b.as_ref().unwrap().client_count()
|
b.as_ref().unwrap().clients().count()
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
a.as_ref().unwrap().own_client_id,
|
a.as_ref().unwrap().own_client_id,
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# chanora_storage
|
||||||
|
|
||||||
|
Two strictly separated storage concerns per SAD-067:
|
||||||
|
|
||||||
|
1. **`BookmarkRepository`** — non-secret bookmark state via SQLite with optional encrypted password fields (`rusqlite` bundled, DEC-013.1).
|
||||||
|
2. **`IdentityFileStore`** — Beta fallback storage for identity material while platform secure-storage backends mature.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### IdentityFileStore
|
||||||
|
|
||||||
|
- Persists a single TS3 identity to `<dir>/identity.tskey` encrypted with ChaCha20-Poly1305.
|
||||||
|
- The Data Encryption Key (DEK) is 32 random bytes stored in the platform keyring (Linux Secret Service, macOS Keychain, Windows Credential Manager, iOS Keychain) when available, with a best-effort file fallback at `identity.dek` (mode 0600 on Unix).
|
||||||
|
- Legacy plaintext files from pre-Beta are still readable; the next `save()` upgrades them to encrypted form.
|
||||||
|
- Audio metadata (`transmit_mode`, `release_tail_ms`, PTT binding) is persisted alongside as `audio_meta.json` (plaintext, non-secret).
|
||||||
|
|
||||||
|
### BookmarkRepository
|
||||||
|
|
||||||
|
- SQLite-backed store at `<dir>/chanora.db`.
|
||||||
|
- Schema v1: basic bookmark columns. Schema v2: adds `password_blob` for encrypted passwords.
|
||||||
|
- When constructed via `with_crypto()`, the `password` column is replaced by a ChaCha20-Poly1305 envelope under the same per-install DEK.
|
||||||
|
- Legacy plaintext passwords are transparently read and upgraded on the next `update()`.
|
||||||
|
|
||||||
|
### Crypto abstraction
|
||||||
|
|
||||||
|
- `Crypto` trait: `encrypt(plaintext)` / `decrypt(blob)` — callers see only the encrypt/decrypt pair.
|
||||||
|
- `DekCrypto` — concrete implementation sharing the same per-install DEK with `IdentityFileStore`.
|
||||||
|
|
||||||
|
## Public API Summary
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
| Type | Role |
|
||||||
|
|---|---|
|
||||||
|
| `IdentityFileStore` | Encrypted identity file store |
|
||||||
|
| `BookmarkRepository` | SQLite bookmark store with optional password encryption |
|
||||||
|
| `Bookmark` | Bookmark DTO: id, display_name, host, nickname, password |
|
||||||
|
| `PttBindingMeta` | Persisted PTT binding metadata |
|
||||||
|
| `StorageError` | NotFound, Migration, Sqlite, SecureStore, Io, Crypto |
|
||||||
|
| `Crypto` trait | Encrypt/decrypt abstraction |
|
||||||
|
|
||||||
|
### IdentityFileStore methods
|
||||||
|
|
||||||
|
- `new(dir)` — create or open store, ensure DEK exists
|
||||||
|
- `load()` → `Option<String>` — read identity (handles legacy plaintext)
|
||||||
|
- `save(identity)` — persist encrypted (ChaCha20-Poly1305, atomic write)
|
||||||
|
- `clear()` — remove identity file
|
||||||
|
- `crypto()` — obtain a `Crypto` handle sharing the DEK
|
||||||
|
- `set_transmit_mode(mode)` / `get_transmit_mode()` — audio settings persistence
|
||||||
|
- `set_release_tail_ms(ms)` / `get_release_tail_ms()` — release-tail persistence
|
||||||
|
- `set_ptt_binding(...)` / `get_ptt_binding()` — PTT binding persistence
|
||||||
|
|
||||||
|
### BookmarkRepository methods
|
||||||
|
|
||||||
|
- `new(dir)` / `with_crypto(dir, crypto)` — open (plain or encrypted)
|
||||||
|
- `add(bookmark)` → `i64` — insert, return id
|
||||||
|
- `update(bookmark)` — replace by id
|
||||||
|
- `delete(id)` — remove by id
|
||||||
|
- `list()` → `Vec<Bookmark>` — all bookmarks ordered by id
|
||||||
|
- `upsert_or_add(bookmark)` — insert or update by host, preserves user's display name
|
||||||
|
- `encrypts_passwords()` — whether password encryption is active
|
||||||
|
|
||||||
|
## Platform notes
|
||||||
|
|
||||||
|
- Unix: files written with mode 0600.
|
||||||
|
- Keyring access can be disabled via `CHANORA_DISABLE_KEYRING=1` for tests/headless environments.
|
||||||
|
- Android: file in app-private storage (not encrypted at rest — documented Beta gap).
|
||||||
|
- iOS/Windows/macOS: caller provides the storage directory; platform sandbox handles access control.
|
||||||
@@ -79,6 +79,28 @@ pub enum StorageError {
|
|||||||
Crypto(String),
|
Crypto(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl StorageError {
|
||||||
|
fn io_ctx(ctx: impl std::fmt::Display, e: impl std::fmt::Display) -> Self {
|
||||||
|
StorageError::Io(format!("{ctx}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn crypto_ctx(ctx: impl std::fmt::Display, e: impl std::fmt::Display) -> Self {
|
||||||
|
StorageError::Crypto(format!("{ctx}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sqlite_ctx(ctx: impl std::fmt::Display, e: impl std::fmt::Display) -> Self {
|
||||||
|
StorageError::Sqlite(format!("{ctx}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn migration_ctx(ctx: impl std::fmt::Display, e: impl std::fmt::Display) -> Self {
|
||||||
|
StorageError::Migration(format!("{ctx}: {e}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_dir(dir: &Path) -> Result<(), StorageError> {
|
||||||
|
fs::create_dir_all(dir).map_err(|e| StorageError::io_ctx(format!("mkdir {dir:?}"), e))
|
||||||
|
}
|
||||||
|
|
||||||
/// Audio-related per-identity settings persisted alongside the
|
/// Audio-related per-identity settings persisted alongside the
|
||||||
/// identity file as a small JSON blob (SDD-095 / SDD-096). These
|
/// identity file as a small JSON blob (SDD-095 / SDD-096). These
|
||||||
/// are *not* secrets; they sit beside the encrypted identity in
|
/// are *not* secrets; they sit beside the encrypted identity in
|
||||||
@@ -193,7 +215,7 @@ impl IdentityFileStore {
|
|||||||
/// the DEK on first use; subsequent uses reuse the existing DEK.
|
/// the DEK on first use; subsequent uses reuse the existing DEK.
|
||||||
pub fn new(dir: impl AsRef<Path>) -> Result<Self, StorageError> {
|
pub fn new(dir: impl AsRef<Path>) -> Result<Self, StorageError> {
|
||||||
let dir = dir.as_ref();
|
let dir = dir.as_ref();
|
||||||
fs::create_dir_all(dir).map_err(|e| StorageError::Io(format!("mkdir {dir:?}: {e}")))?;
|
ensure_dir(dir)?;
|
||||||
let canonical = fs::canonicalize(dir)
|
let canonical = fs::canonicalize(dir)
|
||||||
.map(|p| p.to_string_lossy().into_owned())
|
.map(|p| p.to_string_lossy().into_owned())
|
||||||
.unwrap_or_else(|_| dir.to_string_lossy().into_owned());
|
.unwrap_or_else(|_| dir.to_string_lossy().into_owned());
|
||||||
@@ -242,7 +264,7 @@ impl IdentityFileStore {
|
|||||||
Ok(b64) => {
|
Ok(b64) => {
|
||||||
let bytes = base64::engine::general_purpose::STANDARD
|
let bytes = base64::engine::general_purpose::STANDARD
|
||||||
.decode(b64.as_bytes())
|
.decode(b64.as_bytes())
|
||||||
.map_err(|e| StorageError::Crypto(format!("keyring dek decode: {e}")))?;
|
.map_err(|e| StorageError::crypto_ctx("keyring dek decode", e))?;
|
||||||
if bytes.len() != 32 {
|
if bytes.len() != 32 {
|
||||||
return Err(StorageError::Crypto(format!(
|
return Err(StorageError::Crypto(format!(
|
||||||
"keyring dek length {} (expected 32)",
|
"keyring dek length {} (expected 32)",
|
||||||
@@ -270,7 +292,9 @@ impl IdentityFileStore {
|
|||||||
target_os = "ios"
|
target_os = "ios"
|
||||||
)))]
|
)))]
|
||||||
fn keyring_load(&self) -> Result<Option<[u8; 32]>, StorageError> {
|
fn keyring_load(&self) -> Result<Option<[u8; 32]>, StorageError> {
|
||||||
Ok(None)
|
Err(StorageError::SecureStore(
|
||||||
|
"keyring is not yet supported on this platform".into(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persist the DEK in the platform keyring. Returns true on
|
/// Persist the DEK in the platform keyring. Returns true on
|
||||||
@@ -353,9 +377,9 @@ impl IdentityFileStore {
|
|||||||
let _ = self.keyring_save(&key);
|
let _ = self.keyring_save(&key);
|
||||||
let mut f = open_private(&self.dek_path)?;
|
let mut f = open_private(&self.dek_path)?;
|
||||||
f.write_all(&key)
|
f.write_all(&key)
|
||||||
.map_err(|e| StorageError::Io(format!("write dek: {e}")))?;
|
.map_err(|e| StorageError::io_ctx("write dek", e))?;
|
||||||
f.sync_all()
|
f.sync_all()
|
||||||
.map_err(|e| StorageError::Io(format!("sync dek: {e}")))?;
|
.map_err(|e| StorageError::io_ctx("sync dek", e))?;
|
||||||
info!(target: "chanora_storage", path = ?self.dek_path, "DEK generated (file fallback)");
|
info!(target: "chanora_storage", path = ?self.dek_path, "DEK generated (file fallback)");
|
||||||
key.zeroize();
|
key.zeroize();
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -391,11 +415,11 @@ impl IdentityFileStore {
|
|||||||
let mut f = match fs::File::open(&self.path) {
|
let mut f = match fs::File::open(&self.path) {
|
||||||
Ok(f) => f,
|
Ok(f) => f,
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||||
Err(e) => return Err(StorageError::Io(format!("open {:?}: {e}", self.path))),
|
Err(e) => return Err(StorageError::io_ctx(format!("open {:?}", self.path), e)),
|
||||||
};
|
};
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
f.read_to_end(&mut buf)
|
f.read_to_end(&mut buf)
|
||||||
.map_err(|e| StorageError::Io(format!("read {:?}: {e}", self.path)))?;
|
.map_err(|e| StorageError::io_ctx(format!("read {:?}", self.path), e))?;
|
||||||
if buf.is_empty() {
|
if buf.is_empty() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
@@ -414,11 +438,11 @@ impl IdentityFileStore {
|
|||||||
let nonce = Nonce::from_slice(nonce_bytes);
|
let nonce = Nonce::from_slice(nonce_bytes);
|
||||||
let pt = cipher.decrypt(nonce, &buf[12..]).map_err(|e| {
|
let pt = cipher.decrypt(nonce, &buf[12..]).map_err(|e| {
|
||||||
key_bytes.zeroize();
|
key_bytes.zeroize();
|
||||||
StorageError::Crypto(format!("decrypt: {e}"))
|
StorageError::crypto_ctx("decrypt", e)
|
||||||
})?;
|
})?;
|
||||||
key_bytes.zeroize();
|
key_bytes.zeroize();
|
||||||
let s = String::from_utf8(pt)
|
let s = String::from_utf8(pt)
|
||||||
.map_err(|e| StorageError::Crypto(format!("plaintext not utf8: {e}")))?;
|
.map_err(|e| StorageError::crypto_ctx("plaintext not utf8", e))?;
|
||||||
let trimmed = s.trim().to_string();
|
let trimmed = s.trim().to_string();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -434,7 +458,7 @@ impl IdentityFileStore {
|
|||||||
"identity file is in legacy plaintext format; will encrypt on next save"
|
"identity file is in legacy plaintext format; will encrypt on next save"
|
||||||
);
|
);
|
||||||
let s = String::from_utf8(buf)
|
let s = String::from_utf8(buf)
|
||||||
.map_err(|e| StorageError::Io(format!("legacy not utf8: {e}")))?;
|
.map_err(|e| StorageError::io_ctx("legacy not utf8", e))?;
|
||||||
let trimmed = s.trim().to_string();
|
let trimmed = s.trim().to_string();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
@@ -456,7 +480,7 @@ impl IdentityFileStore {
|
|||||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
let ct = cipher.encrypt(nonce, plaintext).map_err(|e| {
|
let ct = cipher.encrypt(nonce, plaintext).map_err(|e| {
|
||||||
key_bytes.zeroize();
|
key_bytes.zeroize();
|
||||||
StorageError::Crypto(format!("encrypt: {e}"))
|
StorageError::crypto_ctx("encrypt", e)
|
||||||
})?;
|
})?;
|
||||||
key_bytes.zeroize();
|
key_bytes.zeroize();
|
||||||
|
|
||||||
@@ -465,14 +489,14 @@ impl IdentityFileStore {
|
|||||||
{
|
{
|
||||||
let mut f = open_private(&tmp)?;
|
let mut f = open_private(&tmp)?;
|
||||||
f.write_all(&nonce_bytes)
|
f.write_all(&nonce_bytes)
|
||||||
.map_err(|e| StorageError::Io(format!("write nonce: {e}")))?;
|
.map_err(|e| StorageError::io_ctx("write nonce", e))?;
|
||||||
f.write_all(&ct)
|
f.write_all(&ct)
|
||||||
.map_err(|e| StorageError::Io(format!("write ct: {e}")))?;
|
.map_err(|e| StorageError::io_ctx("write ct", e))?;
|
||||||
f.sync_all()
|
f.sync_all()
|
||||||
.map_err(|e| StorageError::Io(format!("sync {tmp:?}: {e}")))?;
|
.map_err(|e| StorageError::io_ctx(format!("sync {tmp:?}"), e))?;
|
||||||
}
|
}
|
||||||
fs::rename(&tmp, &self.path)
|
fs::rename(&tmp, &self.path)
|
||||||
.map_err(|e| StorageError::Io(format!("rename {tmp:?} -> {:?}: {e}", self.path)))?;
|
.map_err(|e| StorageError::io_ctx(format!("rename {tmp:?} -> {:?}", self.path), e))?;
|
||||||
info!(target: "chanora_storage", path = ?self.path, "identity persisted (encrypted)");
|
info!(target: "chanora_storage", path = ?self.path, "identity persisted (encrypted)");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -501,17 +525,17 @@ impl IdentityFileStore {
|
|||||||
let path = self.meta_path();
|
let path = self.meta_path();
|
||||||
let tmp = path.with_extension("json.tmp");
|
let tmp = path.with_extension("json.tmp");
|
||||||
let body = serde_json::to_vec_pretty(m)
|
let body = serde_json::to_vec_pretty(m)
|
||||||
.map_err(|e| StorageError::Io(format!("meta serialize: {e}")))?;
|
.map_err(|e| StorageError::io_ctx("meta serialize", e))?;
|
||||||
{
|
{
|
||||||
let mut f = fs::File::create(&tmp)
|
let mut f = fs::File::create(&tmp)
|
||||||
.map_err(|e| StorageError::Io(format!("open meta {tmp:?}: {e}")))?;
|
.map_err(|e| StorageError::io_ctx(format!("open meta {tmp:?}"), e))?;
|
||||||
f.write_all(&body)
|
f.write_all(&body)
|
||||||
.map_err(|e| StorageError::Io(format!("write meta: {e}")))?;
|
.map_err(|e| StorageError::io_ctx("write meta", e))?;
|
||||||
f.sync_all()
|
f.sync_all()
|
||||||
.map_err(|e| StorageError::Io(format!("sync meta: {e}")))?;
|
.map_err(|e| StorageError::io_ctx("sync meta", e))?;
|
||||||
}
|
}
|
||||||
fs::rename(&tmp, &path)
|
fs::rename(&tmp, &path)
|
||||||
.map_err(|e| StorageError::Io(format!("rename meta {tmp:?} -> {path:?}: {e}")))?;
|
.map_err(|e| StorageError::io_ctx(format!("rename meta {tmp:?} -> {path:?}"), e))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,10 +641,10 @@ fn is_plausibly_legacy_plaintext(buf: &[u8]) -> bool {
|
|||||||
/// and the legacy migration path inside `ensure_dek`.
|
/// and the legacy migration path inside `ensure_dek`.
|
||||||
fn read_file_dek(path: &Path) -> Result<[u8; 32], StorageError> {
|
fn read_file_dek(path: &Path) -> Result<[u8; 32], StorageError> {
|
||||||
let mut f =
|
let mut f =
|
||||||
fs::File::open(path).map_err(|e| StorageError::Io(format!("open dek {:?}: {e}", path)))?;
|
fs::File::open(path).map_err(|e| StorageError::io_ctx(format!("open dek {path:?}"), e))?;
|
||||||
let mut key = [0u8; 32];
|
let mut key = [0u8; 32];
|
||||||
f.read_exact(&mut key)
|
f.read_exact(&mut key)
|
||||||
.map_err(|e| StorageError::Io(format!("read dek: {e}")))?;
|
.map_err(|e| StorageError::io_ctx("read dek", e))?;
|
||||||
Ok(key)
|
Ok(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -650,7 +674,7 @@ fn open_private(p: &Path) -> Result<fs::File, StorageError> {
|
|||||||
.truncate(true)
|
.truncate(true)
|
||||||
.mode(0o600)
|
.mode(0o600)
|
||||||
.open(p)
|
.open(p)
|
||||||
.map_err(|e| StorageError::Io(format!("open {p:?}: {e}")))
|
.map_err(|e| StorageError::io_ctx(format!("open {p:?}"), e))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
#[cfg(not(unix))]
|
||||||
@@ -664,7 +688,7 @@ fn open_private(p: &Path) -> Result<fs::File, StorageError> {
|
|||||||
.write(true)
|
.write(true)
|
||||||
.truncate(true)
|
.truncate(true)
|
||||||
.open(p)
|
.open(p)
|
||||||
.map_err(|e| StorageError::Io(format!("open {p:?}: {e}")))
|
.map_err(|e| StorageError::io_ctx(format!("open {p:?}"), e))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Public abstraction over the per-install envelope-encryption
|
/// Public abstraction over the per-install envelope-encryption
|
||||||
@@ -719,7 +743,7 @@ impl DekCrypto {
|
|||||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
let ct = cipher
|
let ct = cipher
|
||||||
.encrypt(nonce, plaintext)
|
.encrypt(nonce, plaintext)
|
||||||
.map_err(|e| StorageError::Crypto(format!("encrypt: {e}")))?;
|
.map_err(|e| StorageError::crypto_ctx("encrypt", e))?;
|
||||||
let mut out = Vec::with_capacity(12 + ct.len());
|
let mut out = Vec::with_capacity(12 + ct.len());
|
||||||
out.extend_from_slice(&nonce_bytes);
|
out.extend_from_slice(&nonce_bytes);
|
||||||
out.extend_from_slice(&ct);
|
out.extend_from_slice(&ct);
|
||||||
@@ -738,7 +762,7 @@ impl DekCrypto {
|
|||||||
let nonce = Nonce::from_slice(&blob[..12]);
|
let nonce = Nonce::from_slice(&blob[..12]);
|
||||||
cipher
|
cipher
|
||||||
.decrypt(nonce, &blob[12..])
|
.decrypt(nonce, &blob[12..])
|
||||||
.map_err(|e| StorageError::Crypto(format!("decrypt: {e}")))
|
.map_err(|e| StorageError::crypto_ctx("decrypt", e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -812,12 +836,12 @@ impl BookmarkRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn open(dir: &Path, crypto: Option<Box<dyn Crypto>>) -> Result<Self, StorageError> {
|
fn open(dir: &Path, crypto: Option<Box<dyn Crypto>>) -> Result<Self, StorageError> {
|
||||||
fs::create_dir_all(dir).map_err(|e| StorageError::Io(format!("mkdir {dir:?}: {e}")))?;
|
ensure_dir(dir)?;
|
||||||
let path = dir.join("chanora.db");
|
let path = dir.join("chanora.db");
|
||||||
let conn = Connection::open(&path)
|
let conn = Connection::open(&path)
|
||||||
.map_err(|e| StorageError::Sqlite(format!("open {path:?}: {e}")))?;
|
.map_err(|e| StorageError::sqlite_ctx(format!("open {path:?}"), e))?;
|
||||||
conn.pragma_update(None, "foreign_keys", "ON")
|
conn.pragma_update(None, "foreign_keys", "ON")
|
||||||
.map_err(|e| StorageError::Sqlite(format!("pragma: {e}")))?;
|
.map_err(|e| StorageError::sqlite_ctx("pragma", e))?;
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
"CREATE TABLE IF NOT EXISTS bookmarks (
|
"CREATE TABLE IF NOT EXISTS bookmarks (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -831,7 +855,7 @@ impl BookmarkRepository {
|
|||||||
);
|
);
|
||||||
INSERT OR IGNORE INTO schema_version(v) VALUES (1);",
|
INSERT OR IGNORE INTO schema_version(v) VALUES (1);",
|
||||||
)
|
)
|
||||||
.map_err(|e| StorageError::Migration(format!("init schema: {e}")))?;
|
.map_err(|e| StorageError::migration_ctx("init schema", e))?;
|
||||||
// Schema v2 migration: encrypted password column. Idempotent.
|
// Schema v2 migration: encrypted password column. Idempotent.
|
||||||
let has_blob: i64 = conn
|
let has_blob: i64 = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
@@ -839,12 +863,12 @@ impl BookmarkRepository {
|
|||||||
[],
|
[],
|
||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
)
|
)
|
||||||
.map_err(|e| StorageError::Migration(format!("table_info: {e}")))?;
|
.map_err(|e| StorageError::migration_ctx("table_info", e))?;
|
||||||
if has_blob == 0 {
|
if has_blob == 0 {
|
||||||
conn.execute("ALTER TABLE bookmarks ADD COLUMN password_blob BLOB", [])
|
conn.execute("ALTER TABLE bookmarks ADD COLUMN password_blob BLOB", [])
|
||||||
.map_err(|e| StorageError::Migration(format!("add password_blob: {e}")))?;
|
.map_err(|e| StorageError::migration_ctx("add password_blob", e))?;
|
||||||
conn.execute("INSERT OR IGNORE INTO schema_version(v) VALUES (2)", [])
|
conn.execute("INSERT OR IGNORE INTO schema_version(v) VALUES (2)", [])
|
||||||
.map_err(|e| StorageError::Migration(format!("bump version: {e}")))?;
|
.map_err(|e| StorageError::migration_ctx("bump version", e))?;
|
||||||
info!(target: "chanora_storage", "bookmark db migrated to v2 (password_blob)");
|
info!(target: "chanora_storage", "bookmark db migrated to v2 (password_blob)");
|
||||||
}
|
}
|
||||||
info!(target: "chanora_storage", path = ?path, "bookmark db opened");
|
info!(target: "chanora_storage", path = ?path, "bookmark db opened");
|
||||||
@@ -880,7 +904,7 @@ impl BookmarkRepository {
|
|||||||
"INSERT INTO bookmarks (display_name, host, nickname, password, password_blob) VALUES (?1, ?2, ?3, ?4, ?5)",
|
"INSERT INTO bookmarks (display_name, host, nickname, password, password_blob) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||||
params![b.display_name, b.host, b.nickname, plain, blob],
|
params![b.display_name, b.host, b.nickname, plain, blob],
|
||||||
)
|
)
|
||||||
.map_err(|e| StorageError::Sqlite(format!("insert: {e}")))?;
|
.map_err(|e| StorageError::sqlite_ctx("insert", e))?;
|
||||||
Ok(conn.last_insert_rowid())
|
Ok(conn.last_insert_rowid())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -909,20 +933,20 @@ impl BookmarkRepository {
|
|||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)
|
)
|
||||||
.optional()
|
.optional()
|
||||||
.map_err(|e| StorageError::Sqlite(format!("select: {e}")))?;
|
.map_err(|e| StorageError::sqlite_ctx("select", e))?;
|
||||||
if let Some(id) = existing {
|
if let Some(id) = existing {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE bookmarks SET nickname = ?1, password = ?2, password_blob = ?3 WHERE id = ?4",
|
"UPDATE bookmarks SET nickname = ?1, password = ?2, password_blob = ?3 WHERE id = ?4",
|
||||||
params![b.nickname, plain, blob, id],
|
params![b.nickname, plain, blob, id],
|
||||||
)
|
)
|
||||||
.map_err(|e| StorageError::Sqlite(format!("update: {e}")))?;
|
.map_err(|e| StorageError::sqlite_ctx("update", e))?;
|
||||||
Ok(id)
|
Ok(id)
|
||||||
} else {
|
} else {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO bookmarks (display_name, host, nickname, password, password_blob) VALUES (?1, ?2, ?3, ?4, ?5)",
|
"INSERT INTO bookmarks (display_name, host, nickname, password, password_blob) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||||
params![b.display_name, b.host, b.nickname, plain, blob],
|
params![b.display_name, b.host, b.nickname, plain, blob],
|
||||||
)
|
)
|
||||||
.map_err(|e| StorageError::Sqlite(format!("insert: {e}")))?;
|
.map_err(|e| StorageError::sqlite_ctx("insert", e))?;
|
||||||
Ok(conn.last_insert_rowid())
|
Ok(conn.last_insert_rowid())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -951,7 +975,7 @@ impl BookmarkRepository {
|
|||||||
"UPDATE bookmarks SET display_name=?1, host=?2, nickname=?3, password=?4, password_blob=?5 WHERE id=?6",
|
"UPDATE bookmarks SET display_name=?1, host=?2, nickname=?3, password=?4, password_blob=?5 WHERE id=?6",
|
||||||
params![b.display_name, b.host, b.nickname, plain, blob, b.id],
|
params![b.display_name, b.host, b.nickname, plain, blob, b.id],
|
||||||
)
|
)
|
||||||
.map_err(|e| StorageError::Sqlite(format!("update: {e}")))?;
|
.map_err(|e| StorageError::sqlite_ctx("update", e))?;
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
Err(StorageError::NotFound)
|
Err(StorageError::NotFound)
|
||||||
} else {
|
} else {
|
||||||
@@ -966,7 +990,7 @@ impl BookmarkRepository {
|
|||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| StorageError::Sqlite("poisoned lock".to_string()))?;
|
.map_err(|_| StorageError::Sqlite("poisoned lock".to_string()))?;
|
||||||
conn.execute("DELETE FROM bookmarks WHERE id = ?1", params![id])
|
conn.execute("DELETE FROM bookmarks WHERE id = ?1", params![id])
|
||||||
.map_err(|e| StorageError::Sqlite(format!("delete: {e}")))?;
|
.map_err(|e| StorageError::sqlite_ctx("delete", e))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -982,7 +1006,7 @@ impl BookmarkRepository {
|
|||||||
.prepare(
|
.prepare(
|
||||||
"SELECT id, display_name, host, nickname, password, password_blob FROM bookmarks ORDER BY id",
|
"SELECT id, display_name, host, nickname, password, password_blob FROM bookmarks ORDER BY id",
|
||||||
)
|
)
|
||||||
.map_err(|e| StorageError::Sqlite(format!("prepare: {e}")))?;
|
.map_err(|e| StorageError::sqlite_ctx("prepare", e))?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map([], |row| {
|
.query_map([], |row| {
|
||||||
let id: i64 = row.get(0)?;
|
let id: i64 = row.get(0)?;
|
||||||
@@ -993,15 +1017,15 @@ impl BookmarkRepository {
|
|||||||
let blob: Option<Vec<u8>> = row.get(5)?;
|
let blob: Option<Vec<u8>> = row.get(5)?;
|
||||||
Ok((id, display_name, host, nickname, plain, blob))
|
Ok((id, display_name, host, nickname, plain, blob))
|
||||||
})
|
})
|
||||||
.map_err(|e| StorageError::Sqlite(format!("query: {e}")))?;
|
.map_err(|e| StorageError::sqlite_ctx("query", e))?;
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for r in rows {
|
for r in rows {
|
||||||
let (id, display_name, host, nickname, plain, blob) =
|
let (id, display_name, host, nickname, plain, blob) =
|
||||||
r.map_err(|e| StorageError::Sqlite(format!("row: {e}")))?;
|
r.map_err(|e| StorageError::sqlite_ctx("row", e))?;
|
||||||
let password = match (blob.as_ref(), self.crypto.as_ref()) {
|
let password = match (blob.as_ref(), self.crypto.as_ref()) {
|
||||||
(Some(b), Some(c)) => Some(
|
(Some(b), Some(c)) => Some(
|
||||||
String::from_utf8(c.decrypt(b)?)
|
String::from_utf8(c.decrypt(b)?)
|
||||||
.map_err(|e| StorageError::Crypto(format!("blob utf8: {e}")))?,
|
.map_err(|e| StorageError::crypto_ctx("blob utf8", e))?,
|
||||||
),
|
),
|
||||||
(Some(_), None) => {
|
(Some(_), None) => {
|
||||||
// We have an encrypted blob but no key. Skip the
|
// We have an encrypted blob but no key. Skip the
|
||||||
|
|||||||
@@ -0,0 +1,737 @@
|
|||||||
|
# File Transfer Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-10
|
||||||
|
**Status:** Draft for review
|
||||||
|
**Scope:** Download files from TeamSpeak-compatible servers via the native client protocol, starting with avatars and icons.
|
||||||
|
**Direct upstream source:** `docs/architecture/sad.md` (SAD-067, SDD-MOD-009)
|
||||||
|
|
||||||
|
## 1. Goal
|
||||||
|
|
||||||
|
Chanora needs to download files stored on TeamSpeak-compatible servers. The most visible use cases are client avatars and server/channel/client icons. The file transfer mechanism is also used for channel file browser features, but this document scopes the initial design to avatar and icon retrieval only.
|
||||||
|
|
||||||
|
This document describes:
|
||||||
|
|
||||||
|
- How the TeamSpeak file transfer protocol works.
|
||||||
|
- How `tsclientlib` exposes it.
|
||||||
|
- How Chanora should integrate it following the existing protocol adapter pattern.
|
||||||
|
- How the result flows through the bridge to the Flutter UI layer.
|
||||||
|
|
||||||
|
Upload, channel file browsing, and file deletion are explicitly out of scope for the initial implementation.
|
||||||
|
|
||||||
|
## 2. Protocol Background
|
||||||
|
|
||||||
|
### 2.1 Two-Phase Transfer
|
||||||
|
|
||||||
|
TeamSpeak file transfer is a two-phase process:
|
||||||
|
|
||||||
|
1. **Command phase** — The client sends a command over the main encrypted UDP connection to request a transfer token (`ftkey`).
|
||||||
|
2. **Transfer phase** — The client opens a separate TCP connection to the server's file transfer port (default `30033`) and sends the `ftkey` to authenticate the transfer. Raw bytes flow over this TCP stream.
|
||||||
|
|
||||||
|
### 2.2 Relevant ServerQuery Commands
|
||||||
|
|
||||||
|
| Command | Direction | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `ftinitdownload` | Client → Server | Initialize a download. Returns `ftkey`, `port`, `size`. |
|
||||||
|
| `ftgetfileinfo` | Client → Server | Get metadata for one or more files. |
|
||||||
|
| `ftgetfilelist` | Client → Server | List files in a channel's file repository. |
|
||||||
|
| `ftinitupload` | Client → Server | Initialize an upload. |
|
||||||
|
| `ftlist` | Client → Server | List active file transfers. |
|
||||||
|
| `ftstop` | Client → Server | Stop a running transfer. |
|
||||||
|
| `ftdeletefile` | Client → Server | Delete a file. |
|
||||||
|
| `ftcreatedir` | Client → Server | Create a directory. |
|
||||||
|
| `ftrenamefile` | Client → Server | Rename or move a file. |
|
||||||
|
|
||||||
|
Initial scope uses only `ftinitdownload` and `ftgetfileinfo`.
|
||||||
|
|
||||||
|
### 2.3 File Paths
|
||||||
|
|
||||||
|
Files are addressed by a path scoped to a channel ID (`cid`):
|
||||||
|
|
||||||
|
- `cid=0` — Server-level file repository. Avatars and icons live here.
|
||||||
|
- `cid=N` (non-zero) — Channel-specific file repository.
|
||||||
|
|
||||||
|
Avatar path: `/avatar_<hex>` where `<hex>` is derived from the client's unique identifier (UID). Each byte of the base64-decoded UID is split into two nibbles, and each nibble maps to a letter `a` through `p` (0→a, 1→b, ..., 15→p).
|
||||||
|
|
||||||
|
Icon path: `/icon_<id>` where `<id>` is the icon's signed 64-bit integer ID. If negative, treat as unsigned for the path.
|
||||||
|
|
||||||
|
### 2.4 `ftinitdownload` Command
|
||||||
|
|
||||||
|
```
|
||||||
|
ftinitdownload clientftfid={id} name={path} cid={channelId} cpw={password} seekpos={seek} proto=0
|
||||||
|
```
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `clientftfid` | `u16` | Arbitrary client-side transfer ID. |
|
||||||
|
| `name` | `string` | File path, e.g. `/avatar_abcdef`. |
|
||||||
|
| `cid` | `ChannelId` | Channel scope (0 = server). |
|
||||||
|
| `cpw` | `string` | Channel password. Empty for server-level. |
|
||||||
|
| `seekpos` | `u64` | Resume offset. 0 for a fresh download. |
|
||||||
|
| `proto` | `u8` | Protocol version. Always 0. |
|
||||||
|
|
||||||
|
Server response:
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `clientftfid` | `u16` | Echo of the client transfer ID. |
|
||||||
|
| `serverftfid` | `u16` | Server-side transfer ID. |
|
||||||
|
| `ftkey` | `string` | One-time transfer key (hex). |
|
||||||
|
| `port` | `u16` | File transfer TCP port (usually 30033). |
|
||||||
|
| `size` | `u64` | File size in bytes. |
|
||||||
|
| `proto` | `u8` | Protocol version echo. |
|
||||||
|
| `ip` | `string` (optional) | Override IP for the TCP connection. |
|
||||||
|
|
||||||
|
### 2.5 TCP Transfer
|
||||||
|
|
||||||
|
After receiving the `ftkey`, the client:
|
||||||
|
|
||||||
|
1. Opens a TCP connection to `server_ip:port`.
|
||||||
|
2. Sends `ftkey` followed by a newline.
|
||||||
|
3. Reads exactly `size` bytes of raw file data.
|
||||||
|
4. Closes the TCP connection.
|
||||||
|
|
||||||
|
### 2.6 Permissions
|
||||||
|
|
||||||
|
File transfer requires the following permissions on the server:
|
||||||
|
|
||||||
|
| Permission | Needed for |
|
||||||
|
|---|---|
|
||||||
|
| `i_ft_file_download_power` | Downloading files. |
|
||||||
|
| `i_ft_needed_file_download_power` | Required download power on the channel/server. |
|
||||||
|
| `b_ft_ignore_password` | Bypassing channel passwords (not needed for avatars). |
|
||||||
|
|
||||||
|
Avatar downloads typically require only basic download power because avatars are in the server-level repository (`cid=0`), which is generally accessible.
|
||||||
|
|
||||||
|
### 2.7 Avatar Detection
|
||||||
|
|
||||||
|
When a client connects or updates, the server sends `client_flag_avatar` as a string (the avatar hash). If non-empty, the client has an avatar. The avatar is downloaded from `/avatar_<hex>` where `<hex>` is computed from the client's UID (not from the hash string itself — the hash is just a presence indicator).
|
||||||
|
|
||||||
|
## 3. tsclientlib Support
|
||||||
|
|
||||||
|
`tsclientlib` implements file transfer natively. The library handles the entire command + TCP flow internally:
|
||||||
|
|
||||||
|
### 3.1 Public API
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// tsclientlib/src/lib.rs (relevant signatures)
|
||||||
|
impl Connection {
|
||||||
|
pub fn download_file(
|
||||||
|
&mut self,
|
||||||
|
channel_id: ChannelId,
|
||||||
|
path: &str,
|
||||||
|
channel_password: Option<&str>,
|
||||||
|
seek_position: Option<u64>,
|
||||||
|
) -> Result<FiletransferHandle>;
|
||||||
|
|
||||||
|
pub fn upload_file(
|
||||||
|
&mut self,
|
||||||
|
channel_id: ChannelId,
|
||||||
|
path: &str,
|
||||||
|
channel_password: Option<&str>,
|
||||||
|
size: u64,
|
||||||
|
overwrite: bool,
|
||||||
|
resume: bool,
|
||||||
|
) -> Result<FiletransferHandle>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`download_file` sends the `ftinitdownload` command and returns a `FiletransferHandle(u16)` immediately. The actual transfer completes asynchronously.
|
||||||
|
|
||||||
|
### 3.2 Stream Items
|
||||||
|
|
||||||
|
The connection's event stream emits:
|
||||||
|
|
||||||
|
| StreamItem | When | Data |
|
||||||
|
|---|---|---|
|
||||||
|
| `StreamItem::FileDownload(FileDownloadResult)` | Server responds with `ftkey`; TCP connected and `ftkey` written | `{ size: u64, stream: TcpStream }` |
|
||||||
|
| `StreamItem::FileUpload(FileUploadResult)` | Upload ready | `{ seek_position: u64, stream: TcpStream }` |
|
||||||
|
| `StreamItem::FiletransferFailed(FiletransferHandle, Error)` | Transfer failed | Handle + error |
|
||||||
|
|
||||||
|
When `FileDownload` fires, tsclientlib has already:
|
||||||
|
|
||||||
|
1. Sent `ftinitdownload` over the encrypted UDP command channel.
|
||||||
|
2. Received the `ftkey`, `port`, and `size` from the server.
|
||||||
|
3. Opened a TCP connection to `server:port`.
|
||||||
|
4. Written the `ftkey` to the TCP socket.
|
||||||
|
|
||||||
|
The `TcpStream` in `FileDownloadResult` is ready to read; Chanora only needs to read exactly `size` bytes.
|
||||||
|
|
||||||
|
### 3.3 Avatar Helper
|
||||||
|
|
||||||
|
`tsproto-types` provides `Uid::as_avatar()` which computes the avatar filename from a UID. Chanora's existing `uid_to_avatar_path()` in `adapter.rs` does the same thing independently.
|
||||||
|
|
||||||
|
### 3.4 Doc-Comment Examples
|
||||||
|
|
||||||
|
tsclientlib's source contains usage examples in doc comments:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Download an icon:
|
||||||
|
/// con.download_file(ChannelId(0), &format!("/icon_{}", icon_id), None, None)
|
||||||
|
|
||||||
|
/// Upload an avatar:
|
||||||
|
/// con.upload_file(ChannelId(0), "/avatar", None, data.len() as u64, true, false)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Architecture Integration
|
||||||
|
|
||||||
|
### 4.1 Existing Pattern
|
||||||
|
|
||||||
|
The protocol adapter (`crates/chanora_protocol`) uses a single tokio task that owns the `tsclientlib::Connection`. All operations follow this pattern:
|
||||||
|
|
||||||
|
1. Define a `Request` enum variant with parameters and a `oneshot::Sender` for the reply.
|
||||||
|
2. Send the request through the `mpsc` channel to the connection task.
|
||||||
|
3. The connection task calls tsclientlib and resolves the oneshot.
|
||||||
|
|
||||||
|
File transfer fits this pattern exactly. The only difference is that the result arrives asynchronously via `StreamItem::FileDownload` rather than immediately from the command call.
|
||||||
|
|
||||||
|
### 4.2 Design
|
||||||
|
|
||||||
|
The file transfer integration adds:
|
||||||
|
|
||||||
|
1. **`Request` variants** for file download.
|
||||||
|
2. **A pending-downloads map** (`HashMap<FiletransferHandle, DownloadContext>`) in the connection task, mirroring the existing `pending_moves` pattern.
|
||||||
|
3. **`StreamItem::FileDownload` and `StreamItem::FiletransferFailed`** handling in the event loop.
|
||||||
|
4. **New DTOs** for file transfer results.
|
||||||
|
5. **Convenience methods** on `ProtocolClient` for avatar and icon downloads.
|
||||||
|
|
||||||
|
### 4.3 Layer Responsibilities
|
||||||
|
|
||||||
|
| Layer | Responsibility |
|
||||||
|
|---|---|
|
||||||
|
| `chanora_protocol` | Call `tsclientlib::download_file`, track pending transfers, read `TcpStream`, return bytes. No tsclientlib types leak. |
|
||||||
|
| `chanora_core` | Orchestrate when to download (e.g., on profile fetch or on avatar cache miss). |
|
||||||
|
| `chanora_bridge` | Expose typed `download_avatar` / `download_icon` commands to Flutter. |
|
||||||
|
| Flutter UI | Call bridge, display with `Image.memory()`. Cache in memory/image cache. |
|
||||||
|
|
||||||
|
### 4.4 Error Mapping
|
||||||
|
|
||||||
|
File transfer errors map to the existing `ProtocolError` variants:
|
||||||
|
|
||||||
|
| tsclientlib error | ProtocolError |
|
||||||
|
|---|---|
|
||||||
|
| Permission denied (TS3 error code) | `ServerRejected { code, message }` |
|
||||||
|
| File not found | `ServerRejected { code, message }` |
|
||||||
|
| Network/TCP failure | `Backend(String)` |
|
||||||
|
| Timeout | `Timeout` |
|
||||||
|
| Connection lost mid-transfer | `Lost(String)` |
|
||||||
|
|
||||||
|
## 5. Detailed Design
|
||||||
|
|
||||||
|
### 5.1 New Types in `dto.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// A downloaded file's raw content and metadata.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct DownloadedFile {
|
||||||
|
/// Raw file bytes.
|
||||||
|
pub data: Vec<u8>,
|
||||||
|
/// The server path that was requested.
|
||||||
|
pub path: String,
|
||||||
|
/// Channel ID the file was downloaded from.
|
||||||
|
pub channel_id: u64,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 New Request Variants in `adapter.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
enum Request {
|
||||||
|
// ... existing variants ...
|
||||||
|
|
||||||
|
/// Download a file from the server's file repository.
|
||||||
|
DownloadFile {
|
||||||
|
/// Channel ID. 0 for server-level (avatars, icons).
|
||||||
|
channel_id: u64,
|
||||||
|
/// File path, e.g. "/avatar_abcdef" or "/icon_12345".
|
||||||
|
path: String,
|
||||||
|
/// Channel password. None for server-level files.
|
||||||
|
channel_password: Option<String>,
|
||||||
|
/// Reply channel for the result.
|
||||||
|
reply: oneshot::Sender<Result<DownloadedFile, ProtocolError>>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Pending Downloads Map
|
||||||
|
|
||||||
|
```rust
|
||||||
|
type PendingDownloads = HashMap<tsclientlib::FiletransferHandle, PendingDownload>;
|
||||||
|
|
||||||
|
struct PendingDownload {
|
||||||
|
path: String,
|
||||||
|
channel_id: u64,
|
||||||
|
reply: oneshot::Sender<Result<DownloadedFile, ProtocolError>>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 Event Loop Handling
|
||||||
|
|
||||||
|
In the connection task's main loop, add handling for file transfer stream items:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// In handle_non_audio_stream_item or in the main loop:
|
||||||
|
StreamItem::FileDownload(result) => {
|
||||||
|
// result: FileDownloadResult { size, stream }
|
||||||
|
// Look up the handle in pending_downloads
|
||||||
|
// Use tokio::io::AsyncReadExt::read_exact to read 'size' bytes
|
||||||
|
// Resolve the oneshot with DownloadedFile
|
||||||
|
}
|
||||||
|
StreamItem::FiletransferFailed(handle, error) => {
|
||||||
|
// Look up the handle in pending_downloads
|
||||||
|
// Resolve the oneshot with ProtocolError::Backend
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The TCP read from the `TcpStream` is an async operation. Since the connection task already runs in a tokio context, the read can be done inline. However, for large files this would block the main event loop. Two approaches:
|
||||||
|
|
||||||
|
**Option A: Read inline (simple, good for small files like avatars)**
|
||||||
|
|
||||||
|
Avatars are typically under 100 KB. Reading them inline in the event loop is acceptable and avoids complexity.
|
||||||
|
|
||||||
|
**Option B: Spawn a reader task**
|
||||||
|
|
||||||
|
For future channel-file-browser support with potentially large files, spawn a separate tokio task that reads the stream and sends the result back.
|
||||||
|
|
||||||
|
**Recommendation:** Start with Option A. The initial scope is avatars and icons (small files). Refactor to Option B when channel file browsing is implemented.
|
||||||
|
|
||||||
|
### 5.5 Request Handling
|
||||||
|
|
||||||
|
When the connection task receives `Request::DownloadFile`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
Ok(Request::DownloadFile { channel_id, path, channel_password, reply }) => {
|
||||||
|
let ts_channel_id = TsChannelId(channel_id);
|
||||||
|
match con.download_file(ts_channel_id, &path, channel_password.as_deref(), None) {
|
||||||
|
Ok(handle) => {
|
||||||
|
pending_downloads.insert(handle, PendingDownload {
|
||||||
|
path,
|
||||||
|
channel_id,
|
||||||
|
reply,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = reply.send(Err(ProtocolError::Backend(
|
||||||
|
format!("download_file init: {e}")
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.6 Public API on `ProtocolClient`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
impl ProtocolClient {
|
||||||
|
/// Download a file from the server's file repository.
|
||||||
|
/// `channel_id` 0 means server-level (avatars, icons).
|
||||||
|
pub async fn download_file(
|
||||||
|
&self,
|
||||||
|
channel_id: u64,
|
||||||
|
path: String,
|
||||||
|
channel_password: Option<String>,
|
||||||
|
) -> Result<DownloadedFile, ProtocolError> {
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
self.tx
|
||||||
|
.send(Request::DownloadFile { channel_id, path, channel_password, reply: tx })
|
||||||
|
.await
|
||||||
|
.map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))?;
|
||||||
|
rx.await
|
||||||
|
.map_err(|_| ProtocolError::Lost("download_file reply dropped".to_string()))?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download a client's avatar image. Returns raw image bytes.
|
||||||
|
/// Pass the `avatar_path` from `ClientProfile`.
|
||||||
|
pub async fn download_avatar(
|
||||||
|
&self,
|
||||||
|
avatar_path: String,
|
||||||
|
) -> Result<DownloadedFile, ProtocolError> {
|
||||||
|
self.download_file(0, avatar_path, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download a server, channel, or client icon by its icon ID.
|
||||||
|
pub async fn download_icon(
|
||||||
|
&self,
|
||||||
|
icon_id: i64,
|
||||||
|
) -> Result<DownloadedFile, ProtocolError> {
|
||||||
|
let unsigned_id = icon_id as u64;
|
||||||
|
let path = format!("/icon_{}", unsigned_id);
|
||||||
|
self.download_file(0, path, None).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.7 Exports in `lib.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub use dto::DownloadedFile;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.8 Bridge Layer
|
||||||
|
|
||||||
|
In `crates/chanora_bridge/src/api.rs`, add:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub async fn download_avatar(&self, avatar_path: String) -> Result<Vec<u8>, BridgeError> {
|
||||||
|
self.protocol
|
||||||
|
.download_avatar(avatar_path)
|
||||||
|
.await
|
||||||
|
.map(|file| file.data)
|
||||||
|
.map_err(BridgeError::Protocol)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.9 Flutter Integration
|
||||||
|
|
||||||
|
Flutter side:
|
||||||
|
|
||||||
|
1. Call `clientProfile()` to get `ClientProfile` (already exists).
|
||||||
|
2. Check if `avatarPath` is non-empty.
|
||||||
|
3. Call bridge `downloadAvatar(avatarPath)` to get `Uint8List`.
|
||||||
|
4. Display with `Image.memory(bytes)`.
|
||||||
|
|
||||||
|
Caching strategy:
|
||||||
|
|
||||||
|
- In-memory: Use Flutter's standard `ImageCache` or a simple `Map<String, Uint8List>` keyed by avatar path.
|
||||||
|
- Disk: Consider caching to local storage for offline display. This is a follow-up decision, not MVP scope.
|
||||||
|
- The avatar path already encodes the UID, so it can serve as a cache key.
|
||||||
|
|
||||||
|
## 6. Avatar Path Computation
|
||||||
|
|
||||||
|
Chanora already has this implemented in `adapter.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn uid_to_avatar_path(uid_b64: &str) -> String {
|
||||||
|
let decoded = BASE64_STANDARD.decode(uid_b64).unwrap_or_default();
|
||||||
|
let mut rendered = String::with_capacity(decoded.len() * 2);
|
||||||
|
for byte in decoded {
|
||||||
|
rendered.push((b'a' + (byte >> 4)) as char);
|
||||||
|
rendered.push((b'a' + (byte & 0x0f)) as char);
|
||||||
|
}
|
||||||
|
rendered
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This maps each nibble to `a` through `p` (0→a, 1→b, ..., 15→p), matching the canonical TeamSpeak implementation.
|
||||||
|
|
||||||
|
The full avatar path is constructed as:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let avatar_path = if client.avatar_hash.is_empty() || unique_id.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("/avatar_{}", uid_to_avatar_path(&unique_id))
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
This is already correct and used in `ClientProfile.avatar_path`. No changes needed.
|
||||||
|
|
||||||
|
## 7. Threading and Concurrency
|
||||||
|
|
||||||
|
| Concern | Design |
|
||||||
|
|---|---|
|
||||||
|
| TCP read blocking the event loop | For avatar/icon sizes (< 100 KB typically), inline async read is acceptable. Spawn a reader task for larger files when channel file browsing is added. |
|
||||||
|
| Multiple concurrent downloads | `pending_downloads` is a HashMap keyed by `FiletransferHandle`. Multiple downloads can be in flight simultaneously. tsclientlib assigns unique handles. |
|
||||||
|
| Download timeout | Add a deadline to pending downloads (e.g., 30 seconds). Sweep expired entries similar to the existing `pending_moves` sweep. |
|
||||||
|
| Cancellation on disconnect | When the connection task exits, all pending oneshot senders are dropped, which resolves the caller's await with a `RecvError`. The caller maps this to `ProtocolError::Lost`. |
|
||||||
|
|
||||||
|
## 8. Diagnostic and Security Considerations
|
||||||
|
|
||||||
|
### 8.1 Diagnostic Redaction
|
||||||
|
|
||||||
|
- File transfer paths may contain user-identifying information (UID-derived avatar names). These should be registered for diagnostic redaction if they appear in log output.
|
||||||
|
- File contents (avatar images) must not appear in log output or diagnostic exports.
|
||||||
|
|
||||||
|
### 8.2 Security
|
||||||
|
|
||||||
|
- The `ftkey` is a one-time token and must not be logged.
|
||||||
|
- TCP file transfer connections are not encrypted. This is a TeamSpeak protocol limitation, not a Chanora design choice. Avatar data is public (visible to anyone on the server), so the risk is acceptable.
|
||||||
|
- File download does not require secrets beyond the existing authenticated connection.
|
||||||
|
|
||||||
|
### 8.3 Privacy
|
||||||
|
|
||||||
|
- Avatar downloads reveal to the server that the user is viewing a specific client's avatar. This is inherent in the protocol.
|
||||||
|
- Chanora should not download avatars proactively for all clients. Download only when the UI needs to display a specific avatar (lazy/on-demand).
|
||||||
|
|
||||||
|
## 9. Out of Scope
|
||||||
|
|
||||||
|
The following are explicitly deferred:
|
||||||
|
|
||||||
|
- File upload (avatar upload, channel file upload).
|
||||||
|
- Channel file browser (listing, creating directories, deleting, renaming).
|
||||||
|
- Resumable downloads (seek position > 0).
|
||||||
|
- File transfer progress reporting.
|
||||||
|
- myTeamSpeak avatar resolution (the `client_myteamspeak_avatar` field).
|
||||||
|
- In-memory hot cache in Rust (Flutter's `ImageCache` handles decoded image caching; add Rust-side layer only if profiling shows need).
|
||||||
|
- Upload, file browser, and channel file management.
|
||||||
|
|
||||||
|
## 10. Cache Architecture
|
||||||
|
|
||||||
|
### 10.1 Layer Ownership
|
||||||
|
|
||||||
|
| Layer | Responsibility | Storage |
|
||||||
|
|---|---|---|
|
||||||
|
| `chanora_protocol` | Download raw bytes from server. No caching logic. | None |
|
||||||
|
| `chanora_cache` | Content-addressed blob store backed by `cacache`: crash-safe writes, SSRI integrity verification, key validation, eviction, clear. Separate crate from `chanora_storage`. | Platform cache directory |
|
||||||
|
| `chanora_core` | Session-aware cache orchestration: check freshness, coalesce requests, rate-limit downloads, persist to disk via `chanora_cache`. | Delegates to `chanora_cache` |
|
||||||
|
| `chanora_bridge` | Expose typed `download_avatar` / `clear_file_cache` / `file_cache_size` to Flutter. | None |
|
||||||
|
| Flutter | Display via `Image.memory`. Standard `ImageCache` for hot memory caching. Evict from `ImageCache` when hash changes. | In-memory only |
|
||||||
|
|
||||||
|
### 10.2 Why Separate `chanora_cache` Crate
|
||||||
|
|
||||||
|
`chanora_cache` is a separate crate from `chanora_storage` for three reasons:
|
||||||
|
|
||||||
|
1. **Different durability semantics.** `chanora_storage` holds identity, bookmarks, and connection profiles — data the user explicitly created. `chanora_cache` holds downloaded blobs that are fully reconstructible from the server. Losing the cache is an inconvenience, not data loss.
|
||||||
|
2. **Different backup semantics.** Cache should be excluded from backups; persistent storage should be included. Platform conventions (iOS `Library/Caches/` vs `Library/Application Support/`) reflect this distinction.
|
||||||
|
3. **Different directory placement.** Cache lives in the platform's cache directory (OS may evict under storage pressure on mobile). Persistent storage lives in the support directory.
|
||||||
|
|
||||||
|
The cache wraps the `cacache` crate for production-tested crash safety and integrity verification. It does not share `chanora_storage`'s crate or directory, and does not reimplement cacache's atomic write or content-addressing logic.
|
||||||
|
|
||||||
|
### 10.3 Why Hybrid (Rust Disk + Flutter Memory)
|
||||||
|
|
||||||
|
- Flutter's built-in `ImageCache` is an LRU in-memory cache (default 1000 images / 100 MiB). It handles hot display caching automatically when you use `MemoryImage`.
|
||||||
|
- Flutter has no built-in disk cache. `cached_network_image` / `flutter_cache_manager` are designed for HTTP URLs, not custom binary protocol data.
|
||||||
|
- Rust already owns the protocol, the connection state, and the anti-flood budget. Putting disk cache here avoids a feedback loop across the bridge.
|
||||||
|
|
||||||
|
### 10.4 Cache Storage
|
||||||
|
|
||||||
|
`chanora_cache` wraps the `cacache` crate for its on-disk storage. The physical layout is managed by `cacache`:
|
||||||
|
|
||||||
|
```
|
||||||
|
<app_cache_dir>/chanora/
|
||||||
|
blobs/ ← cacache content store root
|
||||||
|
content-v2/ ← content-addressed by SHA-512
|
||||||
|
<sha512-hex>/
|
||||||
|
data ← raw blob bytes
|
||||||
|
tmp/ ← temp files (in-flight writes)
|
||||||
|
index-v2/ ← entry index (key → content mapping)
|
||||||
|
```
|
||||||
|
|
||||||
|
Chanora's `BlobCache` maps protocol keys to `cacache` string keys:
|
||||||
|
|
||||||
|
| Protocol key | cacache key | Example |
|
||||||
|
|---|---|---|
|
||||||
|
| Avatar MD5 | `"av_<md5hex>"` | `"av_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"` |
|
||||||
|
| Icon CRC32 | `"ic_<crc32u>"` | `"ic_123456789"` |
|
||||||
|
|
||||||
|
**Why `cacache`:**
|
||||||
|
|
||||||
|
1. **Crash safety.** Production-tested atomic writes (temp → rename). Handles partial writes, power loss, crash mid-write. No custom crash safety code to maintain.
|
||||||
|
2. **Integrity verification.** SSRI integrity check on every `read()`. Detects corruption, bit rot, partial writes automatically. Better than custom "delete on read failure".
|
||||||
|
3. **Content dedup.** Same bytes stored once regardless of key. Same avatar on two servers = stored once automatically.
|
||||||
|
4. **Less code to maintain.** ~120 LOC wrapper vs ~200 LOC custom implementation. Crash safety and integrity are the hard parts — `cacache` owns them.
|
||||||
|
|
||||||
|
**Why no `<server_uid>` subdirectory:** The protocol uses content-addressed identifiers. `client_flag_avatar` is the MD5 of the avatar bytes — a given avatar hash always maps to the same bytes regardless of which server the user is on. Same avatar on two servers = same content = stored once by `cacache`. This is a deliberate dedup advantage over per-server namespacing.
|
||||||
|
|
||||||
|
**Why no metadata sidecars:** Content is immutable (a given hash always maps to the same bytes). `cacache` manages its own entry index with timestamps. No custom metadata files needed.
|
||||||
|
|
||||||
|
**Key validation rules:**
|
||||||
|
|
||||||
|
| Prefix | Key format | Validation |
|
||||||
|
|---|---|---|
|
||||||
|
| `av_` | `av_<32 hex chars>` | MD5 is exactly 32 hex characters |
|
||||||
|
| `ic_` | `ic_<1-10 digit number>` | CRC32 unsigned, 0–4294967295 |
|
||||||
|
|
||||||
|
Keys failing validation are rejected at the `BlobCache` API boundary. This prevents path traversal or malformed filenames on disk.
|
||||||
|
|
||||||
|
**Platform paths** (Flutter passes the base directory into Rust at startup, matching the existing `initStorage` pattern):
|
||||||
|
|
||||||
|
| Platform | Cache directory |
|
||||||
|
|---|---|
|
||||||
|
| Android | `context.cacheDir/chanora/` (via `getCacheDir()`) |
|
||||||
|
| iOS | `Library/Caches/chanora/` (via `getApplicationCacheDirectory()`) |
|
||||||
|
| macOS | `~/Library/Caches/chanora/` |
|
||||||
|
| Windows | `%LOCALAPPDATA%/chanora/cache/` |
|
||||||
|
| Linux | `$XDG_CACHE_HOME/chanora/` or `~/.cache/chanora/` |
|
||||||
|
|
||||||
|
Flutter already resolves platform-specific paths. The same `getApplicationCacheDirectory()` call that is available in `path_provider` across all Chanora target platforms should be used. This follows the existing pattern where `app_bootstrap.dart` calls `getApplicationSupportDirectory()` for persistent storage; avatar/icon cache uses the cache-equivalent directory instead.
|
||||||
|
|
||||||
|
The cache init call is separate from storage init:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Bridge init (Flutter calls these at startup)
|
||||||
|
pub fn init_storage(support_dir: String) -> Result<(), BridgeError>; // existing
|
||||||
|
pub fn init_cache(cache_dir: String) -> Result<(), BridgeError>; // new
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.5 Cache Freshness Strategy
|
||||||
|
|
||||||
|
The `client_flag_avatar` field on each client is the authoritative freshness signal:
|
||||||
|
|
||||||
|
```
|
||||||
|
On connect / on client list update:
|
||||||
|
For each visible client with non-empty avatar_hash:
|
||||||
|
cache_key = "av_<avatar_hash>"
|
||||||
|
if cacache entry exists for "av_<avatar_hash>":
|
||||||
|
use cached file (zero downloads)
|
||||||
|
else:
|
||||||
|
enqueue download for avatar_path with expected hash = avatar_hash
|
||||||
|
|
||||||
|
On avatar_hash change for a client:
|
||||||
|
The new hash produces a different cacache key.
|
||||||
|
The old entry remains until eviction or manual clear.
|
||||||
|
The new file is downloaded on demand.
|
||||||
|
```
|
||||||
|
|
||||||
|
This means:
|
||||||
|
|
||||||
|
- **First connect:** No cache hits. Downloads happen lazily as the UI requests avatars.
|
||||||
|
- **Reconnect to same server:** All avatars hit cache instantly (hashes match keys). Zero downloads.
|
||||||
|
- **User changes avatar:** New hash = new cache key. Old entry becomes orphan. New file downloads on next UI request.
|
||||||
|
- **Same user on different server:** Same avatar hash = same cached file. Cross-server dedup for free.
|
||||||
|
|
||||||
|
### 10.6 Anti-Flood and Download Timing
|
||||||
|
|
||||||
|
TeamSpeak servers enforce anti-flood rate limiting. Downloading all avatars eagerly on connect would trigger it on servers with many users.
|
||||||
|
|
||||||
|
**Strategy: lazy + throttled prefetch**
|
||||||
|
|
||||||
|
| Phase | What | Rate |
|
||||||
|
|---|---|---|
|
||||||
|
| Connect settle (first 2-5 s) | Do nothing. Let the initial state snapshot and channel tree arrive. | — |
|
||||||
|
| After settle | UI requests avatars for visible clients in the current channel. These trigger downloads one at a time. | Max 1-2 concurrent downloads per server |
|
||||||
|
| Channel switch | UI requests avatars for newly visible clients. | Same throttle |
|
||||||
|
| Background prefetch (optional, future) | Low-priority downloads for clients in adjacent channels. | 1 request per 500 ms |
|
||||||
|
|
||||||
|
**Anti-flood handling:**
|
||||||
|
|
||||||
|
- If the server responds with an anti-flood error (TS3 error code `0x0701` = `client_could_not_be_banned` / flood-related), back off the download queue.
|
||||||
|
- Implement a simple semaphore in `chanora_core`: max 1-2 concurrent downloads.
|
||||||
|
- If a download gets a flood error, pause the queue for 5 seconds, then resume at reduced rate.
|
||||||
|
|
||||||
|
### 10.7 Retry on Failure
|
||||||
|
|
||||||
|
| Failure type | Strategy |
|
||||||
|
|---|---|
|
||||||
|
| Transient (network timeout, TCP reset) | Retry with exponential backoff: 5 s, 30 s, 2 min, 10 min. Cap at 10 min. |
|
||||||
|
| Server flood limit hit | Pause queue 5 s, then resume at reduced rate. Do not count as a per-file retry. |
|
||||||
|
| Permission denied (no download power) | Do not retry. Record negative cache entry. Only retry if hash changes. |
|
||||||
|
| File not found (avatar removed) | Do not retry. Record negative cache entry. Clear when hash changes or becomes empty. |
|
||||||
|
| Connection lost | All pending downloads fail. On reconnect, cache check runs fresh with current hashes. |
|
||||||
|
|
||||||
|
**Negative cache:** In-memory `HashMap<String, Instant>` with 5-minute TTL. Keys like `"av_<hash>"` or `"ic_<id>"` that received permanent errors are stored with an expiry. On lookup, expired entries are treated as absent. Cleared entirely on reconnect.
|
||||||
|
|
||||||
|
### 10.8 Request Coalescing
|
||||||
|
|
||||||
|
Multiple UI widgets may request the same avatar simultaneously (e.g., channel list + chat view + client info sheet).
|
||||||
|
|
||||||
|
**Pattern:** In `chanora_core`'s `FileTransferService`, maintain an in-flight map:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
HashMap<String, tokio::task::JoinHandle<Result<Vec<u8>, FileTransferError>>>
|
||||||
|
```
|
||||||
|
|
||||||
|
- First request: start download, store handle.
|
||||||
|
- Subsequent requests for same key: await the same handle.
|
||||||
|
- When handle completes: write to cache, wake all waiters, remove from map.
|
||||||
|
|
||||||
|
### 10.9 Cache Eviction and Size Limits
|
||||||
|
|
||||||
|
**MVP approach:**
|
||||||
|
|
||||||
|
- No automatic size-based eviction in MVP. Avatars are small (typically 10-100 KB). Even 1000 avatars = ~50-100 MB.
|
||||||
|
- Rely on platform cache directory semantics (OS may evict under storage pressure on mobile).
|
||||||
|
- Old hash files accumulate but are harmless.
|
||||||
|
|
||||||
|
**Post-MVP:**
|
||||||
|
|
||||||
|
- `BlobCache::evict(max_bytes)` — walk `cacache::ls()` entries, sort by timestamp (oldest first), delete until total size < `max_bytes`. `cacache` manages timestamps internally. No metadata sidecars needed.
|
||||||
|
- Or simpler: `BlobCache::evict_older_than(duration)` — delete entries with timestamp older than N days.
|
||||||
|
- Call on startup and periodically (e.g., every 24 hours or on app resume).
|
||||||
|
|
||||||
|
### 10.10 User-Initiated Cache Clear
|
||||||
|
|
||||||
|
Add a bridge method:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn clear_file_cache(&self) -> Result<(), BridgeError> {
|
||||||
|
// Delete the entire blobs/ directory contents
|
||||||
|
// Flutter evicts all avatar/icon-related entries from ImageCache
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn file_cache_size(&self) -> Result<u64, BridgeError> {
|
||||||
|
// Walk blobs/ and sum file sizes
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Flutter side:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// In settings or storage management UI:
|
||||||
|
onPressed: () async {
|
||||||
|
await api.clearFileCache();
|
||||||
|
PaintingBinding.instance.imageCache.clear();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This should be exposed in the app's settings UI under a "Clear cache" or "Storage management" section.
|
||||||
|
|
||||||
|
### 10.11 Storage Clear Across Servers
|
||||||
|
|
||||||
|
Since the cache is flat with content-addressed keys (no server namespacing):
|
||||||
|
|
||||||
|
- Connecting to a different server does not conflict — same avatar hash = same file.
|
||||||
|
- Avatars unique to the old server remain cached. If a user on the new server has the same avatar (same hash), it hits cache instantly (cross-server dedup).
|
||||||
|
- Cache clear removes all cached data regardless of which server it came from.
|
||||||
|
|
||||||
|
### 10.12 Flutter Display Strategy
|
||||||
|
|
||||||
|
**Option A: Bytes across bridge (simpler, recommended for MVP)**
|
||||||
|
|
||||||
|
Rust returns `Vec<u8>` across the bridge. Flutter uses `Image.memory(bytes)`.
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final bytes = await api.downloadAvatar(clientUid: uid);
|
||||||
|
if (bytes != null && bytes.isNotEmpty) {
|
||||||
|
return Image.memory(Uint8List.fromList(bytes));
|
||||||
|
} else {
|
||||||
|
return CircleAvatar(child: Text(initials)); // fallback
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Flutter's `ImageCache` caches the decoded image in memory automatically. Same avatar bytes = cache hit in memory.
|
||||||
|
|
||||||
|
**Option B: File path across bridge (better for large images, future)**
|
||||||
|
|
||||||
|
Rust writes to disk and returns the file path. Flutter uses `FileImage`.
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final path = await api.getAvatarPath(avatarHash: hash);
|
||||||
|
if (path != null) {
|
||||||
|
return Image.file(File(path));
|
||||||
|
} else {
|
||||||
|
return CircleAvatar(child: Text(initials));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`FileImage` does not watch for file changes. When the hash changes, the UI must evict the old entry from `ImageCache` using `PaintingBinding.instance.imageCache.evict(key)`.
|
||||||
|
|
||||||
|
**Recommendation:** Start with Option A for MVP. It avoids file-path cross-platform complications and works well for small avatar files. The bridge already returns `Vec<u8>` for the download result.
|
||||||
|
|
||||||
|
## 11. Implementation Sequence
|
||||||
|
|
||||||
|
| Phase | Scope | What |
|
||||||
|
|---|---|---|
|
||||||
|
| Phase 1 | Protocol download | `Request::DownloadFile`, `StreamItem::FileDownload` handling, `ProtocolClient::download_avatar()` / `download_icon()`. No caching. |
|
||||||
|
| Phase 2 | Bridge + Flutter display | Bridge `downloadAvatar()`, Flutter `Image.memory()`, initials fallback. Still no caching — every view re-downloads. |
|
||||||
|
| Phase 3 | Rust disk cache | New `chanora_cache` crate: `cacache`-backed content-addressed blob store (`BlobCache`), key validation, mtime-based eviction, `init_cache` bridge call. |
|
||||||
|
| Phase 4 | Session orchestration | `chanora_core` `FileTransferService`: request coalescing, rate limiter (semaphore), negative cache (5 min TTL), retry backoff. |
|
||||||
|
| Phase 5 | Cache management | Bridge `clearFileCache()` + `fileCacheSize()`, Flutter settings UI, eviction on startup. |
|
||||||
|
|
||||||
|
Phase 1 and 2 deliver visible value (avatars in the UI). Phase 3-5 add robustness.
|
||||||
|
|
||||||
|
## 12. References
|
||||||
|
|
||||||
|
| Reference | Use |
|
||||||
|
|---|---|
|
||||||
|
| `ReSpeak/tsdeclarations` `Messages.toml` lines 828-830 | `ftinitdownload` command declaration |
|
||||||
|
| `ReSpeak/tsdeclarations` `Messages.toml` lines 590 | `FileDownload` response structure |
|
||||||
|
| `ReSpeak/tsdeclarations` `ts3protocol.md` | Low-level TeamSpeak protocol specification |
|
||||||
|
| `ReSpeak/tsclientlib` `src/lib.rs` lines 956-1005 | `download_file` / `upload_file` public API |
|
||||||
|
| `ReSpeak/tsclientlib` `src/lib.rs` lines 1371-1427 | `StreamItem::FileDownload` handling |
|
||||||
|
| `ReSpeak/tsclientlib` `src/lib.rs` lines 1630-1672 | Outgoing init commands |
|
||||||
|
| `Multivit4min/TS3-NodeJS-Library` `src/transport/FileTransfer.ts` | Reference TCP transfer implementation |
|
||||||
|
| `Speckmops/ts3admin.class` `lib/ts3admin.class.php` lines 1352-1370 | Reference avatar download flow |
|
||||||
|
| `docs/architecture/sad.md` SAD-067, SDD-MOD-009 | Protocol adapter boundary rules |
|
||||||
|
| `crates/chanora_protocol/src/adapter.rs` lines 1561-1568 | Existing `uid_to_avatar_path` implementation |
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,770 @@
|
|||||||
|
# File Transfer Cache Research
|
||||||
|
|
||||||
|
**Date:** 2026-06-10
|
||||||
|
**Status:** Research complete, design implications noted (updated with TeaSpeak server findings)
|
||||||
|
**Companion to:** `docs/architecture/file-transfer-design.md`
|
||||||
|
**Purpose:** Factual findings from protocol analysis, existing client implementations, and cross-platform research that inform the cache architecture decision.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. TS3 Protocol Identity Semantics
|
||||||
|
|
||||||
|
### 1.1 Avatar Identity
|
||||||
|
|
||||||
|
| Aspect | Value |
|
||||||
|
|---|---|
|
||||||
|
| Protocol field | `client_flag_avatar` |
|
||||||
|
| Type | `TYPE_STRING` (TeaSpeakLibrary `PropertyDefinition.h:200`) |
|
||||||
|
| Meaning | MD5 hash of the avatar file bytes |
|
||||||
|
| Scope | Per-client per-server — a user can have different avatars on different servers |
|
||||||
|
| Freshness | Automatically up-to-date for any client "in view" (`FLAG_CLIENT_VIEW`) |
|
||||||
|
| Empty value | No avatar set |
|
||||||
|
|
||||||
|
**Key fact:** Identical avatar image bytes produce the **same** `client_flag_avatar` hash on any TS3 server. The hash is a content fingerprint, not a server-assigned identifier.
|
||||||
|
|
||||||
|
**Download path on server:** `/avatar_<base64HashClientUID>` — the filename is derived from the client's unique identifier (UID), not from the content hash. The content hash is communicated separately via `client_flag_avatar`.
|
||||||
|
|
||||||
|
**Sources:**
|
||||||
|
- TeaSpeakLibrary `PropertyDefinition.h:200`: `PropertyDescription{CLIENT_FLAG_AVATAR, "client_flag_avatar", "", TYPE_STRING, FLAG_CLIENT_VIEW | FLAG_SAVE | FLAG_USER_EDITABLE}`
|
||||||
|
- TS3AudioBot avatar upload: computes MD5 of image bytes, then sets `client_flag_avatar` to that hash ([`TS3AudioBot/TSLib/TsBaseFunctions.cs:324-341`](https://github.com/Splamy/TS3AudioBot/blob/a69a38d8cba5a4d671dbe06505506f6b46f1d947/TSLib/TsBaseFunctions.cs#L324-L341))
|
||||||
|
- TS3 NodeJS Library: avatar filename is `avatar_${clientBase64HashClientUID}` ([`TS3-NodeJS-Library/src/node/Client.ts:300-313`](https://github.com/Multivit4min/TS3-NodeJS-Library/blob/0c69b7ee80fa5b74e9175cf4ae3018346f7eb300/src/node/Client.ts#L300-L313))
|
||||||
|
- TS3 PHP Framework: avatar name derivation from UID ([`ts3phpframework/src/Node/Client.php:288-307`](https://github.com/planetteamspeak/ts3phpframework/blob/87046b3d493c4d3d8064c639ea4269571192e476/src/Node/Client.php#L288-L307))
|
||||||
|
|
||||||
|
### 1.2 Icon Identity
|
||||||
|
|
||||||
|
| Aspect | Value |
|
||||||
|
|---|---|
|
||||||
|
| Protocol fields | `channel_icon_id`, `client_icon_id`, `virtualserver_icon_id` |
|
||||||
|
| Type | `TYPE_UNSIGNED_NUMBER` (TeaSpeakLibrary `PropertyDefinition.h:83,146,217`) |
|
||||||
|
| Meaning | CRC32 (unsigned) of the icon file bytes |
|
||||||
|
| Scope | Per-entity per-server — but CRC32 is content-derived |
|
||||||
|
| Download path | `/icon_<unsigned_crc32>` |
|
||||||
|
|
||||||
|
**Key fact:** Identical icon bytes produce the **same** CRC32 on any TS3 server. The icon ID is a content fingerprint. The upload process computes `crc32.unsigned(data)` and stores at `/icon_<id>`.
|
||||||
|
|
||||||
|
**CRC32 collision caveat:** CRC32 is only 32 bits. Different icon content can theoretically produce the same CRC32. Qint's `filecache.rs` explicitly notes this: "there could be collisions because only CRC-32 is used." ForChanora's purposes (small icons, not security-critical), this is acceptable.
|
||||||
|
|
||||||
|
**Sources:**
|
||||||
|
- TeaSpeakLibrary `PropertyDefinition.h:83,146,217`: all icon IDs are `TYPE_UNSIGNED_NUMBER`
|
||||||
|
- TS3 NodeJS Library `uploadIcon()`: computes `crc32.unsigned(data)`, uploads to `/icon_<id>` ([`TS3-NodeJS-Library/src/TeamSpeak.ts:2234-2241`](https://github.com/Multivit4min/TS3-NodeJS-Library/blob/0c69b7ee80fa5b74e9175cf4ae3018346f7eb300/src/TeamSpeak.ts#L2234-L2241))
|
||||||
|
- TS3 PHP Framework: icon path uses `/icon_<unsigned id>` ([`ts3phpframework/src/Node/Node.php:137-146`](https://github.com/planetteamspeak/ts3phpframework/blob/87046b3d493c4d3d8064c639ea4269571192e476/src/Node/Node.php#L137-L146))
|
||||||
|
- TS3 community forum: "The filename itself is the result of the CRC32 checksum" (TeamSpeak staff)
|
||||||
|
|
||||||
|
### 1.3 Implication for Cache Design
|
||||||
|
|
||||||
|
Both avatars and icons are **content-addressed by the protocol itself**:
|
||||||
|
|
||||||
|
| Asset | Content hash source | Same content across servers? |
|
||||||
|
|---|---|---|
|
||||||
|
| Avatar | `client_flag_avatar` = MD5 of bytes | Same bytes → same hash → same ID |
|
||||||
|
| Icon | `icon_id` = CRC32 of bytes | Same bytes → same CRC32 → same ID |
|
||||||
|
|
||||||
|
This means a **flat content-addressed blob store** can achieve zero-duplication without any per-server directories, hardlinks, or ref-counting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Virtual Server Identity
|
||||||
|
|
||||||
|
### 2.1 Server UID
|
||||||
|
|
||||||
|
| Aspect | Value |
|
||||||
|
|---|---|
|
||||||
|
| Protocol field | `virtualserver_unique_identifier` |
|
||||||
|
| Type | `TYPE_STRING` (TeaSpeakLibrary `PropertyDefinition.h:22`) |
|
||||||
|
| Generated by | The server instance, on creation |
|
||||||
|
| Globally unique? | **Not guaranteed** — locally generated, no central registry |
|
||||||
|
| Stable? | Yes — persists across restarts of the same virtual server |
|
||||||
|
|
||||||
|
**Key fact:** `virtualserver_unique_identifier` is generated by each TS3 server. Two physically different servers could theoretically produce the same UID. It is **not safe as a global cache key**.
|
||||||
|
|
||||||
|
### 2.2 What Chanora Currently Tracks
|
||||||
|
|
||||||
|
| Layer | Server identity fields | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| Protocol adapter (`adapter.rs:1752-1755`) | `server_name`, `welcome_message`, `platform`, `version` | `state.server.*` from tsclientlib |
|
||||||
|
| DTO (`ServerSnapshot`) | `server_name`, `welcome_message`, `platform`, `version` | No UID field |
|
||||||
|
| Bridge (`BridgeSnapshot`) | Same as DTO | Same |
|
||||||
|
| Storage (bookmarks) | Keyed by `host` (hostname:port) | SQLite `WHERE host = ?1` |
|
||||||
|
| Core (recent servers) | `cfg.address` as host | Auto-saved on connect |
|
||||||
|
|
||||||
|
**Chanora does not currently plumb `virtualserver_unique_identifier` through the DTO stack.** The field exists in tsclientlib's state but is not extracted.
|
||||||
|
|
||||||
|
### 2.3 Implication for Cache Design
|
||||||
|
|
||||||
|
Using `virtualserver_unique_identifier` as the sole cache key is risky (not globally unique). Using connection address (`host:port`) is safe but duplicates cache entries when the same server is accessed via different addresses.
|
||||||
|
|
||||||
|
**Recommendation:** For a content-addressed blob store, server identity is only needed for per-server metadata (eviction, "clear cache for this server"), not for the blob key itself. The blob key is the content hash.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Existing TS3 Client Cache Implementations
|
||||||
|
|
||||||
|
### 3.1 Qint (tsclientlib-based, Tauri + Rust)
|
||||||
|
|
||||||
|
**Architecture:** Per-server directory with SQLite metadata.
|
||||||
|
|
||||||
|
```
|
||||||
|
<cache>/files/<server-uid>/<channel-id>/<base64(path)>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Avatar handling:**
|
||||||
|
- Avatar state stored per `(server, client)` row in SQLite
|
||||||
|
- On avatar hash change, deletes the cached `/avatar_<uid>` file for that server
|
||||||
|
- Avatar download path: `/avatar_<uid_base64>`
|
||||||
|
|
||||||
|
**Icon handling:**
|
||||||
|
- Icons path-cached with CRC32
|
||||||
|
- Code comments note CRC32 collisions and freshness by mtime
|
||||||
|
- Qint explicitly deletes and re-downloads when icon mtime changes
|
||||||
|
|
||||||
|
**Dedup:** None. Same avatar on 5 servers = 5 stored copies.
|
||||||
|
|
||||||
|
**Sources:**
|
||||||
|
- [`Qint/proxy/src/filecache.rs`](https://github.com/ReSpeak/Qint/blob/7efe949adfa1a1ecb9d185e7740e015da18cc41b/proxy/src/filecache.rs#L1-L6): "Stores files transferred via the TS3 file transfer protocol. This includes icons and avatars."
|
||||||
|
- [`Qint/proxy/src/db/mod.rs`](https://github.com/ReSpeak/Qint/blob/7efe949adfa1a1ecb9d185e7740e015da18cc41b/proxy/src/db/mod.rs#L1158-L1176): avatar hash change triggers delete
|
||||||
|
- [`Qint/src-tauri/src/cmd.rs`](https://github.com/ReSpeak/Qint/blob/7efe949adfa1a1ecb9d185e7740e015da18cc41b/src-tauri/src/cmd.rs#L524-L539): file download command
|
||||||
|
|
||||||
|
### 3.2 TS3 Official Client (closed source)
|
||||||
|
|
||||||
|
**Architecture:** Lazy cache with SDK callbacks.
|
||||||
|
|
||||||
|
- `getAvatar()` returns cached path if present; otherwise triggers download
|
||||||
|
- `onAvatarUpdated` callback fires when avatar is downloaded or deleted
|
||||||
|
- Cache paths (from community documentation):
|
||||||
|
- Windows: `%LOCALAPPDATA%\TeamSpeak\Cache\Default`
|
||||||
|
- Linux: `~/.cache/TeamSpeak/Default`
|
||||||
|
- macOS: `~/Library/Caches/TeamSpeak/Default`
|
||||||
|
- SDK also exposes `CLIENT_MYTS_AVATAR` / `client_myteamspeak_avatar` for cross-server myTeamSpeak avatars
|
||||||
|
|
||||||
|
**Sources:**
|
||||||
|
- [`ts3client-pluginsdk/src/plugin.c`](https://github.com/teamspeak/ts3client-pluginsdk/blob/4aa90a53aa150cbf81e13bc97e68c0431b26499f/src/plugin.c#L384-L396): `getAvatar()` and `onAvatarUpdated`
|
||||||
|
- [`ts3client-pluginsdk/public_rare_definitions.h`](https://github.com/teamspeak/ts3client-pluginsdk/blob/4aa90a53aa150cbf81e13bc97e68c0431b26499f/include/teamspeak/public_rare_definitions.h#L284-L313): `CLIENT_FLAG_AVATAR`, `CLIENT_MYTS_AVATAR`
|
||||||
|
- Community: [clear cache](https://community.teamspeak.com/t/clear-cache/41511), [broken icons](https://community.teamspeak.com/t/server-icons-are-displaying-a-broken-image-issues-with-local-cache/58680)
|
||||||
|
|
||||||
|
### 3.3 TeaSpeak Client (TypeScript + C++ native)
|
||||||
|
|
||||||
|
**Architecture:** Browser Cache API for images, per-server own-avatar storage.
|
||||||
|
|
||||||
|
**Key components (from `.d.ts` type declarations):**
|
||||||
|
|
||||||
|
- `AvatarManager` — per-connection (`FileManager`) avatar handler
|
||||||
|
- `cachedAvatars` (private) — in-memory cache of `ClientAvatar` objects
|
||||||
|
- `updateCache(clientAvatarId, clientAvatarHash)` — updates cache when hash changes
|
||||||
|
- `resolveAvatar(clientAvatarId, avatarHash?, cacheOnly?)` — resolves avatar by ID
|
||||||
|
- `flush_cache()` — clears cache
|
||||||
|
- `create_avatar_download(client_avatar_id)` — initiates file transfer
|
||||||
|
|
||||||
|
- `ClientAvatar` — tracks individual avatar state
|
||||||
|
- `clientAvatarId` — derived from client UID via `uniqueId2AvatarId()`
|
||||||
|
- `currentAvatarHash` — the `client_flag_avatar` value
|
||||||
|
- State machine: `unset` → `loading` → `loaded` / `errored`
|
||||||
|
- `loadingTimestamp` — when download started
|
||||||
|
|
||||||
|
- `ImageCache` — generic image cache using browser Cache API
|
||||||
|
- `resolveCached(key, maxAge?)` — check if cached
|
||||||
|
- `putCache(key, value, type?, headers?)` — store
|
||||||
|
- `cleanup(maxAge)` — evict old entries
|
||||||
|
- `reset()` — clear all
|
||||||
|
- `isPersistent()` — whether cache persists to disk
|
||||||
|
|
||||||
|
- `OwnAvatarStorage` — user's own avatar, keyed by `serverUniqueId + mode`
|
||||||
|
- `loadAvatarImage(serverUniqueId, mode)` — load own avatar for a server
|
||||||
|
- `updateAvatar(serverUniqueId, mode, target)` — update own avatar
|
||||||
|
- `avatarUploadSucceeded(serverUniqueId)` — move from "uploading" to "server" state
|
||||||
|
- Stores `LocalAvatarInfo`: fileName, fileSize, **fileHashMD5**, timestamps, contentType
|
||||||
|
|
||||||
|
- `FileManager` — per-connection file transfer manager
|
||||||
|
- `MAX_CONCURRENT_TRANSFERS` — transfer concurrency limit
|
||||||
|
- `avatars: AvatarManager` — avatar subsystem
|
||||||
|
- `initializeFileDownload(options)` — start download (path, name, channel, target)
|
||||||
|
- `deleteIcon(iconId: number)` — delete icon by ID
|
||||||
|
|
||||||
|
- `FileTransfer` — transfer state machine
|
||||||
|
- States: `PENDING → INITIALIZING → CONNECTING → RUNNING → FINISHED / ERRORED / CANCELED`
|
||||||
|
- `InitializedTransferProperties`: serverTransferId, transferKey, **addresses[]**, protocol, seekOffset, fileSize
|
||||||
|
- Multiple addresses returned by server for file transfer (failover)
|
||||||
|
|
||||||
|
- `localIconCache: ImageCache` — global icon cache (singleton)
|
||||||
|
|
||||||
|
**Sources:**
|
||||||
|
- TeaSpeak-Client `imports/shared-app/file/Avatars.d.ts` — ClientAvatar, AbstractAvatarManager
|
||||||
|
- TeaSpeak-Client `imports/shared-app/file/LocalAvatars.d.ts` — AvatarManager
|
||||||
|
- TeaSpeak-Client `imports/shared-app/file/LocalIcons.d.ts` — localIconCache
|
||||||
|
- TeaSpeak-Client `imports/shared-app/file/ImageCache.d.ts` — ImageCache (browser Cache API)
|
||||||
|
- TeaSpeak-Client `imports/shared-app/file/FileManager.d.ts` — FileManager, transfer API
|
||||||
|
- TeaSpeak-Client `imports/shared-app/file/Transfer.d.ts` — FileTransfer, state machine, error types
|
||||||
|
- TeaSpeak-Client `imports/shared-app/file/OwnAvatarStorage.d.ts` — own avatar per-server storage
|
||||||
|
- TeaSpeak-Client `native/serverconnection/test/js/ft.ts` — file transfer test (TCP + ftkey protocol)
|
||||||
|
|
||||||
|
### 3.4 TS3AudioBot (C#)
|
||||||
|
|
||||||
|
**Architecture:** No local avatar cache. Avatar upload is hash-driven.
|
||||||
|
|
||||||
|
- Uploads avatar bytes to `/avatar`, computes MD5, sets `client_flag_avatar` to that hash
|
||||||
|
- Bot avatar selection reads local files from an `avatars/` directory
|
||||||
|
- No caching of other users' avatars
|
||||||
|
|
||||||
|
**Sources:**
|
||||||
|
- [`TS3AudioBot/TSLib/TsBaseFunctions.cs:324-341`](https://github.com/Splamy/TS3AudioBot/blob/a69a38d8cba5a4d671dbe06505506f6b46f1d947/TSLib/TsBaseFunctions.cs#L324-L341)
|
||||||
|
- [`TS3AudioBot/Bot.cs:420-470`](https://github.com/Splamy/TS3AudioBot/blob/a69a38d8cba5a4d671dbe06505506f6b46f1d947/TS3AudioBot/Bot.cs#L420-L470)
|
||||||
|
|
||||||
|
### 3.5 TS3 NodeJS Library
|
||||||
|
|
||||||
|
**Architecture:** No local cache. Downloads on demand.
|
||||||
|
|
||||||
|
- Avatar filename: `avatar_${clientBase64HashClientUID}`
|
||||||
|
- `getAvatar()` downloads directly — no caching layer
|
||||||
|
- Tests assert the exact `/avatar_<base64uid>` path
|
||||||
|
|
||||||
|
**Sources:**
|
||||||
|
- [`TS3-NodeJS-Library/src/node/Client.ts:300-313`](https://github.com/Multivit4min/TS3-NodeJS-Library/blob/0c69b7ee80fa5b74e9175cf4ae3018346f7eb300/src/node/Client.ts#L300-L313)
|
||||||
|
- [`TS3-NodeJS-Library/tests/Client.spec.ts:342-358`](https://github.com/Multivit4min/TS3-NodeJS-Library/blob/0c69b7ee80fa5b74e9175cf4ae3018346f7eb300/tests/Client.spec.ts#L342-L358)
|
||||||
|
|
||||||
|
### 3.6 Summary Table
|
||||||
|
|
||||||
|
| Client | Cache Key Strategy | Dedup Across Servers? | Icon Cache |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Qint** | `<server-uid>/<channel-id>/<path>` | No | Yes (CRC32, mtime freshness) |
|
||||||
|
| **TS3 Official** | Lazy cache (path-based) | Unknown | Yes |
|
||||||
|
| **TeaSpeak** | UID-derived avatar ID + browser Cache API | Implicit (same hash = same cache) | Yes (global ImageCache) |
|
||||||
|
| **TS3AudioBot** | None | N/A | No |
|
||||||
|
| **TS3 NodeLib** | None | N/A | No |
|
||||||
|
| **Chanora (decided)** | Content hash (MD5/CRC32), flat `blobs/` in `chanora_cache` crate | **Yes** | Yes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. TeaSpeak Protocol Definitions (Authoritative)
|
||||||
|
|
||||||
|
From TeaSpeakLibrary `src/PropertyDefinition.h` — the most complete open-source reference for TS3 protocol property types:
|
||||||
|
|
||||||
|
### 4.1 Avatar Properties
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Line 200
|
||||||
|
PropertyDescription{CLIENT_FLAG_AVATAR, "client_flag_avatar", "",
|
||||||
|
TYPE_STRING, FLAG_CLIENT_VIEW | FLAG_SAVE | FLAG_USER_EDITABLE}
|
||||||
|
// "automatically up-to-date for any manager 'in view', this manager got an avatar"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Icon Properties
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Line 83 — server icon
|
||||||
|
PropertyDescription{VIRTUALSERVER_ICON_ID, "virtualserver_icon_id", "0",
|
||||||
|
TYPE_UNSIGNED_NUMBER, FLAG_SERVER_VVSS | FLAG_USER_EDITABLE}
|
||||||
|
|
||||||
|
// Line 146 — channel icon
|
||||||
|
PropertyDescription{CHANNEL_ICON_ID, "channel_icon_id", "0",
|
||||||
|
TYPE_UNSIGNED_NUMBER, FLAG_CHANNEL_VIEW | FLAG_SS | FLAG_USER_EDITABLE}
|
||||||
|
|
||||||
|
// Line 217 — client icon
|
||||||
|
PropertyDescription{CLIENT_ICON_ID, "client_icon_id", "0",
|
||||||
|
TYPE_UNSIGNED_NUMBER, FLAG_CLIENT_VIEW | FLAG_CLIENT_VARIABLE}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Server Identity
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Line 22
|
||||||
|
PropertyDescription{VIRTUALSERVER_UNIQUE_IDENTIFIER,
|
||||||
|
"virtualserver_unique_identifier", "",
|
||||||
|
TYPE_STRING, FLAG_SERVER_VV | FLAG_SNAPSHOT}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 File Transfer Permissions
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// From PermissionManager.cpp
|
||||||
|
PermissionType::i_client_max_avatar_filesize // "Max avatar filesize in bytes"
|
||||||
|
PermissionType::b_client_avatar_delete_other // "Allow deletion of avatars from other clients"
|
||||||
|
PermissionType::b_ft_transfer_list // "Retrieve list of running filetransfers"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 File Transfer Error Codes
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// From Error.h
|
||||||
|
channel_no_filetransfer_supported = 0x30C
|
||||||
|
file_transfer_connection_timeout = 0x80E
|
||||||
|
file_transfer_complete = 0x811
|
||||||
|
file_transfer_canceled = 0x812
|
||||||
|
file_transfer_interrupted = 0x813
|
||||||
|
file_transfer_server_quota_exceeded = 0x814
|
||||||
|
file_transfer_client_quota_exceeded = 0x815
|
||||||
|
file_transfer_reset = 0x816
|
||||||
|
file_transfer_limit_reached = 0x817
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Cross-Platform Filesystem Research
|
||||||
|
|
||||||
|
### 5.1 Hardlink Support
|
||||||
|
|
||||||
|
| Platform | Filesystem | Hardlinks in App-Private Storage? | Gotcha |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Android (API 28+) | ext4 / f2fs | **Yes** | Rust uses `libc::link`; not FUSE-mounted; same-filesystem only |
|
||||||
|
| iOS | APFS | **Yes** (writable sandbox dirs) | App bundle is read-only; avoid hardlinks to bundle assets |
|
||||||
|
| macOS | APFS | **Yes** | — |
|
||||||
|
| Linux | ext4 / btrfs / xfs | **Yes** | — |
|
||||||
|
| Windows | NTFS | **Yes** | Rust uses `CreateHardLinkW` |
|
||||||
|
|
||||||
|
**`std::fs::hard_link` gotchas (all platforms):**
|
||||||
|
- Same filesystem required
|
||||||
|
- Destination must not exist (returns error)
|
||||||
|
- Symlink behavior is platform-specific
|
||||||
|
- All hardlinks share the same inode — modifying one modifies all
|
||||||
|
- Files must be treated as **immutable** for hardlink safety
|
||||||
|
|
||||||
|
**Sources:**
|
||||||
|
- Rust stdlib: `hard_link` maps to `libc::link` (Unix), `CreateHardLinkW` (Windows) ([Rust source](https://github.com/rust-lang/rust/blob/beae781308e9ddef13074a03faf57ca2fac59a5b/library/std/src/fs.rs#L2898-L2900))
|
||||||
|
- Android: internal storage uses ext4/f2fs, not FUSE ([Android scoped storage docs](https://source.android.com/docs/core/storage/scoped))
|
||||||
|
- iOS: APFS supports hardlinks; writable sandbox directories work ([Apple FileSystem basics](https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/FileSystemOverview/FileSystemOverview.html))
|
||||||
|
|
||||||
|
### 5.2 Rust Cache Libraries
|
||||||
|
|
||||||
|
**`cacache`** (MIT licensed, production-ready):
|
||||||
|
- Content-addressed disk cache
|
||||||
|
- Automatic dedup, atomic writes, integrity verification
|
||||||
|
- Exposes `hard_link`, `copy`, and `reflink` paths for retrieval
|
||||||
|
- On-disk layout: `content-v2/sha512/...`
|
||||||
|
- Could replace a custom implementation, but adds a dependency
|
||||||
|
|
||||||
|
**Sources:**
|
||||||
|
- [`cacache-rs` README](https://github.com/zkat/cacache-rs/blob/105692a4daa04ce5f5ef3f8688cd3e1c1fb6a7c0/README.md#L39-L60)
|
||||||
|
- [`cacache-rs` content path](https://github.com/zkat/cacache-rs/blob/105692a4daa04ce5f5ef3f8688cd3e1c1fb6a7c0/src/content/path.rs#L6-L19)
|
||||||
|
- [`cacache-rs` hard_link impl](https://github.com/zkat/cacache-rs/blob/105692a4daa04ce5f5ef3f8688cd3e1c1fb6a7c0/src/content/read.rs#L257-L285)
|
||||||
|
|
||||||
|
### 5.3 Flutter Cache Patterns
|
||||||
|
|
||||||
|
Common Flutter packages use **cache-dir + metadata DB**, not hardlink dedup:
|
||||||
|
- `flutter_cache_manager`: files in cache dir + `sqflite` metadata
|
||||||
|
- `super_cache_disk`: file-per-entry (`.dat` + `.meta`) in app cache dir
|
||||||
|
|
||||||
|
**Sources:**
|
||||||
|
- [flutter_cache_manager on pub.dev](https://pub.dev/packages/flutter_cache_manager)
|
||||||
|
- [super_cache_disk on pub.dev](https://pub.dev/packages/super_cache_disk/versions/1.0.0)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Chanora Codebase Context
|
||||||
|
|
||||||
|
### 6.1 Storage Patterns
|
||||||
|
|
||||||
|
| Component | Pattern | Location |
|
||||||
|
|---|---|---|
|
||||||
|
| Identity storage | Atomic write (temp + `sync_all` + `rename`), mode 0600 on Unix | `chanora_storage/src/lib.rs:446-476` |
|
||||||
|
| Metadata | Same atomic write pattern | `chanora_storage/src/lib.rs:480-515` |
|
||||||
|
| Bookmarks | SQLite at `<storage_dir>/chanora.db`, keyed by `host` | `chanora_storage/src/lib.rs:782-928` |
|
||||||
|
| Storage root | `getApplicationSupportDirectory()` from Flutter | `app_bootstrap.dart:19-24` |
|
||||||
|
| Bridge init | `rust.initStorage(dir: dir)` | `app_bootstrap.dart:23-25,108-133` |
|
||||||
|
|
||||||
|
### 6.2 Existing Avatar Handling
|
||||||
|
|
||||||
|
| Component | What | Location |
|
||||||
|
|---|---|---|
|
||||||
|
| UID to avatar path | `uid_to_avatar_path()` — base64 decode UID, encode each byte as 2 chars (a-p) | `adapter.rs:1561-1568` |
|
||||||
|
| Client profile DTO | `avatar_path` field — set when `client.avatar_hash` and `unique_id` non-empty | `dto.rs:135-136`, `adapter.rs:1323-1332` |
|
||||||
|
| No download | Currently no file download implementation exists | — |
|
||||||
|
| No icon handling | No icon field/path in protocol DTO or adapter | — |
|
||||||
|
|
||||||
|
### 6.3 Server Identity in Chanora
|
||||||
|
|
||||||
|
Chanora currently tracks servers by **connection address** (`host:port`), not by server UID:
|
||||||
|
|
||||||
|
- Bookmarks: `WHERE host = ?1`
|
||||||
|
- Recent servers: auto-saved by `cfg.address`
|
||||||
|
- Prefetch cache: keyed by normalized host
|
||||||
|
- ServerSnapshot: has `server_name` but no `server_uid`
|
||||||
|
|
||||||
|
The `virtualserver_unique_identifier` field is available from tsclientlib's state but is **not extracted** by the adapter.
|
||||||
|
|
||||||
|
### 6.4 tsclientlib File Transfer API
|
||||||
|
|
||||||
|
| Aspect | Detail |
|
||||||
|
|---|---|
|
||||||
|
| Download method | `Connection::download_file()` |
|
||||||
|
| Stream items | `StreamItem::FileDownload(FileDownloadResult { size, stream })` |
|
||||||
|
| Failure | `StreamItem::FiletransferFailed(handle, error)` |
|
||||||
|
| TCP handling | tsclientlib handles TCP connection + ftkey writing automatically |
|
||||||
|
| Chanora's job | Read `size` bytes from the returned `TcpStream` |
|
||||||
|
| Async behavior | `StreamItem::FileDownload` fires asynchronously, not inline with the request |
|
||||||
|
|
||||||
|
### 6.5 ts-bookkeeping Generated Fields
|
||||||
|
|
||||||
|
From the generated parser in `target/debug/build/ts-bookkeeping-*/out/`:
|
||||||
|
|
||||||
|
- `virtual_server_id: u64` — numeric, per-virtual-server, may change across restarts
|
||||||
|
- `virtual_server_uid` — string, the `virtualserver_unique_identifier`
|
||||||
|
|
||||||
|
Both are available in the `InInitServer` struct from the init handshake but are not currently plumbed through.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. TeaSpeak Server Internals (Authoritative)
|
||||||
|
|
||||||
|
Source: TeaSpeak Server at `https://git.did.science/TeaSpeak/Server/Server` (branch `new-groups`, commit `b54c6d4e`).
|
||||||
|
|
||||||
|
### 7.1 Avatar ID Derivation — Server Side
|
||||||
|
|
||||||
|
The server derives the avatar filename from the **client UID**, not from the avatar content:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// DataClient.cpp:242-244
|
||||||
|
std::string DataClient::getAvatarId() {
|
||||||
|
return hex::hex(base64::validate(this->getUid()) ? base64::decode(this->getUid()) : this->getUid(), 'a', 'q');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The same transform produces `client_base64HashClientUID` (shown to other clients):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// client.cpp:1113-1114
|
||||||
|
bulk.put_unchecked("client_base64HashClientUID",
|
||||||
|
hex::hex(base64::validate(info->client_unique_id) ? base64::decode(info->client_unique_id) : info->client_unique_id, 'a', 'q'));
|
||||||
|
```
|
||||||
|
|
||||||
|
**This matches Chanora's existing `uid_to_avatar_path()` in `adapter.rs:1561-1568`.**
|
||||||
|
|
||||||
|
### 7.2 Avatar Upload Path
|
||||||
|
|
||||||
|
When a client uploads an avatar, the server stores it as `/avatar_<avatarId>`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// file.cpp:696-702
|
||||||
|
} else if (cmd["path"].as<std::string>().empty() && cmd["name"].string() == "/avatar") {
|
||||||
|
...
|
||||||
|
info.file_path = "/avatar_" + this->getAvatarId();
|
||||||
|
transfer_response = file::server()->file_transfer().initialize_avatar_transfer(...);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The avatar file path is identity-based (from UID), not content-based.
|
||||||
|
|
||||||
|
### 7.3 `client_flag_avatar` — Who Computes the Hash?
|
||||||
|
|
||||||
|
**The CLIENT computes the MD5 and sends it to the server during upload.** The server stores it as a string property (`FLAG_USER_EDITABLE`). The server does NOT compute or verify the hash.
|
||||||
|
|
||||||
|
This means `client_flag_avatar` is:
|
||||||
|
- Set by the uploading client
|
||||||
|
- Stored verbatim by the server
|
||||||
|
- Broadcast to other clients as part of the client properties
|
||||||
|
- A reliable content fingerprint: same avatar bytes → same MD5 → same `client_flag_avatar` on any server
|
||||||
|
|
||||||
|
### 7.4 Icon IDs — Server Does NOT Compute CRC32
|
||||||
|
|
||||||
|
Icon IDs are **permission values**, not content hashes computed by the server:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// ConnectedClient.cpp:186-210 — client icon ID from permissions
|
||||||
|
auto permission_flags = local_permissions->permission_flags(permission::i_icon_id);
|
||||||
|
new_icon_id = value.value;
|
||||||
|
updated_client_properties.emplace_back(property::CLIENT_ICON_ID);
|
||||||
|
```
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// channel.cpp:1495-1504 — channel icon ID
|
||||||
|
if(key == property::CHANNEL_ICON_ID) {
|
||||||
|
auto icon_id = converter<uint32_t>::from_string_view(value);
|
||||||
|
channel->permissions()->set_permission(permission::i_icon_id, { ... icon_id ... });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// server.cpp:76-89 — server icon ID
|
||||||
|
SERVEREDIT_CHK_PROP_CACHED("virtualserver_icon_id", permission::b_virtualserver_modify_icon_id, int64_t)
|
||||||
|
```
|
||||||
|
|
||||||
|
**The CLIENT computes the CRC32 during upload and uses it as the filename `/icon_<crc32>`.** The server stores the file and records the ID as a permission value. No server-side CRC32 or MD5 computation exists.
|
||||||
|
|
||||||
|
### 7.5 Per-Server Storage Layout
|
||||||
|
|
||||||
|
Avatars and icons are stored **per virtual server** on the server's filesystem:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// LocalFileSystem.cpp:39-45
|
||||||
|
fs::path LocalFileSystem::server_path(const std::shared_ptr<VirtualFileServer> &server) {
|
||||||
|
return fs::u8path(this->root_path_) / fs::u8path("server_" + std::to_string(server->server_id()));
|
||||||
|
}
|
||||||
|
// target_path = this->server_path(server) / "icons" / path;
|
||||||
|
// target_path = this->server_path(server) / "avatars" / path;
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
<server_root>/
|
||||||
|
server_<sid>/
|
||||||
|
avatars/
|
||||||
|
/avatar_<avatarId> ← one per client who uploaded
|
||||||
|
icons/
|
||||||
|
/icon_<id> ← one per unique icon
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.6 File Transfer Protocol (Server Side)
|
||||||
|
|
||||||
|
Upload/delete/query routing:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// file.cpp:273-341 — delete routing
|
||||||
|
if (first_entry_name.find("/icon_") == 0 && file_path.empty()) { ... delete_icons(...); }
|
||||||
|
else if (first_entry_name.starts_with("/avatar_") && file_path.empty()) { ... delete_avatars(...); }
|
||||||
|
```
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// file.cpp:483-523 — query routing
|
||||||
|
if (first_entry_name.find("/icon_") == 0 && file_path.empty()) { ... query_icon_info(...); }
|
||||||
|
else if (first_entry_name.starts_with("/avatar_") && file_path.empty()) { ... query_avatar_info(...); }
|
||||||
|
```
|
||||||
|
|
||||||
|
Transfer initialization returns ftkey and metadata:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// file.cpp:759-761
|
||||||
|
result.put_unchecked(0, "ftkey", transfer->transfer_key);
|
||||||
|
result.put_unchecked(0, "seekpos", transfer->file_offset);
|
||||||
|
```
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// file.cpp:887-899
|
||||||
|
result.put_unchecked(0, "ftkey", transfer->transfer_key);
|
||||||
|
result.put_unchecked(0, "proto", "1");
|
||||||
|
result.put_unchecked(0, "size", transfer->expected_file_size);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.7 Key Takeaway for Chanora
|
||||||
|
|
||||||
|
| Who does what | Avatar | Icon |
|
||||||
|
|---|---|---|
|
||||||
|
| **Uploader (client)** computes | MD5 of avatar bytes → sets `client_flag_avatar` | CRC32 of icon bytes → filename `/icon_<crc32>` |
|
||||||
|
| **Server** does | Stores file as `/avatar_<uid>`, saves property | Stores file as `/icon_<id>`, saves permission |
|
||||||
|
| **Other clients** receive | `client_flag_avatar` (MD5) as a property update | `icon_id` (CRC32) as a property update |
|
||||||
|
| **Chanora cache key** | `av_<md5>.dat` — content fingerprint | `ic_<crc32>.dat` — content fingerprint |
|
||||||
|
|
||||||
|
The content hash is computed once (by the uploader) and then broadcast as a property. Chanora never needs to hash anything — it just uses the protocol-provided values as cache keys.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Design Implications
|
||||||
|
|
||||||
|
### 8.1 The Core Insight
|
||||||
|
|
||||||
|
The TS3 protocol provides content hashes as part of normal server-to-client updates:
|
||||||
|
|
||||||
|
| Event | Data provided by server | What Chanora gets for free |
|
||||||
|
|---|---|---|
|
||||||
|
| Client enters view | `client_flag_avatar` = MD5 of avatar bytes | Content key for blob store |
|
||||||
|
| Channel update | `channel_icon_id` = CRC32 of icon bytes | Content key for blob store |
|
||||||
|
| Server update | `virtualserver_icon_id` = CRC32 of icon bytes | Content key for blob store |
|
||||||
|
|
||||||
|
No hashing needed on the client side. The protocol is **already content-addressed**.
|
||||||
|
|
||||||
|
### 8.2 Recommended Cache Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
<app_cache_dir>/chanora/
|
||||||
|
blobs/ ← cacache content store root
|
||||||
|
content-v2/ ← content-addressed by SHA-512
|
||||||
|
<sha512-hex>/data ← raw blob bytes
|
||||||
|
index-v2/ ← key → content mapping
|
||||||
|
```
|
||||||
|
|
||||||
|
Where `<app_cache_dir>` is the platform cache directory (not the support directory used by `chanora_storage`). Chanora's `BlobCache` maps protocol keys (`av_<md5>`, `ic_<crc32>`) to `cacache` string keys. Physical layout is managed by `cacache`.
|
||||||
|
|
||||||
|
**Lookup flow:**
|
||||||
|
1. Server sends `client_flag_avatar = "a1b2c3d4..."` for user X
|
||||||
|
2. Check: does `blobs/av_a1b2c3d4....dat` exist?
|
||||||
|
3. Yes → use it, zero downloads (works for ANY server)
|
||||||
|
4. No → download from `/avatar_<uid_base64>` → save as `blobs/av_a1b2c3d4....dat`
|
||||||
|
|
||||||
|
**Same for icons with `ic_<crc32>.dat`.**
|
||||||
|
|
||||||
|
### 8.3 Why This Beats Alternatives
|
||||||
|
|
||||||
|
| Approach | Dedup | Globally unique key | Needs server UID plumbing | Needs hardlinks | Complexity |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `<server_uid>/<hash>.dat` | No | No (UID not guaranteed unique) | Yes | Optional | Medium |
|
||||||
|
| `<host>_<port>/<hash>.dat` | No | Yes | No | Optional | Medium |
|
||||||
|
| `<host>_<port>/<hash>.dat` + hardlinks | Yes | Yes | No | Yes | Medium-High |
|
||||||
|
| **`blobs/av_<hash>.dat` (flat)** | **Yes** | **Yes (content hash)** | **No** | **No** | **Low** |
|
||||||
|
|
||||||
|
### 8.4 Trade-offs
|
||||||
|
|
||||||
|
| Pro | Con |
|
||||||
|
|---|---|
|
||||||
|
| Zero duplication across all servers | "Clear cache for server X only" requires metadata layer (Phase 3+) |
|
||||||
|
| No hardlinks needed | Orphan cleanup requires scanning for unreferenced blobs |
|
||||||
|
| No server UID plumbing needed | Cannot distinguish same-hash-different-content for icons (CRC32 collision) |
|
||||||
|
| Simplest possible implementation | — |
|
||||||
|
| Freshness = hash change = different filename (automatic) | — |
|
||||||
|
| Cross-platform (just file I/O) | — |
|
||||||
|
|
||||||
|
### 8.5 Phased Implementation
|
||||||
|
|
||||||
|
| Phase | What | Delivers |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Protocol download (raw bytes via adapter, no cache) | Working download pipeline |
|
||||||
|
| 2 | Bridge + Flutter display (`Image.memory()`) | Visible avatars in UI |
|
||||||
|
| 3 | `chanora_cache` crate: cacache-backed blob cache, separate crate, cache dir, mtime eviction | Zero re-downloads, zero duplication |
|
||||||
|
| 4 | Session orchestration (coalescing, rate limiting, negative cache) | Anti-flood, robustness |
|
||||||
|
| 5 | Cache management (clear all, orphan cleanup, optional per-server metadata) | User control |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Resolved Questions
|
||||||
|
|
||||||
|
### Q1: Icon CRC32 Collisions — Accept with Size Guard
|
||||||
|
|
||||||
|
**Risk assessment:** CRC32 produces a 32-bit hash. For N unique icons, the Birthday paradox gives collision probability ≈ N² / (2 × 2³²).
|
||||||
|
|
||||||
|
| Icons (N) | Collision probability |
|
||||||
|
|---|---|
|
||||||
|
| 100 | ~0.0001% (negligible) |
|
||||||
|
| 1,000 | ~0.01% (negligible) |
|
||||||
|
| 10,000 | ~1.2% (marginal) |
|
||||||
|
| 65,536 | ~50% (likely) |
|
||||||
|
|
||||||
|
A single user typically encounters fewer than 1,000 unique icons across all servers. The practical collision risk is negligible.
|
||||||
|
|
||||||
|
**What happens on collision:** Wrong icon displayed for a channel/client/server. This is a visual glitch, not a security issue. The icon will appear incorrect until the cache is cleared.
|
||||||
|
|
||||||
|
**Existing practice:** Qint explicitly notes CRC32 collisions (`filecache.rs:4`) but does NOT guard against them — they only refresh icons by mtime. No other TS3 client guards against CRC32 collisions.
|
||||||
|
|
||||||
|
**Recommendation:** Accept CRC32 as the cache key. Add a lightweight **file size guard**: when downloading an icon, if `ic_<crc32>.dat` already exists but has a different size than the `ftinitdownload` response reported, re-download. File size is available from the protocol (`msg.size` in `InFileDownloadPart`). This catches most collisions (different content = different size with high probability) without computing a secondary hash.
|
||||||
|
|
||||||
|
**Decision:** CRC32 + file size guard. No SHA256 overhead needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Q2: `client_myteamspeak_avatar` — Defer Indefinitely
|
||||||
|
|
||||||
|
**What it is:** A string property (`Option<String>` in ts-bookkeeping) broadcast alongside `client_flag_avatar`. It represents a myTeamSpeak cross-server avatar — a user linked to a myTeamSpeak account can set a global avatar that follows them across all servers.
|
||||||
|
|
||||||
|
**Current state in Chanora's dependency chain:**
|
||||||
|
- ts-bookkeeping exposes it: `InInitServer` has `my_team_speak_avatar: Option<String>`
|
||||||
|
- TeaSpeakLibrary tracks `client_myteamspeak_id` but not the avatar
|
||||||
|
- tsclientlib exposes it as a property on client state
|
||||||
|
|
||||||
|
**Value for Chanora:**
|
||||||
|
- myTeamSpeak is a TeamSpeak-specific cloud service (account sync, cross-server features)
|
||||||
|
- Chanora is an independent client — no myTeamSpeak account integration is planned
|
||||||
|
- The property may contain a URL or identifier that requires myTeamSpeak API access to resolve
|
||||||
|
- Without myTeamSpeak integration, the avatar cannot be fetched
|
||||||
|
|
||||||
|
**Recommendation:** Defer indefinitely. If Chanora ever integrates myTeamSpeak accounts, this can be handled as a separate avatar source (URL-based HTTP download) alongside the existing protocol-based avatar download. The cache architecture supports this — just add a different blob prefix (e.g., `mt_<hash>.dat`).
|
||||||
|
|
||||||
|
**Decision:** Out of scope for MVP and foreseeable roadmap.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Q3: Cache Backing Store — `cacache` Wrapper in Separate `chanora_cache` Crate
|
||||||
|
|
||||||
|
**Decision:** Use `cacache` as the backing store inside `chanora_cache`. Not a custom flat-file implementation.
|
||||||
|
|
||||||
|
**Why cacache won over custom:**
|
||||||
|
|
||||||
|
1. **Crash safety is production-tested.** `cacache` handles partial writes, power loss, crash mid-write. A custom implementation would need to get `sync_all` + atomic rename right — one bug = corrupted cache. Even though cache data is disposable (reconstructible from server), `cacache` eliminates this entire class of bugs.
|
||||||
|
|
||||||
|
2. **Less code to maintain.** ~120 LOC wrapper vs ~200 LOC custom implementation. The hard parts (atomic writes, integrity, content dedup) are owned by `cacache`, tested by the npm ecosystem.
|
||||||
|
|
||||||
|
3. **Integrity verification on every read.** SSRI verification detects corruption, bit rot, partial writes automatically. A custom impl would need to add this separately or accept silent corruption.
|
||||||
|
|
||||||
|
4. **Content dedup by SHA-512.** Same avatar on two servers = stored once automatically. The protocol's MD5/CRC32 keys map to `cacache` string keys; content dedup happens at the SHA-512 layer underneath.
|
||||||
|
|
||||||
|
**What about the downsides:**
|
||||||
|
|
||||||
|
| Concern | Assessment |
|
||||||
|
|---|---|
|
||||||
|
| ~6 transitive deps | `sha2` already in tree via `chacha20poly1305`. `serde_json`, `tempfile`, `digest` are lightweight. Acceptable for the safety benefit. |
|
||||||
|
| SHA-512 overhead on every write/read | For <100KB avatars, SHA-512 takes ~0.1ms. Negligible. |
|
||||||
|
| Opaque on-disk format | `cacache` provides `ls()` API for enumeration and inspection. Not as simple as `ls blobs/` but adequate. |
|
||||||
|
| `cacache` has no built-in LRU eviction | We write a custom eviction pass using `cacache::ls()` + timestamp sort. ~20 lines. Same complexity as custom impl's eviction. |
|
||||||
|
|
||||||
|
**Separate crate rationale:**
|
||||||
|
|
||||||
|
- `chanora_cache` is separate from `chanora_storage` because cache data has different durability semantics (disposable vs persistent), different backup semantics (excluded vs included), and different directory placement (cache dir vs support dir).
|
||||||
|
- `chanora_cache` lives in the platform's cache directory (`getApplicationCacheDirectory()`). `chanora_storage` lives in the support directory (`getApplicationSupportDirectory()`).
|
||||||
|
- Bridge init is separate: `init_cache(cache_dir)` vs `init_storage(support_dir)`.
|
||||||
|
|
||||||
|
**API design:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct BlobCache { cache_dir: PathBuf, max_bytes: u64 }
|
||||||
|
impl BlobCache {
|
||||||
|
pub fn new(cache_dir: impl AsRef<Path>, max_bytes: u64) -> Result<Self, BlobCacheError>;
|
||||||
|
pub async fn put(&self, prefix: &str, key: &str, data: &[u8]) -> Result<(), BlobCacheError>;
|
||||||
|
pub async fn get(&self, prefix: &str, key: &str) -> Result<Option<Vec<u8>>, BlobCacheError>;
|
||||||
|
pub async fn remove(&self, prefix: &str, key: &str) -> Result<(), BlobCacheError>;
|
||||||
|
pub async fn clear(&self) -> Result<(), BlobCacheError>;
|
||||||
|
pub async fn total_size(&self) -> Result<u64, BlobCacheError>;
|
||||||
|
pub async fn evict(&self) -> Result<(), BlobCacheError>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All methods are async (cacache is async-native). Key validation at API boundary (`av_` = 32 hex chars, `ic_` = decimal digits).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Q4: Per-Blob Metadata — No Metadata Sidecars (Resolved)
|
||||||
|
|
||||||
|
**Original options:**
|
||||||
|
|
||||||
|
| Approach | Pros | Cons |
|
||||||
|
|---|---|---|
|
||||||
|
| SQLite (chanora.db) | ACID, queryable, already in use | Schema migration, couples cache to bookmark DB |
|
||||||
|
| JSON sidecar files | Simple, self-contained, easy to debug | Write amplification (2 files per blob), concurrent write risk |
|
||||||
|
| In-memory only | Simplest | Lost on restart, can't do orphan cleanup offline |
|
||||||
|
| **No metadata (mtime-based)** | **Simplest, zero write amplification, 1 file per blob** | **No per-blob metadata beyond mtime** |
|
||||||
|
|
||||||
|
**Why no metadata is sufficient:**
|
||||||
|
|
||||||
|
1. **Content is immutable.** A given hash (MD5 or CRC32) always maps to the same bytes. There is no "stale content" problem — if the hash changes, it's a new file with a new name. No invalidation needed.
|
||||||
|
|
||||||
|
2. **mtime = insertion time.** Since content is never modified after write, the filesystem mtime equals the time the blob was cached. This is sufficient for "delete oldest files first" eviction.
|
||||||
|
|
||||||
|
3. **Write amplification avoided.** One file per blob (just the data) instead of two (data + JSON sidecar). For a cache that may hold thousands of small files, this matters.
|
||||||
|
|
||||||
|
4. **Eviction is simple.** `walk dir → stat → sort by mtime → delete oldest`. No JSON parsing, no schema, no migration.
|
||||||
|
|
||||||
|
5. **Per-server metadata deferred.** "Clear cache for server X only" and orphan cleanup are post-MVP features. If needed, a refs-layer can be added later without changing the blob layout.
|
||||||
|
|
||||||
|
**Oracle consultation:** Oracle recommended this approach explicitly — no metadata files, mtime-based eviction, separate crate. The immutability guarantee makes metadata redundant.
|
||||||
|
|
||||||
|
**Decision:** No metadata sidecars. One file per blob. Mtime-based eviction. Per-server metadata deferred to post-MVP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Q5: File Transfer Address Failover — Not Needed
|
||||||
|
|
||||||
|
**What the protocol provides:**
|
||||||
|
|
||||||
|
The TeaSpeak client's `InitializedTransferProperties` returns `addresses[]` — an array of `{serverAddress, serverPort}`. The official TS3 client can try multiple addresses for failover.
|
||||||
|
|
||||||
|
**What tsclientlib provides:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// tsclientlib/src/lib.rs:1373-1375
|
||||||
|
let ip = msg.ip.unwrap_or_else(|| self.client.address.ip());
|
||||||
|
let addr = SocketAddr::new(ip, msg.port);
|
||||||
|
TcpStream::connect(&addr).await
|
||||||
|
```
|
||||||
|
|
||||||
|
tsclientlib's `InFileDownloadPart` has `ip: Option<IpAddr>` — **single IP only**, not an array. If the server provides an IP, it uses that. Otherwise, it falls back to the connection address. **No multi-address failover.**
|
||||||
|
|
||||||
|
**What ts-bookkeeping parses:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct InFileDownloadPart {
|
||||||
|
pub client_filetransfer_id: u16,
|
||||||
|
pub server_filetransfer_id: u16,
|
||||||
|
pub filetransfer_key: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub size: u64,
|
||||||
|
pub protocol: u8,
|
||||||
|
pub ip: Option<IpAddr>, // ← single optional IP
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**The server's `notifystartdownload` response** sends `ip` as an optional single value, not an array. The TeaSpeak client's `addresses[]` is a higher-level abstraction (likely the client's own fallback logic), not a protocol feature.
|
||||||
|
|
||||||
|
**Recommendation:** Chanora follows tsclientlib's existing behavior — use `msg.ip` or fallback to connection address. No custom failover logic needed. If the TCP connection fails, the download fails and retries follow the exponential backoff strategy from the design doc.
|
||||||
|
|
||||||
|
**Decision:** Single address (from tsclientlib). No failover needed.
|
||||||
@@ -50,6 +50,7 @@ Chanora is a Flutter application with a Rust core. Flutter owns the user-facing
|
|||||||
| Diagnostics | `crates/chanora_diagnostics` | Redaction, log sink, export bundle, known-secret registry | Rust core, Flutter diagnostics UI |
|
| Diagnostics | `crates/chanora_diagnostics` | Redaction, log sink, export bundle, known-secret registry | Rust core, Flutter diagnostics UI |
|
||||||
| Server resolver | `crates/chanora_resolver` | SRV/TSDNS/DNS fallback resolution | Rust core, prefetch crate |
|
| Server resolver | `crates/chanora_resolver` | SRV/TSDNS/DNS fallback resolution | Rust core, prefetch crate |
|
||||||
| Server prefetch | `crates/chanora_prefetch`, Flutter `prefetch_debouncer.dart` | Invisible host-field resolution warming, TTL cache, generation safety | Resolver, Flutter connect UI, Rust core |
|
| Server prefetch | `crates/chanora_prefetch`, Flutter `prefetch_debouncer.dart` | Invisible host-field resolution warming, TTL cache, generation safety | Resolver, Flutter connect UI, Rust core |
|
||||||
|
| Cache | `crates/chanora_cache` | Typed caching layer for server resolution results and other transient data | Rust core, resolver, prefetch |
|
||||||
|
|
||||||
## 5. Static Architecture View
|
## 5. Static Architecture View
|
||||||
|
|
||||||
@@ -63,7 +64,7 @@ Flutter UI/widgets/services
|
|||||||
-> chanora_audio -> platform audio APIs / Opus / DSP
|
-> chanora_audio -> platform audio APIs / Opus / DSP
|
||||||
-> chanora_storage -> platform secure storage / SQLite
|
-> chanora_storage -> platform secure storage / SQLite
|
||||||
-> chanora_diagnostics
|
-> chanora_diagnostics
|
||||||
-> chanora_prefetch -> chanora_resolver -> network DNS/TSDNS
|
-> chanora_cache -> chanora_prefetch -> chanora_resolver -> network DNS/TSDNS
|
||||||
```
|
```
|
||||||
|
|
||||||
The bridge is the trust and type boundary between Flutter and Rust. Flutter must not directly depend on protocol-library internals. Rust core must not expose platform-specific storage or audio details to UI code except through stable DTOs and capability fields.
|
The bridge is the trust and type boundary between Flutter and Rust. Flutter must not directly depend on protocol-library internals. Rust core must not expose platform-specific storage or audio details to UI code except through stable DTOs and capability fields.
|
||||||
@@ -123,7 +124,7 @@ Runtime event or error
|
|||||||
| Bridge command DTOs | Flutter generated API | `chanora_bridge`, Rust core | Stable typed DTOs; no raw protocol-library types cross to Flutter |
|
| Bridge command DTOs | Flutter generated API | `chanora_bridge`, Rust core | Stable typed DTOs; no raw protocol-library types cross to Flutter |
|
||||||
| Bridge event DTOs | Rust core / bridge | Flutter services/widgets | User-safe errors and capability fields are explicit |
|
| Bridge event DTOs | Rust core / bridge | Flutter services/widgets | User-safe errors and capability fields are explicit |
|
||||||
| Protocol DTOs | `chanora_protocol` | Rust core, state sync | Protocol adapter isolates `tsclientlib` |
|
| Protocol DTOs | `chanora_protocol` | Rust core, state sync | Protocol adapter isolates `tsclientlib` |
|
||||||
| Audio configuration | Flutter settings / Rust core | `chanora_audio` | Voice modes and processing flags are explicit; VAD remains disabled/deferred |
|
| Audio configuration | Flutter settings / Rust core | `chanora_audio` | Voice modes and processing flags are explicit; Windows/Linux desktop VAD-backed `VoiceActivity` is enabled only where runtime evidence exists, with unsupported platforms disabled/deferred |
|
||||||
| Storage records | Storage crate | Rust core / Flutter UI via bridge | Secrets stay behind secure-storage abstraction |
|
| Storage records | Storage crate | Rust core / Flutter UI via bridge | Secrets stay behind secure-storage abstraction |
|
||||||
| Diagnostic bundles | Diagnostics crate | Flutter diagnostics UI | Redaction runs before export or display |
|
| Diagnostic bundles | Diagnostics crate | Flutter diagnostics UI | Redaction runs before export or display |
|
||||||
| Platform capability records | Platform adapters/audio/PTT backends | UI and release record | UI/release wording must not over-claim capability |
|
| Platform capability records | Platform adapters/audio/PTT backends | UI and release record | UI/release wording must not over-claim capability |
|
||||||
@@ -159,7 +160,7 @@ Runtime event or error
|
|||||||
| Secure storage abstraction | Platform storage details do not leak into UI or unrelated crates |
|
| Secure storage abstraction | Platform storage details do not leak into UI or unrelated crates |
|
||||||
| Advisory audio benchmarks | Performance regressions are surfaced without making CI a hard release gate at this stage |
|
| Advisory audio benchmarks | Performance regressions are surfaced without making CI a hard release gate at this stage |
|
||||||
| PTT capability levels | Platform PTT support is represented as capability data and must match release wording |
|
| PTT capability levels | Platform PTT support is represented as capability data and must match release wording |
|
||||||
| VoiceActivity deferral | `VoiceActivity` remains reserved/disabled until a later baseline allocates implementation |
|
| VoiceActivity platform scope | Windows/Linux desktop `VoiceActivity` is implemented through the capture VAD path; unsupported platforms remain disabled/deferred until backend allocation and runtime verification exist |
|
||||||
| No automatic diagnostic upload in MVP | Diagnostics are local and user-initiated unless future approved requirements change policy |
|
| No automatic diagnostic upload in MVP | Diagnostics are local and user-initiated unless future approved requirements change policy |
|
||||||
|
|
||||||
## 11. Verification Handoff
|
## 11. Verification Handoff
|
||||||
|
|||||||
@@ -21,14 +21,20 @@ This Software Detailed Design defines the module-level design details needed for
|
|||||||
| SDD-MOD-005 Voice UI | `voice_bar.dart`, `voice_compact.dart`, `voice_settings*.dart`, `voice_level_meter.dart`, `ptt_capability_badge.dart` | Voice controls, processing settings, metering, PTT capability | Flutter widget layer |
|
| SDD-MOD-005 Voice UI | `voice_bar.dart`, `voice_compact.dart`, `voice_settings*.dart`, `voice_level_meter.dart`, `ptt_capability_badge.dart` | Voice controls, processing settings, metering, PTT capability | Flutter widget layer |
|
||||||
| SDD-MOD-006 Platform services | `android_permissions_service.dart`, `ios_permissions_service.dart`, `audio_lifecycle_service.dart`, `back_intent_*`, `link_trust_service.dart` | Permission, lifecycle, navigation, route/link trust behavior | Flutter service layer |
|
| SDD-MOD-006 Platform services | `android_permissions_service.dart`, `ios_permissions_service.dart`, `audio_lifecycle_service.dart`, `back_intent_*`, `link_trust_service.dart` | Permission, lifecycle, navigation, route/link trust behavior | Flutter service layer |
|
||||||
| SDD-MOD-007 Bridge API | `crates/chanora_bridge/src/api.rs`, generated Dart/Rust bridge files | Typed command/event boundary | Bridge layer |
|
| SDD-MOD-007 Bridge API | `crates/chanora_bridge/src/api.rs`, generated Dart/Rust bridge files | Typed command/event boundary | Bridge layer |
|
||||||
| SDD-MOD-008 Rust core supervisor | `core/chanora_core/src/lib.rs`, `events.rs`, `network_diagnostics.rs`, `ptt.rs` | Connection orchestration, reconnect, bridge-facing event DTOs, network diagnostics, PTT state, storage coordination | Rust core |
|
| SDD-MOD-008 Rust core supervisor | `core/chanora_core/src/lib.rs` | Connection orchestration entry point, re-exports from submodules | Rust core |
|
||||||
|
| SDD-MOD-008a Core events module | `core/chanora_core/src/events.rs` | Public event/bridge-facing DTOs, connection state DTOs | Rust core |
|
||||||
|
| SDD-MOD-008b Core network diagnostics | `core/chanora_core/src/network_diagnostics.rs` | Private connect/loss counters, last-loss ring buffer for diagnostics | Rust core |
|
||||||
|
| SDD-MOD-008c Core PTT state | `core/chanora_core/src/ptt.rs` | Push-to-talk state machine, capability level, key bindings | Rust core |
|
||||||
| SDD-MOD-009 Protocol adapter | `crates/chanora_protocol/src/` | `tsclientlib` isolation, DTO/error mapping | Protocol adapter |
|
| SDD-MOD-009 Protocol adapter | `crates/chanora_protocol/src/` | `tsclientlib` isolation, DTO/error mapping | Protocol adapter |
|
||||||
| SDD-MOD-010 State sync | `crates/chanora_state/src/lib.rs`, `channel_join.rs` | Snapshot/delta model, reducer, channel join support | State sync |
|
| SDD-MOD-010 State sync | `crates/chanora_state/src/lib.rs`, `channel_join.rs` | Snapshot/delta model, reducer, channel join support | State sync |
|
||||||
| SDD-MOD-011 Audio subsystem | `crates/chanora_audio/src/` | Audio capture/playback, DSP, Opus, PTT, mode stack, platform units | Audio subsystem |
|
| SDD-MOD-011 Audio subsystem | `crates/chanora_audio/src/` | Audio capture/playback, DSP, Opus, PTT, mode stack, platform units | Audio subsystem |
|
||||||
| SDD-MOD-012 Storage | `crates/chanora_storage/src/lib.rs` | Bookmarks, identity storage, encrypted local records, keyring abstraction | Storage |
|
| SDD-MOD-012 Storage | `crates/chanora_storage/src/lib.rs` | Bookmarks, identity storage, encrypted local records, keyring abstraction | Storage |
|
||||||
| SDD-MOD-013 Diagnostics | `crates/chanora_diagnostics/src/lib.rs` | Redaction, log sink, known-secret registry, export bundle | Diagnostics |
|
| SDD-MOD-013 Diagnostics | `crates/chanora_diagnostics/src/lib.rs` | Redaction, log sink, known-secret registry, export bundle | Diagnostics |
|
||||||
| SDD-MOD-014 Resolution and prefetch | `crates/chanora_resolver/src/lib.rs`, `crates/chanora_prefetch/src/lib.rs`, `prefetch_debouncer.dart` | SRV/TSDNS/DNS fallback and generation-safe resolution warming | Server resolver / prefetch |
|
| SDD-MOD-014 Resolution and prefetch | `crates/chanora_resolver/src/lib.rs`, `crates/chanora_prefetch/src/lib.rs`, `prefetch_debouncer.dart` | SRV/TSDNS/DNS fallback and generation-safe resolution warming | Server resolver / prefetch |
|
||||||
| SDD-MOD-015 Build and release hooks | `.github/workflows/`, `tools/`, platform project files | CI, unsigned iOS build, benchmark advisory, platform smoke procedures | Release / platform architecture |
|
| SDD-MOD-015 Server resolver | `crates/chanora_resolver/src/lib.rs` | SRV record, TSDNS, and DNS A/AAAA fallback resolution | Server resolver |
|
||||||
|
| SDD-MOD-016 Server prefetch | `crates/chanora_prefetch/src/lib.rs` | TTL-based resolution cache, invisible host-field warming | Server prefetch |
|
||||||
|
| SDD-MOD-017 Cache | `crates/chanora_cache/src/lib.rs` | Typed caching layer for server resolution results and other transient data | Cache |
|
||||||
|
| SDD-MOD-018 Build and release hooks | `.github/workflows/`, `tools/`, platform project files | CI, unsigned iOS build, benchmark advisory, platform smoke procedures | Release / platform architecture |
|
||||||
|
|
||||||
## 3. Bridge Boundary Design
|
## 3. Bridge Boundary Design
|
||||||
|
|
||||||
@@ -63,7 +69,7 @@ Design rules:
|
|||||||
| Capture/playback | Platform-specific units handle Android, iOS, desktop/fallback paths behind Rust audio abstractions |
|
| Capture/playback | Platform-specific units handle Android, iOS, desktop/fallback paths behind Rust audio abstractions |
|
||||||
| Codec | Opus encode/decode lives in `opus_voice.rs` and associated audio modules |
|
| Codec | Opus encode/decode lives in `opus_voice.rs` and associated audio modules |
|
||||||
| DSP chain | High-pass filter, noise suppression, echo cancellation, and AGC are represented by audio processing modules/backends |
|
| DSP chain | High-pass filter, noise suppression, echo cancellation, and AGC are represented by audio processing modules/backends |
|
||||||
| Transmit control | `TransmitMode` supports `Ptt`, `Continuous`, and reserved `VoiceActivity`; `VoiceActivity` has no active MVP implementation |
|
| Transmit control | `TransmitMode` supports `Ptt`, `Continuous`, and `VoiceActivity`; `VoiceActivity` is active for Windows/Linux desktop capture when VAD is configured, while mobile, macOS, and unverified-platform enablement remain deferred |
|
||||||
| VoiceActivity gate (capture-side) | `voice_activity::VoiceActivityStateMachine` is the 10 ms-cadence gate for `TransmitMode::VoiceActivity`; open-after 40 ms (debounce), hangover 500 ms (anti-chatter), min-tx 200 ms (anti-flicker), weak-hold 30-100 frames (anti-stale-VAD); live `configure()` re-clamps existing timers on settings change without resetting state; 9 unit tests cover the main paths |
|
| VoiceActivity gate (capture-side) | `voice_activity::VoiceActivityStateMachine` is the 10 ms-cadence gate for `TransmitMode::VoiceActivity`; open-after 40 ms (debounce), hangover 500 ms (anti-chatter), min-tx 200 ms (anti-flicker), weak-hold 30-100 frames (anti-stale-VAD); live `configure()` re-clamps existing timers on settings change without resetting state; 9 unit tests cover the main paths |
|
||||||
| PTT | Desktop/mobile backends expose capability level and active backend; missed-key-up watchdog prevents stuck transmit |
|
| PTT | Desktop/mobile backends expose capability level and active backend; missed-key-up watchdog prevents stuck transmit |
|
||||||
| Release tail | Tail handling prevents abrupt cutoffs after PTT release where configured |
|
| Release tail | Tail handling prevents abrupt cutoffs after PTT release where configured |
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Audio Loopback Test Tool Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-11
|
||||||
|
**Status:** Design proposal
|
||||||
|
**TODO:** TODO-046
|
||||||
|
**Requirements:** SysRS-073, SRS-083
|
||||||
|
**Effort:** L
|
||||||
|
**Dependencies:** TODO-054 (audio loopback harness)
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
End-to-end audio quality verification. Sends a known test signal through the full encode→decode→playback→capture loop and measures signal quality metrics to verify the entire audio pipeline works correctly on a given platform.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
- Known test signal (sine sweep, white noise, or chirp)
|
||||||
|
- Loopback device configuration (virtual audio device or hardware loopback)
|
||||||
|
- Test duration and sample rate
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
- Signal quality metrics: SNR (dB), latency (ms), jitter (ms), THD+N (%)
|
||||||
|
- Pass/fail against acceptance thresholds
|
||||||
|
- Captured loopback audio WAV for manual inspection
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌──────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐
|
||||||
|
│ Test │────>│ Opus │────>│ Loopback │────>│ Opus │
|
||||||
|
│ Signal │ │ Encode │ │ Device │ │ Decode │
|
||||||
|
│ Generator│ │ │ │ (virtual) │ │ │
|
||||||
|
└──────────┘ └───────────┘ └───────────┘ └─────┬─────┘
|
||||||
|
│
|
||||||
|
┌──────────┐ ┌───────────┐ ┌───────────┐ ┌────v─────┐
|
||||||
|
│ Report │<────│ Metrics │<────│ Compare │<────│ Capture │
|
||||||
|
│ (SNR, │ │ Extract │ │ (original │ │ (loopback│
|
||||||
|
│ latency)│ │ │ │ vs recv) │ │ audio) │
|
||||||
|
└──────────┘ └───────────┘ └───────────┘ └──────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Generate:** Create known test signal (e.g., 1kHz sine, sweep)
|
||||||
|
2. **Encode:** Pass through Opus encoder (matching voice pipeline config)
|
||||||
|
3. **Loopback:** Send encoded audio through virtual audio device
|
||||||
|
4. **Decode:** Capture loopback audio and decode through Opus decoder
|
||||||
|
5. **Compare:** Cross-correlate original and received signals
|
||||||
|
6. **Measure:** Extract SNR, latency (peak correlation offset), jitter, THD+N
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
- New Rust binary crate: `tools/audio-loopback-test/`
|
||||||
|
- Reuse `chanora_audio` Opus encoder/decoder wrappers
|
||||||
|
- Virtual audio device: BlackHole (macOS), VB-Audio (Windows), snd-aloop (Linux)
|
||||||
|
- Cross-correlation for latency measurement
|
||||||
|
- CLI interface: `audio-loopback-test [--duration 5] [--signal sine|sweep|noise] [--device <name>]`
|
||||||
|
- Requires TODO-054 (loopback harness) for CI virtual device setup
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- `chanora_audio` (Opus encode/decode, audio config)
|
||||||
|
- `opus` crate (encoder/decoder)
|
||||||
|
- `hound` (WAV I/O)
|
||||||
|
- Virtual audio device (platform-specific, from TODO-054)
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Unit test: encode→decode roundtrip without loopback, verify signal preserved
|
||||||
|
- Integration test: full loopback on macOS with BlackHole, verify SNR > threshold
|
||||||
|
- Platform test: run on each target OS with configured virtual device
|
||||||
|
- Demo: run tool on developer machine, show metrics report
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Audio Processing Test Tool Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-11
|
||||||
|
**Status:** Design proposal
|
||||||
|
**TODO:** TODO-044
|
||||||
|
**Requirements:** SysRS-074, SRS-083
|
||||||
|
**Effort:** L
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Test the audio DSP pipeline (AEC, NS, AGC, HPF) in isolation. Measures processing latency, signal quality, and verifies each filter stage produces expected output for known input signals.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
- WAV file (reference signal) or live microphone input
|
||||||
|
- Processing configuration (enable/disable AEC, NS, AGC, HPF)
|
||||||
|
- Optional: reference signal for AEC (far-end playback)
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
- Processed audio WAV file
|
||||||
|
- Per-stage metrics: latency (ms), signal level (dBFS), spectral changes
|
||||||
|
- Pass/fail per processing stage against acceptance thresholds
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌──────────┐ ┌───────────────────────────────────────┐ ┌──────────┐
|
||||||
|
│ WAV / │────>│ DSP Chain: HPF → NS → AEC → AGC │────>│ Processed│
|
||||||
|
│ Mic Input│ │ (chanora_audio processors) │ │ WAV + │
|
||||||
|
└──────────┘ └────────────────────────┬──────────────┘ │ Metrics │
|
||||||
|
│ └──────────┘
|
||||||
|
┌──────v───────┐
|
||||||
|
│ Metrics │
|
||||||
|
│ Collector │
|
||||||
|
│ (latency, │
|
||||||
|
│ dBFS, SNR) │
|
||||||
|
└──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Load:** Read WAV file or open mic stream
|
||||||
|
2. **Process:** Feed PCM frames through each enabled DSP stage sequentially
|
||||||
|
3. **Measure:** Collect per-stage latency and signal metrics
|
||||||
|
4. **Output:** Write processed WAV and print metrics table
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
- New Rust binary crate: `tools/audio-processing-test/`
|
||||||
|
- Reuse `chanora_audio` processors: `HpfProcessor`, noise suppression, AEC, AGC
|
||||||
|
- WAV I/O via `hound` crate
|
||||||
|
- CLI interface: `audio-processing-test <input.wav> [--output processed.wav] [--stages hpf,ns,aec,agc]`
|
||||||
|
- Metrics: frame-level latency, input/output RMS, spectral centroid shift
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- `chanora_audio` (HPF, NS, AEC, AGC processors)
|
||||||
|
- `hound` (WAV read/write)
|
||||||
|
- `chanora_audio::engine` (AudioProcessingConfig)
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Unit test: known-tone input through HPF, verify low-frequency attenuation
|
||||||
|
- Unit test: known-noise input through NS, verify noise floor reduction
|
||||||
|
- Integration test: full pipeline on reference WAV, verify output within thresholds
|
||||||
|
- Demo: run tool on sample file, show metrics output
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user