Restore product scaffold to rollback baseline

Merge reset-style baseline PR after local verification. GitHub Actions did not start because of the account billing/spending-limit blocker documented in the PR body.
This commit is contained in:
Edison Jwa
2026-05-29 16:13:46 +09:00
committed by GitHub
434 changed files with 27278 additions and 63230 deletions
-2
View File
@@ -25,8 +25,6 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Checkout oboe-rs fork
run: git clone --depth 1 https://github.com/edisonjwa/oboe-rs.git ../oboe-rs
- name: System deps (cpal / Opus)
run: |
sudo apt-get update
@@ -18,8 +18,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Checkout oboe-rs fork
run: git clone --depth 1 https://github.com/edisonjwa/oboe-rs.git ../oboe-rs
- name: System deps (cpal / Opus)
run: |
sudo apt-get update
-65
View File
@@ -11,8 +11,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Checkout oboe-rs fork
run: git clone --depth 1 https://github.com/edisonjwa/oboe-rs.git ../oboe-rs
- name: System deps (cpal / Opus / SQLite)
run: |
sudo apt-get update
@@ -40,8 +38,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Checkout oboe-rs fork
run: git clone --depth 1 https://github.com/edisonjwa/oboe-rs.git ../oboe-rs
- uses: EmbarkStudios/cargo-deny-action@v2
with:
command: check
@@ -55,8 +51,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Checkout oboe-rs fork
run: git clone --depth 1 https://github.com/edisonjwa/oboe-rs.git ../oboe-rs
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Install cargo-about
@@ -119,71 +113,12 @@ jobs:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Checkout oboe-rs fork
run: git clone --depth 1 https://github.com/edisonjwa/oboe-rs.git ../oboe-rs
- uses: subosito/flutter-action@v2
with:
channel: stable
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: flutter pub get
working-directory: apps/chanora_flutter
run: flutter pub get
- name: flutter build ios --no-codesign
working-directory: apps/chanora_flutter
run: flutter build ios --release --no-codesign
flutter-linux-release-package:
name: flutter Linux release package (.deb)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Checkout oboe-rs fork
run: git clone --depth 1 https://github.com/edisonjwa/oboe-rs.git ../oboe-rs
- name: System deps (Flutter Linux + audio + packaging)
run: |
sudo apt-get update
sudo apt-get install -y \
clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev \
libasound2-dev libpulse-dev libopus-dev libsdl2-dev
- uses: subosito/flutter-action@v2
with:
channel: stable
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Build Linux .deb package
run: ./tools/build-linux-deb.sh
- name: Upload Linux package artifact
uses: actions/upload-artifact@v4
with:
name: chanora-linux-deb
path: dist/linux/*.deb
flutter-android-release-aab:
name: flutter Android release AAB
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Checkout oboe-rs fork
run: git clone --depth 1 https://github.com/edisonjwa/oboe-rs.git ../oboe-rs
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- uses: android-actions/setup-android@v3
- uses: subosito/flutter-action@v2
with:
channel: stable
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-linux-android,x86_64-linux-android
- uses: Swatinem/rust-cache@v2
- name: Install cargo-ndk
run: cargo install --locked cargo-ndk
- name: Build Android release AAB
run: ./tools/build-android-aab.sh
- name: Upload Android bundle artifact
uses: actions/upload-artifact@v4
with:
name: chanora-android-aab
path: dist/android/*.aab
-6
View File
@@ -70,16 +70,10 @@ temp/
# Upstream documentation snapshots (kept as reference, not project source)
chanora-docs-repo-format-*.zip
# Local vendored-crate experiments. Product dependencies must use
# crates.io/git dependencies or explicitly committed source with
# license provenance.
/third_party/
# Cargo
**/Cargo.lock.bak
# Flutter / Dart generated (but keep pubspec.lock for the PoC apps)
.flutter-home/
**/.dart_tool/
**/.flutter-plugins
**/.flutter-plugins-dependencies
Generated
+70 -265
View File
@@ -77,7 +77,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d"
dependencies = [
"alsa-sys",
"bitflags",
"bitflags 2.11.1",
"cfg-if",
"libc",
]
@@ -121,16 +121,6 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
[[package]]
name = "annotate-snippets"
version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "710e8eae58854cdc1790fcb56cca04d712a17be849eeb81da2a724bf4bae2bc4"
dependencies = [
"anstyle",
"unicode-width",
]
[[package]]
name = "anstyle"
version = "1.0.14"
@@ -270,31 +260,18 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bindgen"
version = "0.72.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
dependencies = [
"annotate-snippets",
"bitflags",
"cexpr",
"clang-sys",
"itertools 0.10.5",
"proc-macro2",
"quote",
"regex",
"rustc-hash 2.1.2",
"shlex",
"syn",
]
[[package]]
name = "bit-vec"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.11.1"
@@ -390,25 +367,6 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]]
name = "cexpr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom 7.1.3",
]
[[package]]
name = "cfg-expr"
version = "0.20.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c6b04e07d8080154ed4ac03546d9a2b303cc2fe1901ba0b35b301516e289368"
dependencies = [
"smallvec",
"target-lexicon",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
@@ -469,15 +427,13 @@ dependencies = [
"dispatch2",
"futures-util",
"jni 0.21.1",
"libpulse-binding",
"libpulse-simple-binding",
"ndarray",
"ndk-context",
"oboe",
"ort",
"pipewire",
"rand 0.8.6",
"rustfft",
"sdl2",
"serde_json",
"sonora",
"thiserror 2.0.18",
@@ -515,6 +471,7 @@ dependencies = [
"chanora_audio",
"chanora_diagnostics",
"chanora_protocol",
"chanora_server_prefetch",
"chanora_state",
"chanora_storage",
"rusqlite",
@@ -543,6 +500,7 @@ dependencies = [
"reqwest 0.13.3",
"serde",
"thiserror 2.0.18",
"time",
"tokio",
"tracing",
"ts-bookkeeping",
@@ -563,10 +521,21 @@ dependencies = [
"tracing-subscriber",
]
[[package]]
name = "chanora_server_prefetch"
version = "0.2.0-beta.1"
dependencies = [
"chanora_resolver",
"thiserror 2.0.18",
"tokio",
"tracing",
]
[[package]]
name = "chanora_state"
version = "0.2.0-beta.1"
dependencies = [
"chanora_protocol",
"thiserror 2.0.18",
]
@@ -624,17 +593,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "clang-sys"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
dependencies = [
"glob",
"libc",
"libloading 0.8.9",
]
[[package]]
name = "clap"
version = "4.6.1"
@@ -714,12 +672,6 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "cookie-factory"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2"
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -752,7 +704,7 @@ version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"libc",
"objc2-audio-toolbox",
"objc2-core-audio",
@@ -1117,7 +1069,7 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"block2",
"libc",
"objc2",
@@ -1576,12 +1528,6 @@ dependencies = [
"time",
]
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "group"
version = "0.13.0"
@@ -2203,16 +2149,6 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "libloading"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]]
name = "libloading"
version = "0.9.0"
@@ -2223,81 +2159,6 @@ dependencies = [
"windows-link",
]
[[package]]
name = "libpulse-binding"
version = "2.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "909eb3049e16e373680fe65afe6e2a722ace06b671250cc4849557bc57d6a397"
dependencies = [
"bitflags",
"libc",
"libpulse-sys",
"num-derive",
"num-traits",
"winapi",
]
[[package]]
name = "libpulse-simple-binding"
version = "2.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7bebef0381c8e3e4b23cc24aaf36fab37472bece128de96f6a111efa464cfef"
dependencies = [
"libpulse-binding",
"libpulse-simple-sys",
"libpulse-sys",
]
[[package]]
name = "libpulse-simple-sys"
version = "1.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bd96888fe37ad270d16abf5e82cccca1424871cf6afa2861824d2a52758eebc"
dependencies = [
"libpulse-sys",
"pkg-config",
]
[[package]]
name = "libpulse-sys"
version = "1.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d74371848b22e989f829cc1621d2ebd74960711557d8b45cfe740f60d0a05e61"
dependencies = [
"libc",
"num-derive",
"num-traits",
"pkg-config",
"winapi",
]
[[package]]
name = "libspa"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2909f3be29d674e7f10604aff18d1bbe1bb03c4cd61c8a8ba19c0b1d162f7d4e"
dependencies = [
"bitflags",
"cc",
"cookie-factory",
"libc",
"libspa-sys",
"nom 8.0.0",
"rustix",
"system-deps",
]
[[package]]
name = "libspa-sys"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69ad52764fca54818486f3cf75afec844d1f1a1568c24dcee25d41b1ab007dda"
dependencies = [
"bindgen",
"cc",
"system-deps",
]
[[package]]
name = "libsqlite3-sys"
version = "0.30.1"
@@ -2315,7 +2176,7 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"libc",
]
@@ -2498,7 +2359,7 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"jni-sys 0.3.1",
"log",
"ndk-sys",
@@ -2531,15 +2392,6 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -2650,7 +2502,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"libc",
"objc2",
"objc2-core-audio",
@@ -2688,7 +2540,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"objc2",
]
@@ -2698,7 +2550,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"block2",
"dispatch2",
"libc",
@@ -2717,7 +2569,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"block2",
"libc",
"objc2",
@@ -2736,6 +2588,7 @@ dependencies = [
[[package]]
name = "oboe"
version = "0.6.3"
source = "git+https://github.com/EdisonJwa/oboe-rs?rev=a14f9b83ecea8c93f5a692f2ee7808445b938c35#a14f9b83ecea8c93f5a692f2ee7808445b938c35"
dependencies = [
"num-derive",
"num-traits",
@@ -2745,6 +2598,7 @@ dependencies = [
[[package]]
name = "oboe-sys"
version = "0.6.3"
source = "git+https://github.com/EdisonJwa/oboe-rs?rev=a14f9b83ecea8c93f5a692f2ee7808445b938c35#a14f9b83ecea8c93f5a692f2ee7808445b938c35"
dependencies = [
"cc",
]
@@ -2786,7 +2640,7 @@ version = "0.10.80"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"cfg-if",
"foreign-types",
"libc",
@@ -2839,7 +2693,7 @@ version = "2.0.0-rc.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133"
dependencies = [
"libloading 0.9.0",
"libloading",
"ndarray",
"ort-sys",
"smallvec",
@@ -2931,31 +2785,6 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "pipewire"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8585aba8a52ad74ccc633b8e293c1dc4277976bd5d510b925533f34fd6685f38"
dependencies = [
"bitflags",
"libc",
"libspa",
"libspa-sys",
"pipewire-sys",
"rustix",
]
[[package]]
name = "pipewire-sys"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2089f245b548723e60325773c27f586b7a2372c79ea941b246cd0d654706adc"
dependencies = [
"bindgen",
"libspa-sys",
"system-deps",
]
[[package]]
name = "pkcs8"
version = "0.10.2"
@@ -3303,7 +3132,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
"bitflags 2.11.1",
]
[[package]]
@@ -3470,7 +3299,7 @@ version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
@@ -3525,7 +3354,7 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"errno",
"libc",
"linux-raw-sys",
@@ -3644,6 +3473,29 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sdl2"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b498da7d14d1ad6c839729bd4ad6fc11d90a57583605f3b4df2cd709a9cd380"
dependencies = [
"bitflags 1.3.2",
"lazy_static",
"libc",
"sdl2-sys",
]
[[package]]
name = "sdl2-sys"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "951deab27af08ed9c6068b7b0d05a93c91f0a8eb16b6b816a5e73452a43521d3"
dependencies = [
"cfg-if",
"libc",
"version-compare",
]
[[package]]
name = "sec1"
version = "0.7.3"
@@ -3664,7 +3516,7 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
@@ -3677,7 +3529,7 @@ version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
@@ -4032,7 +3884,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"core-foundation 0.9.4",
"system-configuration-sys",
]
@@ -4047,26 +3899,13 @@ dependencies = [
"libc",
]
[[package]]
name = "system-deps"
version = "7.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7"
dependencies = [
"cfg-expr",
"heck",
"pkg-config",
"toml",
"version-compare",
]
[[package]]
name = "t4rust-derive"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e411692d6e0c8ce07f65a8210e95dcfb56cc4600940456592a49a3579ef6396"
dependencies = [
"nom 7.1.3",
"nom",
"proc-macro2",
"quote",
"syn",
@@ -4078,12 +3917,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
[[package]]
name = "target-lexicon"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c"
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -4371,7 +4204,7 @@ version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"bytes",
"futures-util",
"http",
@@ -4567,7 +4400,7 @@ version = "0.1.0"
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
dependencies = [
"base64",
"bitflags",
"bitflags 2.11.1",
"num-derive",
"num-traits",
"omnom",
@@ -4595,7 +4428,7 @@ version = "0.1.0"
source = "git+https://github.com/ReSpeak/tsclientlib.git?rev=04aa2491#04aa24917abbf6a0c8442a79742d6d2d40ecf71e"
dependencies = [
"base64",
"bitflags",
"bitflags 2.11.1",
"curve25519-dalek-ng",
"elliptic-curve",
"generic-array",
@@ -4638,12 +4471,6 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "unicode-xid"
version = "0.2.6"
@@ -4710,9 +4537,9 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.1"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
checksum = "579a42fc0b8e0c63b76519a339be31bed574929511fa53c1a3acae26eb258f29"
[[package]]
name = "version_check"
@@ -4846,7 +4673,7 @@ version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"hashbrown 0.15.5",
"indexmap",
"semver",
@@ -4905,22 +4732,6 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
@@ -4930,12 +4741,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.54.0"
@@ -5381,7 +5186,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"bitflags 2.11.1",
"indexmap",
"log",
"serde",
+5 -13
View File
@@ -12,15 +12,15 @@
# crates/chanora_audio/ — capture, DSP, Opus, jitter, mixer
# crates/chanora_storage/ — bookmarks, settings, identity refs
# crates/chanora_diagnostics/ — logs, redaction, export
# crates/chanora_server_prefetch — server-resolution prefetch cache/policy
# crates/chanora_bridge/ — Flutter/Rust typed DTOs + glue
#
# The Flutter app is not part of this Cargo workspace; it is owned by
# Gradle / Xcode / Flutter tooling under apps/chanora_flutter/.
#
# Non-promotion reminder (proof-of-concept-plan.md §4): nothing under
# poc/ is product code. The crates here are blank scaffolds at this
# stage; promotion of PoC code happens crate-by-crate with explicit
# audit-trail commits.
# Proof-of-concept spikes are archived separately in the private
# chanoraapp/chanora-poc repository. Promotion of PoC findings into
# product code happens crate-by-crate with explicit audit-trail commits.
[workspace]
resolver = "2"
@@ -31,20 +31,12 @@ members = [
"crates/chanora_audio",
"crates/chanora_storage",
"crates/chanora_diagnostics",
"crates/chanora_server_prefetch",
"crates/chanora_bridge",
"crates/chanora_resolver",
]
# Excludes: the PoC trees stay outside the product workspace so that
# `cargo check --workspace` at the root does not pull in PoC builds.
exclude = [
"poc/tsclientlib-connect-spike",
"poc/secure-storage-spike",
"poc/sqlite-storage-spike",
"poc/diagnostics-redaction-spike",
"poc/audio-capture-playback-spike",
"poc/audio-capture-playback-android-spike",
"poc/flutter_rust_bridge_hello",
"apps/chanora_flutter",
]
+1
View File
@@ -30,6 +30,7 @@ accepted = [
"Unicode-DFS-2016",
"Unicode-3.0",
"CC0-1.0",
"CDLA-Permissive-2.0",
"OpenSSL",
]
@@ -352,22 +352,7 @@ fun registerCargoNdkBuildTask(profile: String): TaskProvider<*> {
).firstOrNull { File(it).resolve("cargo").exists() }
val rustupHome = System.getenv("RUSTUP_HOME") ?: "$homeDir/.rustup"
val cargoHome = System.getenv("CARGO_HOME") ?: "$homeDir/.cargo"
val hostArch = System.getProperty("os.arch").lowercase()
val defaultRustupToolchain = when {
org.gradle.internal.os.OperatingSystem.current().isMacOsX && hostArch.contains("aarch64") ->
"stable-aarch64-apple-darwin"
org.gradle.internal.os.OperatingSystem.current().isMacOsX ->
"stable-x86_64-apple-darwin"
org.gradle.internal.os.OperatingSystem.current().isWindows && hostArch.contains("aarch64") ->
"stable-aarch64-pc-windows-msvc"
org.gradle.internal.os.OperatingSystem.current().isWindows ->
"stable-x86_64-pc-windows-msvc"
hostArch.contains("aarch64") || hostArch.contains("arm64") ->
"stable-aarch64-unknown-linux-gnu"
else ->
"stable-x86_64-unknown-linux-gnu"
}
val rustupToolchain = System.getenv("RUSTUP_TOOLCHAIN") ?: defaultRustupToolchain
val rustupToolchain = System.getenv("RUSTUP_TOOLCHAIN") ?: "stable-aarch64-apple-darwin"
// SDD-118 item 13 (corrected): per-ABI Exec sub-tasks. Each runs an
// isolated `cargo ndk -t <abi> ... -- build ...` so ANDROID_ABI is set
@@ -553,7 +538,7 @@ fun registerJniLibsCopyTask(profile: String): TaskProvider<Task> {
// version bump replacing libc++_shared.so).
inputs.file(bridgeSo).withPropertyName("bridgeSo_$abi")
inputs.file(cxxSharedSo).withPropertyName("cxxSharedSo_$abi")
inputs.file(llvmStrip).withPropertyName("llvmStrip")
inputs.file(llvmStrip).withPropertyName("llvmStrip_$abi")
outputs.file(strippedBridgeSo)
outputs.file(File(jniLibsDir, "$abi/libchanora_bridge.so"))
outputs.file(File(jniLibsDir, "$abi/libc++_shared.so"))
@@ -1,6 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
@@ -34,54 +34,32 @@ import AVFoundation
let session = AVAudioSession.sharedInstance()
try session.setCategory(
.playAndRecord,
mode: .default,
// Mode rationale (re-revisited after the "low playback
// volume" investigation, May 2026):
mode: .voiceChat,
// Mode rationale (May 2026, .voiceChat reinstated):
//
// We've cycled through .voiceChat -> .default -> .voiceChat
// -> .default. Final answer is .default with
// .defaultToSpeaker, driven by these findings:
// We previously used .default mode after discovering that
// .voiceChat routed output through iOS's in-call audio
// channel, which made speaker output barely audible. That
// bug was caused by cpal's RemoteIO unit binding to a stale
// physical transducer after migrating to coreaudio-rs +
// kAudioUnitSubType_VoiceProcessingIO (see
// crates/chanora_audio/src/ios_voice_unit.rs) the route
// binding is correct under either mode because VPIO re-binds
// on overrideOutputAudioPort.
//
// The earlier "speaker selector silent" bug under
// .voiceChat was caused by cpal's RemoteIO unit binding
// to a stale physical transducer. After migrating to
// coreaudio-rs + kAudioUnitSubType_VoiceProcessingIO
// (see crates/chanora_audio/src/ios_voice_unit.rs) the
// route binding is correct under either mode because
// VPIO is the canonical voice unit and re-binds on
// overrideOutputAudioPort. So route switching is no
// longer a deciding factor.
// .voiceChat advantages over .default:
// * Tells iOS this is a VoIP session other apps' audio
// is properly ducked/paused instead of competing.
// * Enables correct Bluetooth HFP negotiation without
// manual workarounds.
// * iOS treats the audio session as a "call" for priority
// purposes (won't be interrupted by notification sounds).
// * System-level CallKit integration (lock-screen controls).
//
// The "broken playback quality" bug under either
// .voiceChat or .default (with VPIO) was actually NOT
// a VPIO problem at all. iOS has TWO independent audio
// channels: the in-call channel (used by .voiceChat /
// .videoChat modes) and the media channel (used by
// .default). The in-call channel:
// * Routes through the phone-call audio path
// * Aggressively ducks non-voice content to the
// earpiece (Apple's "speakerphone vs ear" UX)
// * Volume controlled by separate in-call volume
// hardware, not the side buttons (when not in a
// phone call)
// The media channel:
// * Routes through the standard media playback path
// * No automatic ducking
// * Volume controlled by the side volume buttons
//
// Even with VPIO + .voiceChat producing a perfectly
// good signal, iOS's in-call channel routing made it
// play at "earpiece" loudness on the speaker too \u2014
// user-perceived as "broken and poor" because the
// signal is technically there but barely audible against
// the loud iPhone speaker's noise floor.
//
// Twilio's video-quickstart-ios and Daily.co's patched
// WebRTC both document the same workaround: use .default
// mode with .defaultToSpeaker option even when using
// VPIO for AEC. The VPIO unit itself still does its job
// (echo cancellation, noise suppression, AGC on the mic
// path) \u2014 only the playback routing changes.
// .defaultToSpeaker ensures output goes to the main speaker
// (not the earpiece) by default when no headphones are
// connected, compensating for the in-call channel's tendency
// to route to the earpiece.
//
// References:
// * https://github.com/twilio/video-quickstart-ios/issues/522
@@ -91,9 +69,6 @@ import AVFoundation
// .defaultToSpeaker : route output to the main speaker
// (not the earpiece) by default
// when no headphones are connected.
// This is what makes the audio
// actually audible at normal
// loudness.
// .allowBluetoothHFP : permit Bluetooth Hands-Free
// Profile headsets as both input
// and output.
@@ -245,7 +220,7 @@ import AVFoundation
let session = AVAudioSession.sharedInstance()
try session.setCategory(
.playAndRecord,
mode: .default,
mode: .voiceChat,
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP]
)
try session.setPreferredIOBufferDuration(0.02)
@@ -171,7 +171,7 @@ List<PlatformCapability> _windowsCapabilities() => [
List<PlatformCapability> _linuxCapabilities() => [
const PlatformCapability(
feature: 'Voice capture',
description: 'PipeWire voice I/O with PulseAudio fallback.',
description: 'PulseAudio/ALSA via cpal.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
+58
View File
@@ -22,6 +22,8 @@
"refreshAction": "Refresh",
"diagnosticsAction": "Diagnostics",
"diagnosticsSaveAction": "Save export",
"diagnosticsLiveUpdating": "Live updating",
"shareAction": "Share",
"diagnosticsSaved": "Diagnostic export saved to {path}",
"@diagnosticsSaved": {
"placeholders": { "path": { "type": "String" } }
@@ -39,6 +41,16 @@
"aboutThirdPartyBody": "Chanora is built on tsclientlib, flutter_rust_bridge, platform-native audio backends, rusqlite, and the Flutter framework, among others. See the NOTICE file at the repository root for the current attribution list.",
"copyAction": "Copy",
"closeAction": "Close",
"openAction": "Open",
"cancelAction": "Cancel",
"retryAction": "Retry",
"chatAction": "Chat",
"chatCloseAction": "Close chat",
"chatNewPrivateAction": "New private chat",
"chatSearchClientsHint": "Search clients...",
"chatDirectMessageAction": "Private message",
"chatPokeAction": "Poke",
"clientInfoAction": "Info",
"startAudioAction": "Start audio",
"pttHoldToTalk": "Hold to talk",
"pttTransmitting": "Transmitting…",
@@ -119,6 +131,7 @@
"channelsHeading": "Channels",
"clientsHeading": "Online clients",
"serverWelcomeHeading": "Server welcome message",
"countChannelsAndClients": "{channels} channels • {clients} online",
"@countChannelsAndClients": {
"placeholders": {
@@ -172,6 +185,10 @@
"microphonePermissionBody": "Chanora needs permission to access the microphone. On macOS, go to System Settings → Privacy & Security → Microphone and enable Chanora.",
"microphonePermissionRequiredForVoice": "Microphone permission is required for voice transmission.",
"permissionGrantAction": "Grant",
"startupPermissionsTitle": "Permissions",
"startupPermissionsBody": "Chanora requests microphone, Bluetooth headset, and notification permissions at startup so voice, headset routing, and the foreground session work correctly.",
"startupPermissionsNotNow": "Not now",
"startupPermissionsAllow": "Allow",
"audioRouteSystemDefault": "System default",
"audioRouteEarpiece": "Earpiece",
"audioRouteUsbHeadset": "USB headset",
@@ -181,6 +198,47 @@
"audioRouteChangeFailed": "Could not change audio output.",
"iosAudioInterrupted": "Audio interrupted by system (phone call)",
"iosAudioResuming": "Audio resuming",
"linkTrustTitle": "Open external link?",
"linkTrustBody": "You are about to open a link to:\n\n{domain}",
"@linkTrustBody": {
"placeholders": {
"domain": { "type": "String" }
}
},
"linkTrustRememberDomain": "Trust all links from this domain",
"clientInfoFetchingProfile": "Fetching TeamSpeak profile",
"clientInfoLoadingProfile": "Loading profile...",
"clientInfoProfileUnavailable": "Profile unavailable",
"clientInfoProfileUnavailableBody": "The server did not return profile details for this client.",
"clientInfoIdentitySection": "Identity",
"clientInfoMembershipSection": "Membership",
"clientInfoConnectionSection": "Connection",
"clientInfoHistorySection": "History",
"clientInfoTransferSection": "Transfer",
"clientInfoClientId": "Client ID",
"clientInfoDatabaseId": "Database ID",
"clientInfoUniqueId": "Unique ID",
"clientInfoDescription": "Description",
"clientInfoAvatar": "Avatar",
"clientInfoServerGroups": "Server groups",
"clientInfoChannelGroup": "Channel group",
"clientInfoChannelId": "Channel ID",
"clientInfoOnline": "Online",
"clientInfoIdle": "Idle",
"clientInfoPing": "Ping",
"clientInfoAddress": "Address",
"clientInfoPacketLossClientToServer": "Packet loss C->S",
"clientInfoPacketLossServerToClient": "Packet loss S->C",
"clientInfoFirstConnected": "First connected",
"clientInfoLastConnected": "Last connected",
"clientInfoConnections": "Connections",
"clientInfoDownloadedMonth": "Downloaded this month",
"clientInfoUploadedMonth": "Uploaded this month",
"clientInfoDownloadedTotal": "Downloaded total",
"clientInfoUploadedTotal": "Uploaded total",
"clientInfoUnknown": "Unknown",
"clientInfoHidden": "Hidden",
"clientInfoNone": "None",
"pokeSnackBarClearAction": "Clear",
"pokeSnackBarMoreIndicator": "...",
"pokeSnackBarIncomingNoMessage": "{sender} pokes you",
+53
View File
@@ -21,6 +21,8 @@
"refreshAction": "刷新",
"diagnosticsAction": "诊断信息",
"diagnosticsSaveAction": "保存导出",
"diagnosticsLiveUpdating": "实时更新中",
"shareAction": "分享",
"diagnosticsSaved": "诊断导出已保存到 {path}",
"aboutAction": "关于",
"aboutVersion": "版本 {version}",
@@ -32,6 +34,16 @@
"aboutThirdPartyBody": "Chanora 基于 tsclientlib、flutter_rust_bridge、平台原生音频后端、rusqlite、Flutter 框架等开源组件构建。完整归属信息请参阅仓库根目录的 NOTICE 文件。",
"copyAction": "复制",
"closeAction": "关闭",
"openAction": "打开",
"cancelAction": "取消",
"retryAction": "重试",
"chatAction": "聊天",
"chatCloseAction": "关闭聊天",
"chatNewPrivateAction": "新建私聊",
"chatSearchClientsHint": "搜索用户...",
"chatDirectMessageAction": "私聊",
"chatPokeAction": "戳一下",
"clientInfoAction": "信息",
"startAudioAction": "启动语音",
"pttHoldToTalk": "按住说话",
"pttTransmitting": "正在发送…",
@@ -79,6 +91,7 @@
"channelsHeading": "频道",
"clientsHeading": "在线用户",
"serverWelcomeHeading": "服务器欢迎信息",
"countChannelsAndClients": "{channels} 个频道 • {clients} 在线",
"voiceModePtt": "按键说话",
@@ -126,6 +139,10 @@
"microphonePermissionBody": "Chanora 需要麦克风访问权限。请前往系统设置 → 隐私与安全性 → 麦克风,启用 Chanora。",
"microphonePermissionRequiredForVoice": "语音发送需要麦克风权限。",
"permissionGrantAction": "授权",
"startupPermissionsTitle": "权限",
"startupPermissionsBody": "Chanora 会在启动时请求麦克风、蓝牙耳机和通知权限,以确保语音、耳机路由和前台会话正常工作。",
"startupPermissionsNotNow": "暂不",
"startupPermissionsAllow": "允许",
"audioRouteSystemDefault": "系统默认",
"audioRouteEarpiece": "听筒",
"audioRouteUsbHeadset": "USB 耳机",
@@ -135,6 +152,42 @@
"audioRouteChangeFailed": "无法切换音频输出。",
"iosAudioInterrupted": "系统已中断音频(电话通话)",
"iosAudioResuming": "音频正在恢复",
"linkTrustTitle": "打开外部链接?",
"linkTrustBody": "你将打开指向以下域名的链接:\n\n{domain}",
"linkTrustRememberDomain": "信任来自此域名的所有链接",
"clientInfoFetchingProfile": "正在获取 TeamSpeak 资料",
"clientInfoLoadingProfile": "正在加载资料…",
"clientInfoProfileUnavailable": "资料不可用",
"clientInfoProfileUnavailableBody": "服务器没有返回此用户的资料详情。",
"clientInfoIdentitySection": "身份",
"clientInfoMembershipSection": "成员关系",
"clientInfoConnectionSection": "连接",
"clientInfoHistorySection": "历史",
"clientInfoTransferSection": "传输",
"clientInfoClientId": "用户 ID",
"clientInfoDatabaseId": "数据库 ID",
"clientInfoUniqueId": "唯一 ID",
"clientInfoDescription": "描述",
"clientInfoAvatar": "头像",
"clientInfoServerGroups": "服务器组",
"clientInfoChannelGroup": "频道组",
"clientInfoChannelId": "频道 ID",
"clientInfoOnline": "在线时长",
"clientInfoIdle": "空闲",
"clientInfoPing": "延迟",
"clientInfoAddress": "地址",
"clientInfoPacketLossClientToServer": "丢包 C->S",
"clientInfoPacketLossServerToClient": "丢包 S->C",
"clientInfoFirstConnected": "首次连接",
"clientInfoLastConnected": "上次连接",
"clientInfoConnections": "连接次数",
"clientInfoDownloadedMonth": "本月下载",
"clientInfoUploadedMonth": "本月上传",
"clientInfoDownloadedTotal": "总下载",
"clientInfoUploadedTotal": "总上传",
"clientInfoUnknown": "未知",
"clientInfoHidden": "隐藏",
"clientInfoNone": "无",
"pokeSnackBarClearAction": "清除",
"pokeSnackBarMoreIndicator": "...",
"pokeSnackBarIncomingNoMessage": "{sender} 戳了你一下",
@@ -199,6 +199,18 @@ abstract class AppL10n {
/// **'Save export'**
String get diagnosticsSaveAction;
/// No description provided for @diagnosticsLiveUpdating.
///
/// In en, this message translates to:
/// **'Live updating'**
String get diagnosticsLiveUpdating;
/// No description provided for @shareAction.
///
/// In en, this message translates to:
/// **'Share'**
String get shareAction;
/// No description provided for @diagnosticsSaved.
///
/// In en, this message translates to:
@@ -265,6 +277,66 @@ abstract class AppL10n {
/// **'Close'**
String get closeAction;
/// No description provided for @openAction.
///
/// In en, this message translates to:
/// **'Open'**
String get openAction;
/// No description provided for @cancelAction.
///
/// In en, this message translates to:
/// **'Cancel'**
String get cancelAction;
/// No description provided for @retryAction.
///
/// In en, this message translates to:
/// **'Retry'**
String get retryAction;
/// No description provided for @chatAction.
///
/// In en, this message translates to:
/// **'Chat'**
String get chatAction;
/// No description provided for @chatCloseAction.
///
/// In en, this message translates to:
/// **'Close chat'**
String get chatCloseAction;
/// No description provided for @chatNewPrivateAction.
///
/// In en, this message translates to:
/// **'New private chat'**
String get chatNewPrivateAction;
/// No description provided for @chatSearchClientsHint.
///
/// In en, this message translates to:
/// **'Search clients...'**
String get chatSearchClientsHint;
/// No description provided for @chatDirectMessageAction.
///
/// In en, this message translates to:
/// **'Private message'**
String get chatDirectMessageAction;
/// No description provided for @chatPokeAction.
///
/// In en, this message translates to:
/// **'Poke'**
String get chatPokeAction;
/// No description provided for @clientInfoAction.
///
/// In en, this message translates to:
/// **'Info'**
String get clientInfoAction;
/// No description provided for @startAudioAction.
///
/// In en, this message translates to:
@@ -523,6 +595,12 @@ abstract class AppL10n {
/// **'Online clients'**
String get clientsHeading;
/// No description provided for @serverWelcomeHeading.
///
/// In en, this message translates to:
/// **'Server welcome message'**
String get serverWelcomeHeading;
/// No description provided for @countChannelsAndClients.
///
/// In en, this message translates to:
@@ -769,6 +847,30 @@ abstract class AppL10n {
/// **'Grant'**
String get permissionGrantAction;
/// No description provided for @startupPermissionsTitle.
///
/// In en, this message translates to:
/// **'Permissions'**
String get startupPermissionsTitle;
/// No description provided for @startupPermissionsBody.
///
/// In en, this message translates to:
/// **'Chanora requests microphone, Bluetooth headset, and notification permissions at startup so voice, headset routing, and the foreground session work correctly.'**
String get startupPermissionsBody;
/// No description provided for @startupPermissionsNotNow.
///
/// In en, this message translates to:
/// **'Not now'**
String get startupPermissionsNotNow;
/// No description provided for @startupPermissionsAllow.
///
/// In en, this message translates to:
/// **'Allow'**
String get startupPermissionsAllow;
/// No description provided for @audioRouteSystemDefault.
///
/// In en, this message translates to:
@@ -823,6 +925,222 @@ abstract class AppL10n {
/// **'Audio resuming'**
String get iosAudioResuming;
/// No description provided for @linkTrustTitle.
///
/// In en, this message translates to:
/// **'Open external link?'**
String get linkTrustTitle;
/// No description provided for @linkTrustBody.
///
/// In en, this message translates to:
/// **'You are about to open a link to:\n\n{domain}'**
String linkTrustBody(String domain);
/// No description provided for @linkTrustRememberDomain.
///
/// In en, this message translates to:
/// **'Trust all links from this domain'**
String get linkTrustRememberDomain;
/// No description provided for @clientInfoFetchingProfile.
///
/// In en, this message translates to:
/// **'Fetching TeamSpeak profile'**
String get clientInfoFetchingProfile;
/// No description provided for @clientInfoLoadingProfile.
///
/// In en, this message translates to:
/// **'Loading profile...'**
String get clientInfoLoadingProfile;
/// No description provided for @clientInfoProfileUnavailable.
///
/// In en, this message translates to:
/// **'Profile unavailable'**
String get clientInfoProfileUnavailable;
/// No description provided for @clientInfoProfileUnavailableBody.
///
/// In en, this message translates to:
/// **'The server did not return profile details for this client.'**
String get clientInfoProfileUnavailableBody;
/// No description provided for @clientInfoIdentitySection.
///
/// In en, this message translates to:
/// **'Identity'**
String get clientInfoIdentitySection;
/// No description provided for @clientInfoMembershipSection.
///
/// In en, this message translates to:
/// **'Membership'**
String get clientInfoMembershipSection;
/// No description provided for @clientInfoConnectionSection.
///
/// In en, this message translates to:
/// **'Connection'**
String get clientInfoConnectionSection;
/// No description provided for @clientInfoHistorySection.
///
/// In en, this message translates to:
/// **'History'**
String get clientInfoHistorySection;
/// No description provided for @clientInfoTransferSection.
///
/// In en, this message translates to:
/// **'Transfer'**
String get clientInfoTransferSection;
/// No description provided for @clientInfoClientId.
///
/// In en, this message translates to:
/// **'Client ID'**
String get clientInfoClientId;
/// No description provided for @clientInfoDatabaseId.
///
/// In en, this message translates to:
/// **'Database ID'**
String get clientInfoDatabaseId;
/// No description provided for @clientInfoUniqueId.
///
/// In en, this message translates to:
/// **'Unique ID'**
String get clientInfoUniqueId;
/// No description provided for @clientInfoDescription.
///
/// In en, this message translates to:
/// **'Description'**
String get clientInfoDescription;
/// No description provided for @clientInfoAvatar.
///
/// In en, this message translates to:
/// **'Avatar'**
String get clientInfoAvatar;
/// No description provided for @clientInfoServerGroups.
///
/// In en, this message translates to:
/// **'Server groups'**
String get clientInfoServerGroups;
/// No description provided for @clientInfoChannelGroup.
///
/// In en, this message translates to:
/// **'Channel group'**
String get clientInfoChannelGroup;
/// No description provided for @clientInfoChannelId.
///
/// In en, this message translates to:
/// **'Channel ID'**
String get clientInfoChannelId;
/// No description provided for @clientInfoOnline.
///
/// In en, this message translates to:
/// **'Online'**
String get clientInfoOnline;
/// No description provided for @clientInfoIdle.
///
/// In en, this message translates to:
/// **'Idle'**
String get clientInfoIdle;
/// No description provided for @clientInfoPing.
///
/// In en, this message translates to:
/// **'Ping'**
String get clientInfoPing;
/// No description provided for @clientInfoAddress.
///
/// In en, this message translates to:
/// **'Address'**
String get clientInfoAddress;
/// No description provided for @clientInfoPacketLossClientToServer.
///
/// In en, this message translates to:
/// **'Packet loss C->S'**
String get clientInfoPacketLossClientToServer;
/// No description provided for @clientInfoPacketLossServerToClient.
///
/// In en, this message translates to:
/// **'Packet loss S->C'**
String get clientInfoPacketLossServerToClient;
/// No description provided for @clientInfoFirstConnected.
///
/// In en, this message translates to:
/// **'First connected'**
String get clientInfoFirstConnected;
/// No description provided for @clientInfoLastConnected.
///
/// In en, this message translates to:
/// **'Last connected'**
String get clientInfoLastConnected;
/// No description provided for @clientInfoConnections.
///
/// In en, this message translates to:
/// **'Connections'**
String get clientInfoConnections;
/// No description provided for @clientInfoDownloadedMonth.
///
/// In en, this message translates to:
/// **'Downloaded this month'**
String get clientInfoDownloadedMonth;
/// No description provided for @clientInfoUploadedMonth.
///
/// In en, this message translates to:
/// **'Uploaded this month'**
String get clientInfoUploadedMonth;
/// No description provided for @clientInfoDownloadedTotal.
///
/// In en, this message translates to:
/// **'Downloaded total'**
String get clientInfoDownloadedTotal;
/// No description provided for @clientInfoUploadedTotal.
///
/// In en, this message translates to:
/// **'Uploaded total'**
String get clientInfoUploadedTotal;
/// No description provided for @clientInfoUnknown.
///
/// In en, this message translates to:
/// **'Unknown'**
String get clientInfoUnknown;
/// No description provided for @clientInfoHidden.
///
/// In en, this message translates to:
/// **'Hidden'**
String get clientInfoHidden;
/// No description provided for @clientInfoNone.
///
/// In en, this message translates to:
/// **'None'**
String get clientInfoNone;
/// No description provided for @pokeSnackBarClearAction.
///
/// In en, this message translates to:
@@ -62,6 +62,12 @@ class AppL10nEn extends AppL10n {
@override
String get diagnosticsSaveAction => 'Save export';
@override
String get diagnosticsLiveUpdating => 'Live updating';
@override
String get shareAction => 'Share';
@override
String diagnosticsSaved(String path) {
return 'Diagnostic export saved to $path';
@@ -102,6 +108,36 @@ class AppL10nEn extends AppL10n {
@override
String get closeAction => 'Close';
@override
String get openAction => 'Open';
@override
String get cancelAction => 'Cancel';
@override
String get retryAction => 'Retry';
@override
String get chatAction => 'Chat';
@override
String get chatCloseAction => 'Close chat';
@override
String get chatNewPrivateAction => 'New private chat';
@override
String get chatSearchClientsHint => 'Search clients...';
@override
String get chatDirectMessageAction => 'Private message';
@override
String get chatPokeAction => 'Poke';
@override
String get clientInfoAction => 'Info';
@override
String get startAudioAction => 'Start audio';
@@ -256,6 +292,9 @@ class AppL10nEn extends AppL10n {
@override
String get clientsHeading => 'Online clients';
@override
String get serverWelcomeHeading => 'Server welcome message';
@override
String countChannelsAndClients(int channels, int clients) {
return '$channels channels • $clients online';
@@ -388,6 +427,19 @@ class AppL10nEn extends AppL10n {
@override
String get permissionGrantAction => 'Grant';
@override
String get startupPermissionsTitle => 'Permissions';
@override
String get startupPermissionsBody =>
'Chanora requests microphone, Bluetooth headset, and notification permissions at startup so voice, headset routing, and the foreground session work correctly.';
@override
String get startupPermissionsNotNow => 'Not now';
@override
String get startupPermissionsAllow => 'Allow';
@override
String get audioRouteSystemDefault => 'System default';
@@ -415,6 +467,117 @@ class AppL10nEn extends AppL10n {
@override
String get iosAudioResuming => 'Audio resuming';
@override
String get linkTrustTitle => 'Open external link?';
@override
String linkTrustBody(String domain) {
return 'You are about to open a link to:\n\n$domain';
}
@override
String get linkTrustRememberDomain => 'Trust all links from this domain';
@override
String get clientInfoFetchingProfile => 'Fetching TeamSpeak profile';
@override
String get clientInfoLoadingProfile => 'Loading profile...';
@override
String get clientInfoProfileUnavailable => 'Profile unavailable';
@override
String get clientInfoProfileUnavailableBody =>
'The server did not return profile details for this client.';
@override
String get clientInfoIdentitySection => 'Identity';
@override
String get clientInfoMembershipSection => 'Membership';
@override
String get clientInfoConnectionSection => 'Connection';
@override
String get clientInfoHistorySection => 'History';
@override
String get clientInfoTransferSection => 'Transfer';
@override
String get clientInfoClientId => 'Client ID';
@override
String get clientInfoDatabaseId => 'Database ID';
@override
String get clientInfoUniqueId => 'Unique ID';
@override
String get clientInfoDescription => 'Description';
@override
String get clientInfoAvatar => 'Avatar';
@override
String get clientInfoServerGroups => 'Server groups';
@override
String get clientInfoChannelGroup => 'Channel group';
@override
String get clientInfoChannelId => 'Channel ID';
@override
String get clientInfoOnline => 'Online';
@override
String get clientInfoIdle => 'Idle';
@override
String get clientInfoPing => 'Ping';
@override
String get clientInfoAddress => 'Address';
@override
String get clientInfoPacketLossClientToServer => 'Packet loss C->S';
@override
String get clientInfoPacketLossServerToClient => 'Packet loss S->C';
@override
String get clientInfoFirstConnected => 'First connected';
@override
String get clientInfoLastConnected => 'Last connected';
@override
String get clientInfoConnections => 'Connections';
@override
String get clientInfoDownloadedMonth => 'Downloaded this month';
@override
String get clientInfoUploadedMonth => 'Uploaded this month';
@override
String get clientInfoDownloadedTotal => 'Downloaded total';
@override
String get clientInfoUploadedTotal => 'Uploaded total';
@override
String get clientInfoUnknown => 'Unknown';
@override
String get clientInfoHidden => 'Hidden';
@override
String get clientInfoNone => 'None';
@override
String get pokeSnackBarClearAction => 'Clear';
@@ -59,6 +59,12 @@ class AppL10nZh extends AppL10n {
@override
String get diagnosticsSaveAction => '保存导出';
@override
String get diagnosticsLiveUpdating => '实时更新中';
@override
String get shareAction => '分享';
@override
String diagnosticsSaved(String path) {
return '诊断导出已保存到 $path';
@@ -99,6 +105,36 @@ class AppL10nZh extends AppL10n {
@override
String get closeAction => '关闭';
@override
String get openAction => '打开';
@override
String get cancelAction => '取消';
@override
String get retryAction => '重试';
@override
String get chatAction => '聊天';
@override
String get chatCloseAction => '关闭聊天';
@override
String get chatNewPrivateAction => '新建私聊';
@override
String get chatSearchClientsHint => '搜索用户...';
@override
String get chatDirectMessageAction => '私聊';
@override
String get chatPokeAction => '戳一下';
@override
String get clientInfoAction => '信息';
@override
String get startAudioAction => '启动语音';
@@ -249,6 +285,9 @@ class AppL10nZh extends AppL10n {
@override
String get clientsHeading => '在线用户';
@override
String get serverWelcomeHeading => '服务器欢迎信息';
@override
String countChannelsAndClients(int channels, int clients) {
return '$channels 个频道 • $clients 在线';
@@ -378,6 +417,19 @@ class AppL10nZh extends AppL10n {
@override
String get permissionGrantAction => '授权';
@override
String get startupPermissionsTitle => '权限';
@override
String get startupPermissionsBody =>
'Chanora 会在启动时请求麦克风、蓝牙耳机和通知权限,以确保语音、耳机路由和前台会话正常工作。';
@override
String get startupPermissionsNotNow => '暂不';
@override
String get startupPermissionsAllow => '允许';
@override
String get audioRouteSystemDefault => '系统默认';
@@ -405,6 +457,116 @@ class AppL10nZh extends AppL10n {
@override
String get iosAudioResuming => '音频正在恢复';
@override
String get linkTrustTitle => '打开外部链接?';
@override
String linkTrustBody(String domain) {
return '你将打开指向以下域名的链接:\n\n$domain';
}
@override
String get linkTrustRememberDomain => '信任来自此域名的所有链接';
@override
String get clientInfoFetchingProfile => '正在获取 TeamSpeak 资料';
@override
String get clientInfoLoadingProfile => '正在加载资料…';
@override
String get clientInfoProfileUnavailable => '资料不可用';
@override
String get clientInfoProfileUnavailableBody => '服务器没有返回此用户的资料详情。';
@override
String get clientInfoIdentitySection => '身份';
@override
String get clientInfoMembershipSection => '成员关系';
@override
String get clientInfoConnectionSection => '连接';
@override
String get clientInfoHistorySection => '历史';
@override
String get clientInfoTransferSection => '传输';
@override
String get clientInfoClientId => '用户 ID';
@override
String get clientInfoDatabaseId => '数据库 ID';
@override
String get clientInfoUniqueId => '唯一 ID';
@override
String get clientInfoDescription => '描述';
@override
String get clientInfoAvatar => '头像';
@override
String get clientInfoServerGroups => '服务器组';
@override
String get clientInfoChannelGroup => '频道组';
@override
String get clientInfoChannelId => '频道 ID';
@override
String get clientInfoOnline => '在线时长';
@override
String get clientInfoIdle => '空闲';
@override
String get clientInfoPing => '延迟';
@override
String get clientInfoAddress => '地址';
@override
String get clientInfoPacketLossClientToServer => '丢包 C->S';
@override
String get clientInfoPacketLossServerToClient => '丢包 S->C';
@override
String get clientInfoFirstConnected => '首次连接';
@override
String get clientInfoLastConnected => '上次连接';
@override
String get clientInfoConnections => '连接次数';
@override
String get clientInfoDownloadedMonth => '本月下载';
@override
String get clientInfoUploadedMonth => '本月上传';
@override
String get clientInfoDownloadedTotal => '总下载';
@override
String get clientInfoUploadedTotal => '总上传';
@override
String get clientInfoUnknown => '未知';
@override
String get clientInfoHidden => '隐藏';
@override
String get clientInfoNone => '';
@override
String get pokeSnackBarClearAction => '清除';
File diff suppressed because it is too large Load Diff
@@ -18,19 +18,17 @@ Future<void>? _storageInitFuture;
Future<void>? _vadBootstrapFuture;
StorageDirectoryProvider _storageDirectoryProvider =
getApplicationSupportDirectory;
StorageDirectoryProvider _vadModelDirectoryProvider =
getApplicationSupportDirectory;
StorageInitializer _storageInitializer = _defaultStorageInitializer;
Future<void> _defaultStorageInitializer(String dir) {
return rust.initStorage(dir: dir);
}
Future<File> _copyBundledAssetToManagedDirectory({
Future<File> _copyBundledAssetToDocuments({
required String assetPath,
required String fileName,
}) async {
final dir = await _vadModelDirectoryProvider();
final dir = await getApplicationDocumentsDirectory();
final file = File('${dir.path}/$fileName');
final data = await rootBundle.load(assetPath);
final bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
@@ -61,7 +59,7 @@ Future<void> configureBundledVadModels() async {
}
Future<void> _configureBundledVadModelsImpl() async {
final silero = await _copyBundledAssetToManagedDirectory(
final silero = await _copyBundledAssetToDocuments(
assetPath: _sileroVadAsset,
fileName: 'silero_vad.onnx',
);
@@ -126,15 +124,12 @@ Future<void> _wireStorageImpl() async {
@visibleForTesting
void debugResetStorageBootstrap({
StorageDirectoryProvider? storageDirectoryProvider,
StorageDirectoryProvider? vadModelDirectoryProvider,
StorageInitializer? storageInitializer,
}) {
_storageInitFuture = null;
_vadBootstrapFuture = null;
_storageDirectoryProvider =
storageDirectoryProvider ?? getApplicationSupportDirectory;
_vadModelDirectoryProvider =
vadModelDirectoryProvider ?? getApplicationSupportDirectory;
_storageInitializer = storageInitializer ?? _defaultStorageInitializer;
}
@@ -35,6 +35,12 @@ extension ConnectionPhaseState on ConnectionPhase {
bool get canDisconnect => canOpenChat;
bool shouldShowSnapshotLoading({required bool hasSnapshot}) =>
isServerReachable && !hasSnapshot;
bool canOpenChatWithSnapshot({required bool hasSnapshot}) =>
canOpenChat && hasSnapshot;
ConnectionTokens tokens(ColorScheme colorScheme) {
switch (this) {
case ConnectionPhase.idle:
@@ -52,6 +58,15 @@ extension ConnectionPhaseState on ConnectionPhase {
}
}
ConnectionPhase phaseAfterConnectedEvent(
ConnectionPhase phase, {
required bool hasSnapshot,
}) {
return phase == ConnectionPhase.connected && hasSnapshot
? ConnectionPhase.connected
: ConnectionPhase.synchronizing;
}
String connectionStatusText({
required ConnectionPhase phase,
required AppL10n l10n,
@@ -77,3 +92,9 @@ String connectionStatusText({
return l10n.statusIdle;
}
}
ConnectionPhase phaseAfterSnapshotApplied(ConnectionPhase phase) {
return phase == ConnectionPhase.synchronizing
? ConnectionPhase.connected
: phase;
}
@@ -1,4 +1,6 @@
import 'package:flutter/material.dart';
import '../l10n/generated/app_localizations.dart';
import 'package:shared_preferences/shared_preferences.dart';
class LinkTrustService extends ChangeNotifier {
@@ -54,42 +56,45 @@ Future<bool?> showLinkTrustDialog(BuildContext context, String domain) async {
return showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
title: const Text('Open external link?'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('You are about to open a link to:\n\n$domain'),
const SizedBox(height: 12),
Row(
children: [
SizedBox(
width: 24,
height: 24,
child: Checkbox(
value: remember,
onChanged: (v) =>
setDialogState(() => remember = v ?? false),
builder: (ctx, setDialogState) {
final l10n = AppL10n.of(ctx);
return AlertDialog(
title: Text(l10n.linkTrustTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.linkTrustBody(domain)),
const SizedBox(height: 12),
Row(
children: [
SizedBox(
width: 24,
height: 24,
child: Checkbox(
value: remember,
onChanged: (v) =>
setDialogState(() => remember = v ?? false),
),
),
),
const SizedBox(width: 8),
const Flexible(child: Text('Trust all links from this domain')),
],
const SizedBox(width: 8),
Flexible(child: Text(l10n.linkTrustRememberDomain)),
],
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(null),
child: Text(l10n.cancelAction),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(remember),
child: Text(l10n.openAction),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(null),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(remember),
child: const Text('Open'),
),
],
),
);
},
),
);
}
@@ -0,0 +1,31 @@
import 'dart:async';
class PrefetchDebouncer {
PrefetchDebouncer({
required this.onPrefetch,
this.delay = const Duration(milliseconds: 700),
});
final Future<void> Function(String host) onPrefetch;
final Duration delay;
Timer? _timer;
bool _disposed = false;
void schedule(String rawHost) {
if (_disposed) return;
_timer?.cancel();
final host = rawHost.trim();
if (host.isEmpty) return;
_timer = Timer(delay, () {
if (_disposed) return;
unawaited(onPrefetch(host));
});
}
void dispose() {
_disposed = true;
_timer?.cancel();
_timer = null;
}
}
@@ -1,444 +0,0 @@
import 'dart:ffi';
import 'dart:io';
import 'package:flutter/foundation.dart' show visibleForTesting;
import '../src/rust/api.dart' as rust;
class StartupDependencyCheckResult {
const StartupDependencyCheckResult({
required this.issues,
required this.platformLabel,
});
final List<StartupDependencyIssue> issues;
final String platformLabel;
bool get hasIssues => issues.isNotEmpty;
bool get hasBlockingIssues => issues.any((issue) => issue.isRequired);
bool get hasOnnxRuntime => !issues.any((issue) => issue.id == 'linux-onnxruntime');
}
class StartupDependencyIssue {
const StartupDependencyIssue({
required this.id,
required this.title,
required this.summary,
required this.details,
required this.severity,
this.installHints = const [],
});
final String id;
final String title;
final String summary;
final List<String> details;
final StartupDependencySeverity severity;
final List<StartupInstallHint> installHints;
bool get isRequired => severity == StartupDependencySeverity.required;
}
class StartupInstallHint {
const StartupInstallHint({required this.label, required this.command});
final String label;
final String command;
}
enum StartupDependencySeverity { required, recommended }
enum _LinuxDistro { debian, fedora, arch, other }
enum _LinuxArch { x64, arm64, other }
typedef _LibraryProbe = bool Function(String candidate);
typedef _FileExists = Future<bool> Function(String path);
typedef _ResolvedExecutableProvider = String Function();
typedef _CurrentDirectoryProvider = String Function();
typedef _OsReleaseProvider = Future<String?> Function();
typedef _PlatformIsLinux = bool Function();
typedef _LogFilePathProvider = String Function();
typedef _CurrentAbiProvider = Abi Function();
_LibraryProbe _libraryProbe = _defaultLibraryProbe;
_FileExists _fileExists = _defaultFileExists;
_ResolvedExecutableProvider _resolvedExecutableProvider = () =>
Platform.resolvedExecutable;
_CurrentDirectoryProvider _currentDirectoryProvider = () =>
Directory.current.path;
_OsReleaseProvider _osReleaseProvider = _defaultOsReleaseProvider;
_PlatformIsLinux _platformIsLinux = () => Platform.isLinux;
_LogFilePathProvider _logFilePathProvider = rust.logFilePathStr;
_CurrentAbiProvider _currentAbiProvider = Abi.current;
Future<void> logStartupDependencyIssues(
StartupDependencyCheckResult result,
) async {
if (!result.hasIssues) return;
final path = _logFilePathProvider();
if (path.isEmpty) return;
final issues = result.issues
.map(
(issue) =>
'${issue.id}:${issue.isRequired ? 'required' : 'recommended'}:'
'${_sanitizeLogValue(issue.title)}',
)
.join(', ');
final line =
'${DateTime.now().toUtc().toIso8601String()} '
'[startup_dependency_check] '
'platform="${result.platformLabel}" '
'blocking=${result.hasBlockingIssues} '
'issues=[$issues]';
try {
await File(
path,
).writeAsString('$line\n', mode: FileMode.append, flush: true);
} catch (_) {
// Best-effort only: a missing/unwritable log file must not block app startup.
}
}
String _sanitizeLogValue(String value) => value.replaceAll('"', "'");
Future<StartupDependencyCheckResult> checkStartupDependencies() async {
if (!_platformIsLinux()) {
return const StartupDependencyCheckResult(
issues: [],
platformLabel: 'default',
);
}
final distro = await _detectLinuxDistro();
final arch = _detectLinuxArch();
final executableDir = File(_resolvedExecutableProvider()).parent.path;
final issues = <StartupDependencyIssue>[];
if (!_hasAnyLoadableLibrary(const [
'libpipewire-0.3.so.0',
'libpipewire-0.3.so',
])) {
issues.add(_buildPipeWireIssue(distro));
}
if (!_hasAnyLoadableLibrary(const [
'libpulse.so.0',
'libpulse.so',
'libpulse-simple.so.0',
'libpulse-simple.so',
])) {
issues.add(_buildPulseAudioIssue(distro));
}
if (!await _hasOnnxRuntime(executableDir: executableDir)) {
issues.add(_buildOnnxIssue(executableDir: executableDir, arch: arch));
}
return StartupDependencyCheckResult(
issues: issues,
platformLabel: switch (distro) {
_LinuxDistro.debian => 'Debian / Ubuntu',
_LinuxDistro.fedora => 'Fedora',
_LinuxDistro.arch => 'Arch Linux',
_LinuxDistro.other => 'Linux',
},
);
}
bool _defaultLibraryProbe(String candidate) {
try {
DynamicLibrary.open(candidate);
return true;
} catch (_) {
return false;
}
}
Future<bool> _defaultFileExists(String path) => File(path).exists();
Future<String?> _defaultOsReleaseProvider() async {
const path = '/etc/os-release';
final file = File(path);
if (!await file.exists()) {
return null;
}
return file.readAsString();
}
bool _hasAnyLoadableLibrary(List<String> candidates) {
for (final candidate in candidates) {
if (_libraryProbe(candidate)) {
return true;
}
}
return false;
}
Future<bool> _hasOnnxRuntime({required String executableDir}) async {
final envPath = Platform.environment['ORT_DYLIB_PATH'];
if (envPath != null && envPath.isNotEmpty && await _fileExists(envPath)) {
return true;
}
final candidates = <String>{
'$executableDir/lib/libonnxruntime.so',
'$executableDir/libonnxruntime.so',
'${_currentDirectoryProvider()}/libonnxruntime.so',
'/usr/lib/libonnxruntime.so',
'/usr/lib64/libonnxruntime.so',
'/usr/local/lib/libonnxruntime.so',
'/lib/x86_64-linux-gnu/libonnxruntime.so',
'/usr/lib/x86_64-linux-gnu/libonnxruntime.so',
'/lib/aarch64-linux-gnu/libonnxruntime.so',
'/usr/lib/aarch64-linux-gnu/libonnxruntime.so',
};
for (final candidate in candidates) {
if (await _fileExists(candidate)) {
return true;
}
}
for (final dir in const [
'/usr/lib',
'/usr/lib64',
'/usr/local/lib',
'/usr/lib/x86_64-linux-gnu',
'/usr/lib/aarch64-linux-gnu',
]) {
final match = await _firstMatchingDirEntry(dir, 'libonnxruntime.so');
if (match != null) {
return true;
}
}
return _hasAnyLoadableLibrary(const ['libonnxruntime.so']);
}
Future<String?> _firstMatchingDirEntry(String dir, String prefix) async {
final directory = Directory(dir);
if (!await directory.exists()) {
return null;
}
await for (final entity in directory.list(followLinks: false)) {
if (entity is! File) {
continue;
}
final name = entity.uri.pathSegments.isEmpty
? ''
: entity.uri.pathSegments.last;
if (name == prefix || name.startsWith('$prefix.')) {
return entity.path;
}
}
return null;
}
Future<_LinuxDistro> _detectLinuxDistro() async {
final content = await _osReleaseProvider();
if (content == null || content.isEmpty) {
return _LinuxDistro.other;
}
final fields = _parseOsRelease(content);
final id = fields['id'] ?? '';
final idLike = (fields['id_like'] ?? '').split(RegExp(r'\s+'));
final ids = {id, ...idLike};
if (ids.contains('fedora')) {
return _LinuxDistro.fedora;
}
if (ids.contains('ubuntu') || ids.contains('debian')) {
return _LinuxDistro.debian;
}
if (ids.contains('arch')) {
return _LinuxDistro.arch;
}
return _LinuxDistro.other;
}
Map<String, String> _parseOsRelease(String content) {
final fields = <String, String>{};
for (final line in content.split('\n')) {
final separator = line.indexOf('=');
if (separator <= 0) continue;
final key = line.substring(0, separator).trim().toLowerCase();
var value = line.substring(separator + 1).trim().toLowerCase();
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
value = value.substring(1, value.length - 1);
}
fields[key] = value;
}
return fields;
}
_LinuxArch _detectLinuxArch() {
return switch (_currentAbiProvider()) {
Abi.linuxX64 => _LinuxArch.x64,
Abi.linuxArm64 => _LinuxArch.arm64,
_ => _LinuxArch.other,
};
}
StartupDependencyIssue _buildPipeWireIssue(_LinuxDistro distro) {
final List<StartupInstallHint> hints = switch (distro) {
_LinuxDistro.debian => const <StartupInstallHint>[
StartupInstallHint(
label: 'Debian / Ubuntu',
command: 'sudo apt install libpipewire-0.3-0',
),
],
_LinuxDistro.fedora => const <StartupInstallHint>[
StartupInstallHint(
label: 'Fedora',
command: 'sudo dnf install pipewire-libs',
),
],
_LinuxDistro.arch => const <StartupInstallHint>[
StartupInstallHint(
label: 'Arch Linux',
command: 'sudo pacman -S pipewire',
),
],
_LinuxDistro.other => const <StartupInstallHint>[],
};
return StartupDependencyIssue(
id: 'linux-pipewire-runtime',
title: 'PipeWire runtime is missing',
summary:
'Chanora uses PipeWire as the primary Linux voice backend. Without it, Chanora will try the PulseAudio fallback.',
details: const [
'Install the PipeWire runtime package for your distribution.',
'After installing it, restart Chanora and tap Recheck.',
],
severity: StartupDependencySeverity.recommended,
installHints: hints,
);
}
StartupDependencyIssue _buildPulseAudioIssue(_LinuxDistro distro) {
final List<StartupInstallHint> hints = switch (distro) {
_LinuxDistro.debian => const <StartupInstallHint>[
StartupInstallHint(
label: 'Debian / Ubuntu',
command: 'sudo apt install libpulse0',
),
],
_LinuxDistro.fedora => const <StartupInstallHint>[
StartupInstallHint(
label: 'Fedora',
command: 'sudo dnf install pulseaudio-libs',
),
],
_LinuxDistro.arch => const <StartupInstallHint>[
StartupInstallHint(
label: 'Arch Linux',
command: 'sudo pacman -S libpulse',
),
],
_LinuxDistro.other => const <StartupInstallHint>[],
};
return StartupDependencyIssue(
id: 'linux-pulseaudio-runtime',
title: 'PulseAudio runtime is missing',
summary:
'Chanora uses PulseAudio as the Linux fallback voice backend when PipeWire is unavailable.',
details: const [
'Install the PulseAudio client library package for your distribution.',
'After installing it, restart Chanora and tap Recheck.',
],
severity: StartupDependencySeverity.recommended,
installHints: hints,
);
}
StartupDependencyIssue _buildOnnxIssue({
required String executableDir,
required _LinuxArch arch,
}) {
final bundlePath = '$executableDir/lib/libonnxruntime.so';
final archivePrefix = switch (arch) {
_LinuxArch.x64 => 'onnxruntime-linux-x64-<version>.tgz',
_LinuxArch.arm64 => 'onnxruntime-linux-aarch64-<version>.tgz',
_LinuxArch.other => 'onnxruntime-linux-<arch>-<version>.tgz',
};
final archiveDescription = switch (arch) {
_LinuxArch.x64 =>
'This machine needs the Linux x64 CPU archive ($archivePrefix).',
_LinuxArch.arm64 =>
'This machine needs the Linux ARM64 / aarch64 CPU archive ($archivePrefix).',
_LinuxArch.other =>
'Download the Linux CPU archive that matches this machine ($archivePrefix).',
};
return StartupDependencyIssue(
id: 'linux-onnxruntime',
title: 'ONNX Runtime is not available',
summary:
'Silero voice activity detection needs libonnxruntime.so. Chanora can continue, but it will fall back to simpler voice detection.',
details: [
archiveDescription,
'Open the official ONNX Runtime releases page, download the matching Linux CPU archive, then extract it.',
'Inside the extracted archive, use the file in lib/ named libonnxruntime.so (or the versioned libonnxruntime.so.* file it points to).',
'Use a Chanora build that already bundles ONNX Runtime, or place libonnxruntime.so in the app bundle lib/ directory.',
'You can also point Chanora at an existing ONNX Runtime shared library with ORT_DYLIB_PATH.',
],
severity: StartupDependencySeverity.recommended,
installHints: [
StartupInstallHint(
label: switch (arch) {
_LinuxArch.x64 => 'Download Linux x64 archive',
_LinuxArch.arm64 => 'Download Linux ARM64 archive',
_LinuxArch.other => 'Open release downloads',
},
command: 'https://github.com/microsoft/onnxruntime/releases',
),
StartupInstallHint(
label: 'Archive name to look for',
command: archivePrefix,
),
const StartupInstallHint(
label: 'Official install reference',
command: 'https://onnxruntime.ai/docs/install/',
),
const StartupInstallHint(
label: 'Temporary shell setup',
command: 'export ORT_DYLIB_PATH=/absolute/path/to/libonnxruntime.so',
),
StartupInstallHint(
label: 'Bundle into this app',
command: 'cp /absolute/path/to/libonnxruntime.so $bundlePath',
),
StartupInstallHint(label: 'Bundled file location', command: bundlePath),
],
);
}
@visibleForTesting
void debugResetStartupDependencyCheck({
bool Function(String)? libraryProbe,
Future<bool> Function(String path)? fileExists,
String Function()? resolvedExecutableProvider,
String Function()? currentDirectoryProvider,
Future<String?> Function()? osReleaseProvider,
bool Function()? platformIsLinux,
String Function()? logFilePathProvider,
Abi Function()? currentAbiProvider,
}) {
_libraryProbe = libraryProbe ?? _defaultLibraryProbe;
_fileExists = fileExists ?? _defaultFileExists;
_resolvedExecutableProvider =
resolvedExecutableProvider ?? (() => Platform.resolvedExecutable);
_currentDirectoryProvider =
currentDirectoryProvider ?? (() => Directory.current.path);
_osReleaseProvider = osReleaseProvider ?? _defaultOsReleaseProvider;
_platformIsLinux = platformIsLinux ?? (() => Platform.isLinux);
_logFilePathProvider = logFilePathProvider ?? rust.logFilePathStr;
_currentAbiProvider = currentAbiProvider ?? Abi.current;
}
@@ -1,53 +1,35 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
enum UiThemeMode {
system,
light,
dark;
static UiThemeMode fromStorage(String? value) {
return UiThemeMode.values.firstWhere(
(mode) => mode.name == value,
orElse: () => UiThemeMode.system,
);
}
}
class UiSettings {
const UiSettings({this.host = '', this.nickname = ''});
const UiSettings({
this.host = '',
this.nickname = '',
this.themeMode = UiThemeMode.system,
});
final String host;
final String nickname;
}
class ClientPlaybackPreference {
const ClientPlaybackPreference({this.volume = 1.0, this.muted = false});
final double volume;
final bool muted;
double get appliedVolume => muted ? 0.0 : volume;
ClientPlaybackPreference copyWith({double? volume, bool? muted}) {
return ClientPlaybackPreference(
volume: _clampVolume(volume ?? this.volume),
muted: muted ?? this.muted,
);
}
Map<String, Object> toJson() => {
'volume': _clampVolume(volume),
'muted': muted,
};
static ClientPlaybackPreference fromJson(Map<String, dynamic> json) {
final volume = json['volume'];
return ClientPlaybackPreference(
volume: _clampVolume(volume is num ? volume.toDouble() : 1.0),
muted: json['muted'] as bool? ?? false,
);
}
static double _clampVolume(double volume) {
if (!volume.isFinite) return 1.0;
return volume.clamp(0.0, 4.0);
}
final UiThemeMode themeMode;
}
class UiPreferencesService {
static const _hostKey = 'ui.host';
static const _nicknameKey = 'ui.nickname';
static const _themeModeKey = 'ui.theme_mode';
static const _permissionsExplainedKey = 'perms_explained';
static const _clientPlaybackPrefsKey = 'audio.client_playback_prefs';
const UiPreferencesService();
@@ -56,6 +38,7 @@ class UiPreferencesService {
return UiSettings(
host: prefs.getString(_hostKey) ?? '',
nickname: prefs.getString(_nicknameKey) ?? '',
themeMode: UiThemeMode.fromStorage(prefs.getString(_themeModeKey)),
);
}
@@ -65,6 +48,11 @@ class UiPreferencesService {
if (nickname != null) await prefs.setString(_nicknameKey, nickname);
}
Future<void> saveThemeMode(UiThemeMode themeMode) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_themeModeKey, themeMode.name);
}
Future<bool> hasExplainedPermissions() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_permissionsExplainedKey) ?? false;
@@ -74,80 +62,4 @@ class UiPreferencesService {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_permissionsExplainedKey, true);
}
Future<Map<String, ClientPlaybackPreference>>
loadClientPlaybackPreferencesForServer(String serverHost) async {
final normalizedHost = _normalizeServerHost(serverHost);
if (normalizedHost.isEmpty) return const {};
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_clientPlaybackPrefsKey);
if (raw == null || raw.isEmpty) return const {};
final decoded = _decodeClientPlaybackPreferences(raw);
final result = <String, ClientPlaybackPreference>{};
for (final entry in decoded.entries) {
final key = entry.key;
final value = entry.value;
final (host, uid) = _splitCompositeKey(key);
if (host != normalizedHost ||
uid.isEmpty ||
value is! Map<String, dynamic>) {
continue;
}
result[uid] = ClientPlaybackPreference.fromJson(value);
}
return result;
}
Future<void> saveClientPlaybackPreference({
required String serverHost,
required String userUid,
double? volume,
bool? muted,
}) async {
final normalizedHost = _normalizeServerHost(serverHost);
final normalizedUid = userUid.trim();
if (normalizedHost.isEmpty || normalizedUid.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final current = await loadClientPlaybackPreferencesForServer(serverHost);
final previous = current[normalizedUid] ?? const ClientPlaybackPreference();
final updated = previous.copyWith(volume: volume, muted: muted);
final raw = prefs.getString(_clientPlaybackPrefsKey);
final decoded = _decodeClientPlaybackPreferences(raw);
final compositeKey = _compositeKey(normalizedHost, normalizedUid);
if (updated.volume == 1.0 && !updated.muted) {
decoded.remove(compositeKey);
} else {
decoded[compositeKey] = updated.toJson();
}
await prefs.setString(_clientPlaybackPrefsKey, jsonEncode(decoded));
}
String _compositeKey(String normalizedHost, String normalizedUid) {
return '$normalizedHost|$normalizedUid';
}
(String, String) _splitCompositeKey(String key) {
final index = key.indexOf('|');
if (index == -1) return ('', '');
return (key.substring(0, index), key.substring(index + 1));
}
static String _normalizeServerHost(String host) => host.trim().toLowerCase();
Map<String, dynamic> _decodeClientPlaybackPreferences(String? raw) {
if (raw == null || raw.isEmpty) return <String, dynamic>{};
try {
final decoded = jsonDecode(raw);
return decoded is Map<String, dynamic> ? decoded : <String, dynamic>{};
} catch (_) {
return <String, dynamic>{};
}
}
}
+178 -9
View File
@@ -10,7 +10,7 @@ import 'package:freezed_annotation/freezed_annotation.dart' hide protected;
part 'api.freezed.dart';
// These functions are ignored because they are not marked as `pub`: `install_panic_diagnostic_hook`, `log_file_path`, `log_sink`, `map_join_error_code`, `map_join_sync_state`, `open_log_file`, `permission_events`, `publish_permission_state`, `runtime`, `session`, `task_join_error`, `transmit_mode_from_u8`
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`
// These functions are ignored (category: IgnoreBecauseExplicitAttribute): `from_kotlin_str`, `to_permission_gate`
/// Return the platform-conventional log-file path as a string, or
@@ -33,9 +33,19 @@ Future<BridgeSnapshot> connect({
password: password,
);
/// Warm server address resolution for the active host field. This is
/// intentionally fire-and-forget from the UI perspective: it schedules
/// Rust-side prefetch work and never opens a TS3 session.
Future<void> prefetchServer({required String host}) =>
RustLib.instance.api.crateApiPrefetchServer(host: host);
/// Re-fetch a fresh snapshot from the active connection.
Future<BridgeSnapshot> snapshot() => RustLib.instance.api.crateApiSnapshot();
/// Fetch richer profile and live connection details for one online client.
Future<BridgeClientProfile> clientProfile({required BigInt clientId}) =>
RustLib.instance.api.crateApiClientProfile(clientId: clientId);
/// Disconnect from the server. No-op if not connected.
Future<void> disconnect() => RustLib.instance.api.crateApiDisconnect();
@@ -198,8 +208,7 @@ Future<void> sendChatMessage({
/// On non-Android targets or before a voice session opens the
/// section is omitted. Per SDD-090 every field in that section is
/// a device-side technical scalar — no PII admitted.
Future<String> exportDiagnostics() =>
RustLib.instance.api.crateApiExportDiagnostics();
String exportDiagnostics() => RustLib.instance.api.crateApiExportDiagnostics();
/// Wire the identity persistence store to a platform-private
/// directory. Should be called once on app start after Flutter has
@@ -796,9 +805,6 @@ class BridgeClient {
/// Stable client id.
final BigInt id;
/// Stable TeamSpeak unique identifier.
final String uid;
/// Channel id the client is currently in.
final BigInt channel;
@@ -825,7 +831,6 @@ class BridgeClient {
const BridgeClient({
required this.id,
required this.uid,
required this.channel,
required this.name,
required this.inputMuted,
@@ -839,7 +844,6 @@ class BridgeClient {
@override
int get hashCode =>
id.hashCode ^
uid.hashCode ^
channel.hashCode ^
name.hashCode ^
inputMuted.hashCode ^
@@ -855,7 +859,6 @@ class BridgeClient {
other is BridgeClient &&
runtimeType == other.runtimeType &&
id == other.id &&
uid == other.uid &&
channel == other.channel &&
name == other.name &&
inputMuted == other.inputMuted &&
@@ -866,6 +869,172 @@ class BridgeClient {
talkPowerGranted == other.talkPowerGranted;
}
/// Rich profile and live connection details for one online client.
class BridgeClientProfile {
/// Stable online client id.
final BigInt id;
/// Channel the client is currently in.
final BigInt channel;
/// Nickname.
final String name;
/// TeamSpeak unique id.
final String uniqueId;
/// Stable TeamSpeak database id, when visible.
final BigInt? databaseId;
/// ISO country code, when visible.
final String countryCode;
/// User description, when visible.
final String description;
/// Client version string.
final String version;
/// Client platform string.
final String platform;
/// Account creation time as Unix seconds.
final PlatformInt64? createdUnixSeconds;
/// Last connection time as Unix seconds.
final PlatformInt64? lastConnectedUnixSeconds;
/// Total historical connections.
final BigInt? connectionsTotal;
/// Current online duration in seconds.
final PlatformInt64? onlineSeconds;
/// Current idle time in milliseconds.
final PlatformInt64? idleMilliseconds;
/// Current ping in milliseconds.
final PlatformInt64? pingMilliseconds;
/// Client address. Empty when permission-gated.
final String clientAddress;
/// Resolved server group names.
final List<String> serverGroups;
/// Resolved channel group name.
final String channelGroup;
/// TeamSpeak avatar file path suffix.
final String avatarPath;
/// Downloaded bytes this month.
final BigInt? bytesDownloadedMonth;
/// Uploaded bytes this month.
final BigInt? bytesUploadedMonth;
/// Downloaded bytes across all time.
final BigInt? bytesDownloadedTotal;
/// Uploaded bytes across all time.
final BigInt? bytesUploadedTotal;
/// Client-to-server total packet loss ratio.
final double? packetLossClientToServerTotal;
/// Server-to-client total packet loss ratio.
final double? packetLossServerToClientTotal;
const BridgeClientProfile({
required this.id,
required this.channel,
required this.name,
required this.uniqueId,
this.databaseId,
required this.countryCode,
required this.description,
required this.version,
required this.platform,
this.createdUnixSeconds,
this.lastConnectedUnixSeconds,
this.connectionsTotal,
this.onlineSeconds,
this.idleMilliseconds,
this.pingMilliseconds,
required this.clientAddress,
required this.serverGroups,
required this.channelGroup,
required this.avatarPath,
this.bytesDownloadedMonth,
this.bytesUploadedMonth,
this.bytesDownloadedTotal,
this.bytesUploadedTotal,
this.packetLossClientToServerTotal,
this.packetLossServerToClientTotal,
});
@override
int get hashCode =>
id.hashCode ^
channel.hashCode ^
name.hashCode ^
uniqueId.hashCode ^
databaseId.hashCode ^
countryCode.hashCode ^
description.hashCode ^
version.hashCode ^
platform.hashCode ^
createdUnixSeconds.hashCode ^
lastConnectedUnixSeconds.hashCode ^
connectionsTotal.hashCode ^
onlineSeconds.hashCode ^
idleMilliseconds.hashCode ^
pingMilliseconds.hashCode ^
clientAddress.hashCode ^
serverGroups.hashCode ^
channelGroup.hashCode ^
avatarPath.hashCode ^
bytesDownloadedMonth.hashCode ^
bytesUploadedMonth.hashCode ^
bytesDownloadedTotal.hashCode ^
bytesUploadedTotal.hashCode ^
packetLossClientToServerTotal.hashCode ^
packetLossServerToClientTotal.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is BridgeClientProfile &&
runtimeType == other.runtimeType &&
id == other.id &&
channel == other.channel &&
name == other.name &&
uniqueId == other.uniqueId &&
databaseId == other.databaseId &&
countryCode == other.countryCode &&
description == other.description &&
version == other.version &&
platform == other.platform &&
createdUnixSeconds == other.createdUnixSeconds &&
lastConnectedUnixSeconds == other.lastConnectedUnixSeconds &&
connectionsTotal == other.connectionsTotal &&
onlineSeconds == other.onlineSeconds &&
idleMilliseconds == other.idleMilliseconds &&
pingMilliseconds == other.pingMilliseconds &&
clientAddress == other.clientAddress &&
serverGroups == other.serverGroups &&
channelGroup == other.channelGroup &&
avatarPath == other.avatarPath &&
bytesDownloadedMonth == other.bytesDownloadedMonth &&
bytesUploadedMonth == other.bytesUploadedMonth &&
bytesDownloadedTotal == other.bytesDownloadedTotal &&
bytesUploadedTotal == other.bytesUploadedTotal &&
packetLossClientToServerTotal ==
other.packetLossClientToServerTotal &&
packetLossServerToClientTotal == other.packetLossServerToClientTotal;
}
/// Bridge effect owner for AEC/NS/AGC.
enum BridgeEffectOwner {
/// Platform-owned effect.
@@ -248,7 +248,7 @@ return audioRouteChanged(_that.route);case _:
class BridgeEvent_Connected extends BridgeEvent {
const BridgeEvent_Connected({required this.serverName}): super._();
/// Server name reported by the server snapshot.
final String serverName;
@@ -315,7 +315,7 @@ as String,
class BridgeEvent_Lost extends BridgeEvent {
const BridgeEvent_Lost({required this.reason}): super._();
/// Reason classification from the protocol layer.
final String reason;
@@ -382,7 +382,7 @@ as String,
class BridgeEvent_Reconnecting extends BridgeEvent {
const BridgeEvent_Reconnecting({required this.attempt, required this.delaySecs}): super._();
/// 1-based attempt counter for the current outage.
final int attempt;
@@ -452,7 +452,7 @@ as int,
class BridgeEvent_Disconnected extends BridgeEvent {
const BridgeEvent_Disconnected({required this.reason}): super._();
/// Reason classification.
final String reason;
@@ -519,7 +519,7 @@ as String,
class BridgeEvent_AudioStarted extends BridgeEvent {
const BridgeEvent_AudioStarted(): super._();
@@ -551,7 +551,7 @@ String toString() {
class BridgeEvent_AudioStopped extends BridgeEvent {
const BridgeEvent_AudioStopped(): super._();
@@ -583,7 +583,7 @@ String toString() {
class BridgeEvent_SnapshotChanged extends BridgeEvent {
const BridgeEvent_SnapshotChanged({required this.channels, required this.clients}): super._();
/// Latest channel count.
final int channels;
@@ -653,7 +653,7 @@ as int,
class BridgeEvent_PttCapability extends BridgeEvent {
const BridgeEvent_PttCapability({required this.level, required this.backendId, required this.boundInputClass}): super._();
/// Stable capability identifier (`"L0Focused"`,
/// `"L1GlobalShortcut"`, `"L2GlobalHoldToTalk"`,
@@ -729,7 +729,7 @@ as String,
class BridgeEvent_VoiceState extends BridgeEvent {
const BridgeEvent_VoiceState({required this.inChannel, required this.transmitMode, required this.mute, required this.releaseTailMs, this.currentChannelId, this.pendingTargetChannelId, required this.canJoin, required this.canLeave, required this.joinSyncState, this.joinErrorCode}): super._();
/// True when the session is currently joined to a voice
/// channel and the audio engine is running.
@@ -824,7 +824,7 @@ as BridgeVoiceJoinErrorCode?,
class BridgeEvent_InterruptionState extends BridgeEvent {
const BridgeEvent_InterruptionState({required this.began, required this.shouldResume}): super._();
/// True when interruption began, false when it ended.
final bool began;
@@ -894,7 +894,7 @@ as bool,
class BridgeEvent_PermissionState extends BridgeEvent {
const BridgeEvent_PermissionState({required this.permission, required this.state}): super._();
/// Canonical Android permission identifier.
final String permission;
@@ -964,7 +964,7 @@ as PermissionStateKind,
class BridgeEvent_ChatMessage extends BridgeEvent {
const BridgeEvent_ChatMessage({required this.senderId, required this.senderName, required this.message, required this.target}): super._();
/// Client id of the sender.
final BigInt senderId;
@@ -1037,7 +1037,7 @@ as BridgeMessageTarget,
@override
@pragma('vm:prefer-inline')
$BridgeMessageTargetCopyWith<$Res> get target {
return $BridgeMessageTargetCopyWith<$Res>(_self.target, (value) {
return _then(_self.copyWith(target: value));
});
@@ -1049,7 +1049,7 @@ $BridgeMessageTargetCopyWith<$Res> get target {
class BridgeEvent_ServerActivity extends BridgeEvent {
const BridgeEvent_ServerActivity({required this.message}): super._();
/// TeamSpeak-style activity line.
final String message;
@@ -1116,7 +1116,7 @@ as String,
class BridgeEvent_AudioRouteChanged extends BridgeEvent {
const BridgeEvent_AudioRouteChanged({required this.route}): super._();
/// The new audio route.
final BridgeAudioRoute route;
@@ -1355,7 +1355,7 @@ return poke(_that.field0);case _:
class BridgeMessageTarget_Server extends BridgeMessageTarget {
const BridgeMessageTarget_Server(): super._();
@@ -1387,7 +1387,7 @@ String toString() {
class BridgeMessageTarget_Channel extends BridgeMessageTarget {
const BridgeMessageTarget_Channel(): super._();
@@ -1419,7 +1419,7 @@ String toString() {
class BridgeMessageTarget_Client extends BridgeMessageTarget {
const BridgeMessageTarget_Client(this.field0): super._();
final BigInt field0;
@@ -1485,7 +1485,7 @@ as BigInt,
class BridgeMessageTarget_Poke extends BridgeMessageTarget {
const BridgeMessageTarget_Poke(this.field0): super._();
final BigInt field0;
@@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
String get codegenVersion => '2.12.0';
@override
int get rustContentHash => -560177922;
int get rustContentHash => 281698435;
static const kDefaultExternalLibraryLoaderConfig =
ExternalLibraryLoaderConfig(
@@ -87,6 +87,8 @@ abstract class RustLibApi extends BaseApi {
Future<void> crateApiBridgeInit();
Future<BridgeClientProfile> crateApiClientProfile({required BigInt clientId});
Future<BridgeSnapshot> crateApiConnect({
required String host,
required String nickname,
@@ -101,7 +103,7 @@ abstract class RustLibApi extends BaseApi {
Stream<BridgeEvent> crateApiEventsStream();
Future<String> crateApiExportDiagnostics();
String crateApiExportDiagnostics();
Future<BridgeAudioProcessingConfig> crateApiGetAudioProcessingConfig();
@@ -134,6 +136,8 @@ abstract class RustLibApi extends BaseApi {
required String password,
});
Future<void> crateApiPrefetchServer({required String host});
Future<BridgePttDescriptor> crateApiPttDescriptor();
void crateApiRecordLifecycleEvent({required String state});
@@ -314,6 +318,36 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
TaskConstMeta get kCrateApiBridgeInitConstMeta =>
const TaskConstMeta(debugName: "bridge_init", argNames: []);
@override
Future<BridgeClientProfile> crateApiClientProfile({
required BigInt clientId,
}) {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_u_64(clientId, serializer);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 5,
port: port_,
);
},
codec: SseCodec(
decodeSuccessData: sse_decode_bridge_client_profile,
decodeErrorData: sse_decode_bridge_error,
),
constMeta: kCrateApiClientProfileConstMeta,
argValues: [clientId],
apiImpl: this,
),
);
}
TaskConstMeta get kCrateApiClientProfileConstMeta =>
const TaskConstMeta(debugName: "client_profile", argNames: ["clientId"]);
@override
Future<BridgeSnapshot> crateApiConnect({
required String host,
@@ -330,7 +364,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 5,
funcId: 6,
port: port_,
);
},
@@ -360,7 +394,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 6,
funcId: 7,
port: port_,
);
},
@@ -387,7 +421,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 7,
funcId: 8,
port: port_,
);
},
@@ -415,7 +449,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 8,
funcId: 9,
port: port_,
);
},
@@ -448,7 +482,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 9,
funcId: 10,
port: port_,
);
},
@@ -469,21 +503,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
const TaskConstMeta(debugName: "events_stream", argNames: ["sink"]);
@override
Future<String> crateApiExportDiagnostics() {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
String crateApiExportDiagnostics() {
return handler.executeSync(
SyncTask(
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 10,
port: port_,
);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 11)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_String,
decodeErrorData: sse_decode_bridge_error,
decodeErrorData: null,
),
constMeta: kCrateApiExportDiagnosticsConstMeta,
argValues: [],
@@ -504,7 +533,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 11,
funcId: 12,
port: port_,
);
},
@@ -534,7 +563,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 12,
funcId: 13,
port: port_,
);
},
@@ -561,7 +590,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 13,
funcId: 14,
port: port_,
);
},
@@ -588,7 +617,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 14,
funcId: 15,
port: port_,
);
},
@@ -612,7 +641,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
SyncTask(
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 15)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 16)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -635,7 +664,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_bool(shouldResume, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 16)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 17)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -661,7 +690,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_String(routeClass, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 17)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 18)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -687,7 +716,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_bridge_audio_route(route, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 18)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 19)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -715,7 +744,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 19,
funcId: 20,
port: port_,
);
},
@@ -742,7 +771,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 20,
funcId: 21,
port: port_,
);
},
@@ -769,7 +798,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 21,
funcId: 22,
port: port_,
);
},
@@ -796,7 +825,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 22,
funcId: 23,
port: port_,
);
},
@@ -820,7 +849,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
SyncTask(
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 23)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 24)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_String,
@@ -850,7 +879,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 24,
funcId: 25,
port: port_,
);
},
@@ -870,6 +899,34 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
argNames: ["channelId", "password"],
);
@override
Future<void> crateApiPrefetchServer({required String host}) {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_String(host, serializer);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 26,
port: port_,
);
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
decodeErrorData: sse_decode_bridge_error,
),
constMeta: kCrateApiPrefetchServerConstMeta,
argValues: [host],
apiImpl: this,
),
);
}
TaskConstMeta get kCrateApiPrefetchServerConstMeta =>
const TaskConstMeta(debugName: "prefetch_server", argNames: ["host"]);
@override
Future<BridgePttDescriptor> crateApiPttDescriptor() {
return handler.executeNormal(
@@ -879,7 +936,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 25,
funcId: 27,
port: port_,
);
},
@@ -904,7 +961,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_String(state, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 26)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 28)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -937,7 +994,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 27,
funcId: 29,
port: port_,
);
},
@@ -964,7 +1021,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_bridge_audio_route(route, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 28)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 30)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -998,7 +1055,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 29,
funcId: 31,
port: port_,
);
},
@@ -1033,7 +1090,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 30,
funcId: 32,
port: port_,
);
},
@@ -1063,7 +1120,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 31,
funcId: 33,
port: port_,
);
},
@@ -1091,7 +1148,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 32,
funcId: 34,
port: port_,
);
},
@@ -1119,7 +1176,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 33,
funcId: 35,
port: port_,
);
},
@@ -1149,7 +1206,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 34,
funcId: 36,
port: port_,
);
},
@@ -1177,7 +1234,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_bridge_network_state(state, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 35)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 37)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -1203,7 +1260,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 36,
funcId: 38,
port: port_,
);
},
@@ -1231,7 +1288,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 37,
funcId: 39,
port: port_,
);
},
@@ -1259,7 +1316,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 38,
funcId: 40,
port: port_,
);
},
@@ -1287,7 +1344,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 39,
funcId: 41,
port: port_,
);
},
@@ -1319,7 +1376,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 40,
funcId: 42,
port: port_,
);
},
@@ -1349,7 +1406,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 41,
funcId: 43,
port: port_,
);
},
@@ -1377,7 +1434,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 42,
funcId: 44,
port: port_,
);
},
@@ -1405,7 +1462,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 43,
funcId: 45,
port: port_,
);
},
@@ -1432,7 +1489,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 44,
funcId: 46,
port: port_,
);
},
@@ -1460,7 +1517,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 45,
funcId: 47,
port: port_,
);
},
@@ -1492,7 +1549,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 46,
funcId: 48,
port: port_,
);
},
@@ -1521,7 +1578,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 47,
funcId: 49,
port: port_,
);
},
@@ -1594,12 +1651,24 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return dco_decode_bridge_voice_join_error_code(raw);
}
@protected
double dco_decode_box_autoadd_f_32(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return raw as double;
}
@protected
int dco_decode_box_autoadd_i_32(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return raw as int;
}
@protected
PlatformInt64 dco_decode_box_autoadd_i_64(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return dco_decode_i_64(raw);
}
@protected
BigInt dco_decode_box_autoadd_u_64(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
@@ -1756,19 +1825,53 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
BridgeClient dco_decode_bridge_client(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
final arr = raw as List<dynamic>;
if (arr.length != 10)
throw Exception('unexpected arr length: expect 10 but see ${arr.length}');
if (arr.length != 9)
throw Exception('unexpected arr length: expect 9 but see ${arr.length}');
return BridgeClient(
id: dco_decode_u_64(arr[0]),
uid: dco_decode_String(arr[1]),
channel: dco_decode_u_64(arr[2]),
name: dco_decode_String(arr[3]),
inputMuted: dco_decode_bool(arr[4]),
outputMuted: dco_decode_bool(arr[5]),
isSpeaking: dco_decode_bool(arr[6]),
isServerQuery: dco_decode_bool(arr[7]),
talkPower: dco_decode_i_32(arr[8]),
talkPowerGranted: dco_decode_bool(arr[9]),
channel: dco_decode_u_64(arr[1]),
name: dco_decode_String(arr[2]),
inputMuted: dco_decode_bool(arr[3]),
outputMuted: dco_decode_bool(arr[4]),
isSpeaking: dco_decode_bool(arr[5]),
isServerQuery: dco_decode_bool(arr[6]),
talkPower: dco_decode_i_32(arr[7]),
talkPowerGranted: dco_decode_bool(arr[8]),
);
}
@protected
BridgeClientProfile dco_decode_bridge_client_profile(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
final arr = raw as List<dynamic>;
if (arr.length != 25)
throw Exception('unexpected arr length: expect 25 but see ${arr.length}');
return BridgeClientProfile(
id: dco_decode_u_64(arr[0]),
channel: dco_decode_u_64(arr[1]),
name: dco_decode_String(arr[2]),
uniqueId: dco_decode_String(arr[3]),
databaseId: dco_decode_opt_box_autoadd_u_64(arr[4]),
countryCode: dco_decode_String(arr[5]),
description: dco_decode_String(arr[6]),
version: dco_decode_String(arr[7]),
platform: dco_decode_String(arr[8]),
createdUnixSeconds: dco_decode_opt_box_autoadd_i_64(arr[9]),
lastConnectedUnixSeconds: dco_decode_opt_box_autoadd_i_64(arr[10]),
connectionsTotal: dco_decode_opt_box_autoadd_u_64(arr[11]),
onlineSeconds: dco_decode_opt_box_autoadd_i_64(arr[12]),
idleMilliseconds: dco_decode_opt_box_autoadd_i_64(arr[13]),
pingMilliseconds: dco_decode_opt_box_autoadd_i_64(arr[14]),
clientAddress: dco_decode_String(arr[15]),
serverGroups: dco_decode_list_String(arr[16]),
channelGroup: dco_decode_String(arr[17]),
avatarPath: dco_decode_String(arr[18]),
bytesDownloadedMonth: dco_decode_opt_box_autoadd_u_64(arr[19]),
bytesUploadedMonth: dco_decode_opt_box_autoadd_u_64(arr[20]),
bytesDownloadedTotal: dco_decode_opt_box_autoadd_u_64(arr[21]),
bytesUploadedTotal: dco_decode_opt_box_autoadd_u_64(arr[22]),
packetLossClientToServerTotal: dco_decode_opt_box_autoadd_f_32(arr[23]),
packetLossServerToClientTotal: dco_decode_opt_box_autoadd_f_32(arr[24]),
);
}
@@ -2004,6 +2107,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return dcoDecodeI64(raw);
}
@protected
List<String> dco_decode_list_String(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return (raw as List<dynamic>).map(dco_decode_String).toList();
}
@protected
List<BridgeAudioDevice> dco_decode_list_bridge_audio_device(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
@@ -2049,12 +2158,24 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
: dco_decode_box_autoadd_bridge_voice_join_error_code(raw);
}
@protected
double? dco_decode_opt_box_autoadd_f_32(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return raw == null ? null : dco_decode_box_autoadd_f_32(raw);
}
@protected
int? dco_decode_opt_box_autoadd_i_32(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return raw == null ? null : dco_decode_box_autoadd_i_32(raw);
}
@protected
PlatformInt64? dco_decode_opt_box_autoadd_i_64(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return raw == null ? null : dco_decode_box_autoadd_i_64(raw);
}
@protected
BigInt? dco_decode_opt_box_autoadd_u_64(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
@@ -2152,12 +2273,24 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return (sse_decode_bridge_voice_join_error_code(deserializer));
}
@protected
double sse_decode_box_autoadd_f_32(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
return (sse_decode_f_32(deserializer));
}
@protected
int sse_decode_box_autoadd_i_32(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
return (sse_decode_i_32(deserializer));
}
@protected
PlatformInt64 sse_decode_box_autoadd_i_64(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
return (sse_decode_i_64(deserializer));
}
@protected
BigInt sse_decode_box_autoadd_u_64(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
@@ -2358,7 +2491,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
BridgeClient sse_decode_bridge_client(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
var var_id = sse_decode_u_64(deserializer);
var var_uid = sse_decode_String(deserializer);
var var_channel = sse_decode_u_64(deserializer);
var var_name = sse_decode_String(deserializer);
var var_inputMuted = sse_decode_bool(deserializer);
@@ -2369,7 +2501,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
var var_talkPowerGranted = sse_decode_bool(deserializer);
return BridgeClient(
id: var_id,
uid: var_uid,
channel: var_channel,
name: var_name,
inputMuted: var_inputMuted,
@@ -2381,6 +2512,75 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
);
}
@protected
BridgeClientProfile sse_decode_bridge_client_profile(
SseDeserializer deserializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
var var_id = sse_decode_u_64(deserializer);
var var_channel = sse_decode_u_64(deserializer);
var var_name = sse_decode_String(deserializer);
var var_uniqueId = sse_decode_String(deserializer);
var var_databaseId = sse_decode_opt_box_autoadd_u_64(deserializer);
var var_countryCode = sse_decode_String(deserializer);
var var_description = sse_decode_String(deserializer);
var var_version = sse_decode_String(deserializer);
var var_platform = sse_decode_String(deserializer);
var var_createdUnixSeconds = sse_decode_opt_box_autoadd_i_64(deserializer);
var var_lastConnectedUnixSeconds = sse_decode_opt_box_autoadd_i_64(
deserializer,
);
var var_connectionsTotal = sse_decode_opt_box_autoadd_u_64(deserializer);
var var_onlineSeconds = sse_decode_opt_box_autoadd_i_64(deserializer);
var var_idleMilliseconds = sse_decode_opt_box_autoadd_i_64(deserializer);
var var_pingMilliseconds = sse_decode_opt_box_autoadd_i_64(deserializer);
var var_clientAddress = sse_decode_String(deserializer);
var var_serverGroups = sse_decode_list_String(deserializer);
var var_channelGroup = sse_decode_String(deserializer);
var var_avatarPath = sse_decode_String(deserializer);
var var_bytesDownloadedMonth = sse_decode_opt_box_autoadd_u_64(
deserializer,
);
var var_bytesUploadedMonth = sse_decode_opt_box_autoadd_u_64(deserializer);
var var_bytesDownloadedTotal = sse_decode_opt_box_autoadd_u_64(
deserializer,
);
var var_bytesUploadedTotal = sse_decode_opt_box_autoadd_u_64(deserializer);
var var_packetLossClientToServerTotal = sse_decode_opt_box_autoadd_f_32(
deserializer,
);
var var_packetLossServerToClientTotal = sse_decode_opt_box_autoadd_f_32(
deserializer,
);
return BridgeClientProfile(
id: var_id,
channel: var_channel,
name: var_name,
uniqueId: var_uniqueId,
databaseId: var_databaseId,
countryCode: var_countryCode,
description: var_description,
version: var_version,
platform: var_platform,
createdUnixSeconds: var_createdUnixSeconds,
lastConnectedUnixSeconds: var_lastConnectedUnixSeconds,
connectionsTotal: var_connectionsTotal,
onlineSeconds: var_onlineSeconds,
idleMilliseconds: var_idleMilliseconds,
pingMilliseconds: var_pingMilliseconds,
clientAddress: var_clientAddress,
serverGroups: var_serverGroups,
channelGroup: var_channelGroup,
avatarPath: var_avatarPath,
bytesDownloadedMonth: var_bytesDownloadedMonth,
bytesUploadedMonth: var_bytesUploadedMonth,
bytesDownloadedTotal: var_bytesDownloadedTotal,
bytesUploadedTotal: var_bytesUploadedTotal,
packetLossClientToServerTotal: var_packetLossClientToServerTotal,
packetLossServerToClientTotal: var_packetLossServerToClientTotal,
);
}
@protected
BridgeEffectOwner sse_decode_bridge_effect_owner(
SseDeserializer deserializer,
@@ -2680,6 +2880,18 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return deserializer.buffer.getPlatformInt64();
}
@protected
List<String> sse_decode_list_String(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
var len_ = sse_decode_i_32(deserializer);
var ans_ = <String>[];
for (var idx_ = 0; idx_ < len_; ++idx_) {
ans_.add(sse_decode_String(deserializer));
}
return ans_;
}
@protected
List<BridgeAudioDevice> sse_decode_list_bridge_audio_device(
SseDeserializer deserializer,
@@ -2770,6 +2982,17 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
}
}
@protected
double? sse_decode_opt_box_autoadd_f_32(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
if (sse_decode_bool(deserializer)) {
return (sse_decode_box_autoadd_f_32(deserializer));
} else {
return null;
}
}
@protected
int? sse_decode_opt_box_autoadd_i_32(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
@@ -2781,6 +3004,17 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
}
}
@protected
PlatformInt64? sse_decode_opt_box_autoadd_i_64(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
if (sse_decode_bool(deserializer)) {
return (sse_decode_box_autoadd_i_64(deserializer));
} else {
return null;
}
}
@protected
BigInt? sse_decode_opt_box_autoadd_u_64(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
@@ -2898,12 +3132,27 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_bridge_voice_join_error_code(self, serializer);
}
@protected
void sse_encode_box_autoadd_f_32(double self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_f_32(self, serializer);
}
@protected
void sse_encode_box_autoadd_i_32(int self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_i_32(self, serializer);
}
@protected
void sse_encode_box_autoadd_i_64(
PlatformInt64 self,
SseSerializer serializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_i_64(self, serializer);
}
@protected
void sse_encode_box_autoadd_u_64(BigInt self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
@@ -3056,6 +3305,45 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_bool(self.talkPowerGranted, serializer);
}
@protected
void sse_encode_bridge_client_profile(
BridgeClientProfile self,
SseSerializer serializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_u_64(self.id, serializer);
sse_encode_u_64(self.channel, serializer);
sse_encode_String(self.name, serializer);
sse_encode_String(self.uniqueId, serializer);
sse_encode_opt_box_autoadd_u_64(self.databaseId, serializer);
sse_encode_String(self.countryCode, serializer);
sse_encode_String(self.description, serializer);
sse_encode_String(self.version, serializer);
sse_encode_String(self.platform, serializer);
sse_encode_opt_box_autoadd_i_64(self.createdUnixSeconds, serializer);
sse_encode_opt_box_autoadd_i_64(self.lastConnectedUnixSeconds, serializer);
sse_encode_opt_box_autoadd_u_64(self.connectionsTotal, serializer);
sse_encode_opt_box_autoadd_i_64(self.onlineSeconds, serializer);
sse_encode_opt_box_autoadd_i_64(self.idleMilliseconds, serializer);
sse_encode_opt_box_autoadd_i_64(self.pingMilliseconds, serializer);
sse_encode_String(self.clientAddress, serializer);
sse_encode_list_String(self.serverGroups, serializer);
sse_encode_String(self.channelGroup, serializer);
sse_encode_String(self.avatarPath, serializer);
sse_encode_opt_box_autoadd_u_64(self.bytesDownloadedMonth, serializer);
sse_encode_opt_box_autoadd_u_64(self.bytesUploadedMonth, serializer);
sse_encode_opt_box_autoadd_u_64(self.bytesDownloadedTotal, serializer);
sse_encode_opt_box_autoadd_u_64(self.bytesUploadedTotal, serializer);
sse_encode_opt_box_autoadd_f_32(
self.packetLossClientToServerTotal,
serializer,
);
sse_encode_opt_box_autoadd_f_32(
self.packetLossServerToClientTotal,
serializer,
);
}
@protected
void sse_encode_bridge_effect_owner(
BridgeEffectOwner self,
@@ -3330,6 +3618,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
serializer.buffer.putPlatformInt64(self);
}
@protected
void sse_encode_list_String(List<String> self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_i_32(self.length, serializer);
for (final item in self) {
sse_encode_String(item, serializer);
}
}
@protected
void sse_encode_list_bridge_audio_device(
List<BridgeAudioDevice> self,
@@ -3411,6 +3708,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
}
}
@protected
void sse_encode_opt_box_autoadd_f_32(double? self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_bool(self != null, serializer);
if (self != null) {
sse_encode_box_autoadd_f_32(self, serializer);
}
}
@protected
void sse_encode_opt_box_autoadd_i_32(int? self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
@@ -3421,6 +3728,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
}
}
@protected
void sse_encode_opt_box_autoadd_i_64(
PlatformInt64? self,
SseSerializer serializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_bool(self != null, serializer);
if (self != null) {
sse_encode_box_autoadd_i_64(self, serializer);
}
}
@protected
void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
@@ -48,9 +48,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw,
);
@protected
double dco_decode_box_autoadd_f_32(dynamic raw);
@protected
int dco_decode_box_autoadd_i_32(dynamic raw);
@protected
PlatformInt64 dco_decode_box_autoadd_i_64(dynamic raw);
@protected
BigInt dco_decode_box_autoadd_u_64(dynamic raw);
@@ -88,6 +94,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
BridgeClient dco_decode_bridge_client(dynamic raw);
@protected
BridgeClientProfile dco_decode_bridge_client_profile(dynamic raw);
@protected
BridgeEffectOwner dco_decode_bridge_effect_owner(dynamic raw);
@@ -141,6 +150,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
PlatformInt64 dco_decode_i_64(dynamic raw);
@protected
List<String> dco_decode_list_String(dynamic raw);
@protected
List<BridgeAudioDevice> dco_decode_list_bridge_audio_device(dynamic raw);
@@ -163,9 +175,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
BridgeVoiceJoinErrorCode?
dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw);
@protected
double? dco_decode_opt_box_autoadd_f_32(dynamic raw);
@protected
int? dco_decode_opt_box_autoadd_i_32(dynamic raw);
@protected
PlatformInt64? dco_decode_opt_box_autoadd_i_64(dynamic raw);
@protected
BigInt? dco_decode_opt_box_autoadd_u_64(dynamic raw);
@@ -219,9 +237,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
double sse_decode_box_autoadd_f_32(SseDeserializer deserializer);
@protected
int sse_decode_box_autoadd_i_32(SseDeserializer deserializer);
@protected
PlatformInt64 sse_decode_box_autoadd_i_64(SseDeserializer deserializer);
@protected
BigInt sse_decode_box_autoadd_u_64(SseDeserializer deserializer);
@@ -265,6 +289,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
BridgeClient sse_decode_bridge_client(SseDeserializer deserializer);
@protected
BridgeClientProfile sse_decode_bridge_client_profile(
SseDeserializer deserializer,
);
@protected
BridgeEffectOwner sse_decode_bridge_effect_owner(
SseDeserializer deserializer,
@@ -334,6 +363,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
PlatformInt64 sse_decode_i_64(SseDeserializer deserializer);
@protected
List<String> sse_decode_list_String(SseDeserializer deserializer);
@protected
List<BridgeAudioDevice> sse_decode_list_bridge_audio_device(
SseDeserializer deserializer,
@@ -366,9 +398,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
double? sse_decode_opt_box_autoadd_f_32(SseDeserializer deserializer);
@protected
int? sse_decode_opt_box_autoadd_i_32(SseDeserializer deserializer);
@protected
PlatformInt64? sse_decode_opt_box_autoadd_i_64(SseDeserializer deserializer);
@protected
BigInt? sse_decode_opt_box_autoadd_u_64(SseDeserializer deserializer);
@@ -431,9 +469,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_box_autoadd_f_32(double self, SseSerializer serializer);
@protected
void sse_encode_box_autoadd_i_32(int self, SseSerializer serializer);
@protected
void sse_encode_box_autoadd_i_64(
PlatformInt64 self,
SseSerializer serializer,
);
@protected
void sse_encode_box_autoadd_u_64(BigInt self, SseSerializer serializer);
@@ -491,6 +538,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_bridge_client(BridgeClient self, SseSerializer serializer);
@protected
void sse_encode_bridge_client_profile(
BridgeClientProfile self,
SseSerializer serializer,
);
@protected
void sse_encode_bridge_effect_owner(
BridgeEffectOwner self,
@@ -578,6 +631,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_i_64(PlatformInt64 self, SseSerializer serializer);
@protected
void sse_encode_list_String(List<String> self, SseSerializer serializer);
@protected
void sse_encode_list_bridge_audio_device(
List<BridgeAudioDevice> self,
@@ -617,9 +673,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_opt_box_autoadd_f_32(double? self, SseSerializer serializer);
@protected
void sse_encode_opt_box_autoadd_i_32(int? self, SseSerializer serializer);
@protected
void sse_encode_opt_box_autoadd_i_64(
PlatformInt64? self,
SseSerializer serializer,
);
@protected
void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer);
@@ -50,9 +50,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw,
);
@protected
double dco_decode_box_autoadd_f_32(dynamic raw);
@protected
int dco_decode_box_autoadd_i_32(dynamic raw);
@protected
PlatformInt64 dco_decode_box_autoadd_i_64(dynamic raw);
@protected
BigInt dco_decode_box_autoadd_u_64(dynamic raw);
@@ -90,6 +96,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
BridgeClient dco_decode_bridge_client(dynamic raw);
@protected
BridgeClientProfile dco_decode_bridge_client_profile(dynamic raw);
@protected
BridgeEffectOwner dco_decode_bridge_effect_owner(dynamic raw);
@@ -143,6 +152,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
PlatformInt64 dco_decode_i_64(dynamic raw);
@protected
List<String> dco_decode_list_String(dynamic raw);
@protected
List<BridgeAudioDevice> dco_decode_list_bridge_audio_device(dynamic raw);
@@ -165,9 +177,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
BridgeVoiceJoinErrorCode?
dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw);
@protected
double? dco_decode_opt_box_autoadd_f_32(dynamic raw);
@protected
int? dco_decode_opt_box_autoadd_i_32(dynamic raw);
@protected
PlatformInt64? dco_decode_opt_box_autoadd_i_64(dynamic raw);
@protected
BigInt? dco_decode_opt_box_autoadd_u_64(dynamic raw);
@@ -221,9 +239,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
double sse_decode_box_autoadd_f_32(SseDeserializer deserializer);
@protected
int sse_decode_box_autoadd_i_32(SseDeserializer deserializer);
@protected
PlatformInt64 sse_decode_box_autoadd_i_64(SseDeserializer deserializer);
@protected
BigInt sse_decode_box_autoadd_u_64(SseDeserializer deserializer);
@@ -267,6 +291,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
BridgeClient sse_decode_bridge_client(SseDeserializer deserializer);
@protected
BridgeClientProfile sse_decode_bridge_client_profile(
SseDeserializer deserializer,
);
@protected
BridgeEffectOwner sse_decode_bridge_effect_owner(
SseDeserializer deserializer,
@@ -336,6 +365,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
PlatformInt64 sse_decode_i_64(SseDeserializer deserializer);
@protected
List<String> sse_decode_list_String(SseDeserializer deserializer);
@protected
List<BridgeAudioDevice> sse_decode_list_bridge_audio_device(
SseDeserializer deserializer,
@@ -368,9 +400,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
double? sse_decode_opt_box_autoadd_f_32(SseDeserializer deserializer);
@protected
int? sse_decode_opt_box_autoadd_i_32(SseDeserializer deserializer);
@protected
PlatformInt64? sse_decode_opt_box_autoadd_i_64(SseDeserializer deserializer);
@protected
BigInt? sse_decode_opt_box_autoadd_u_64(SseDeserializer deserializer);
@@ -433,9 +471,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_box_autoadd_f_32(double self, SseSerializer serializer);
@protected
void sse_encode_box_autoadd_i_32(int self, SseSerializer serializer);
@protected
void sse_encode_box_autoadd_i_64(
PlatformInt64 self,
SseSerializer serializer,
);
@protected
void sse_encode_box_autoadd_u_64(BigInt self, SseSerializer serializer);
@@ -493,6 +540,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_bridge_client(BridgeClient self, SseSerializer serializer);
@protected
void sse_encode_bridge_client_profile(
BridgeClientProfile self,
SseSerializer serializer,
);
@protected
void sse_encode_bridge_effect_owner(
BridgeEffectOwner self,
@@ -580,6 +633,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_i_64(PlatformInt64 self, SseSerializer serializer);
@protected
void sse_encode_list_String(List<String> self, SseSerializer serializer);
@protected
void sse_encode_list_bridge_audio_device(
List<BridgeAudioDevice> self,
@@ -619,9 +675,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_opt_box_autoadd_f_32(double? self, SseSerializer serializer);
@protected
void sse_encode_opt_box_autoadd_i_32(int? self, SseSerializer serializer);
@protected
void sse_encode_opt_box_autoadd_i_64(
PlatformInt64? self,
SseSerializer serializer,
);
@protected
void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer);
@@ -313,47 +313,51 @@ class _AndroidAudioOutputPickerSheet extends StatelessWidget {
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
final maxHeight = MediaQuery.sizeOf(context).height * 0.72;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l10n.audioOutputLabel, style: theme.textTheme.titleLarge),
const SizedBox(height: 16),
_PickerRow(
icon: Icons.speaker,
label: l10n.audioRouteSystemDefault,
selected: !devices.any((d) => d.isSelected),
onTap: () => Navigator.of(context).pop('auto'),
),
if (loading)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(child: CircularProgressIndicator()),
)
else if (devices.isEmpty)
TextButton.icon(
onPressed: onRefresh,
icon: const Icon(Icons.refresh),
label: Text(l10n.audioRouteRefreshDevices),
)
else
for (final device in devices)
_PickerRow(
icon: AudioOutputTileState._androidDeviceIcon(device.type),
label: AudioOutputTileState._androidDeviceLabel(
device.type,
device.name,
l10n,
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxHeight),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l10n.audioOutputLabel, style: theme.textTheme.titleLarge),
const SizedBox(height: 16),
_PickerRow(
icon: Icons.speaker,
label: l10n.audioRouteSystemDefault,
selected: !devices.any((d) => d.isSelected),
onTap: () => Navigator.of(context).pop('auto'),
),
if (loading)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(child: CircularProgressIndicator()),
)
else if (devices.isEmpty)
TextButton.icon(
onPressed: onRefresh,
icon: const Icon(Icons.refresh),
label: Text(l10n.audioRouteRefreshDevices),
)
else
for (final device in devices)
_PickerRow(
icon: AudioOutputTileState._androidDeviceIcon(device.type),
label: AudioOutputTileState._androidDeviceLabel(
device.type,
device.name,
l10n,
),
selected: device.isSelected,
onTap: device.isAvailableForCommunication
? () => Navigator.of(context).pop(device.id)
: null,
),
selected: device.isSelected,
onTap: device.isAvailableForCommunication
? () => Navigator.of(context).pop(device.id)
: null,
),
],
],
),
),
),
);
@@ -201,32 +201,19 @@ bool androidShowsLimiterControl(AudioProcessingConfigState state) {
/// Never leave the UI on the hidden disabled backend.
///
/// Desktop keeps Silero as the default, but still allows a user-chosen
/// WebRTC fallback when ONNX Runtime is unavailable.
/// Windows and Linux use Silero as the primary VAD; WebRTC is still
/// available internally as a runtime fallback.
rust.BridgeVadBackend normalizedVadBackend(
rust.BridgeVadBackend backend, {
bool? isWindows,
bool? isLinux,
bool onnxRuntimeAvailable = true,
}) {
final normalized = backend == rust.BridgeVadBackend.disabled
? rust.BridgeVadBackend.sileroOnnx
: backend;
final desktop =
(isWindows ?? Platform.isWindows) || (isLinux ?? Platform.isLinux);
if (desktop &&
!onnxRuntimeAvailable &&
normalized == rust.BridgeVadBackend.sileroOnnx) {
return rust.BridgeVadBackend.webrtcVad;
}
if (!desktop) {
return normalized;
}
return switch (normalized) {
rust.BridgeVadBackend.webrtcVad ||
rust.BridgeVadBackend.sileroOnnx => normalized,
_ => rust.BridgeVadBackend.sileroOnnx,
};
if (desktop) return rust.BridgeVadBackend.sileroOnnx;
return backend == rust.BridgeVadBackend.disabled
? rust.BridgeVadBackend.sileroOnnx
: backend;
}
rust.BridgeIosVoiceProcessingMode normalizedIosProcessingMode(
+188 -176
View File
@@ -1,3 +1,5 @@
import 'dart:async' show unawaited;
import 'package:flutter/material.dart';
import '../l10n/generated/app_localizations.dart';
@@ -9,12 +11,9 @@ import '../src/rust/api.dart' as rust;
import 'bbcode_text.dart';
const double _chatSidebarTileExtent = 92;
const double _chatSidebarIndicatorExtent = 76;
const double _chatSidebarIconExtent = 24;
const double _chatSidebarIconSize = 20;
const BorderRadius _chatSidebarIndicatorRadius = BorderRadius.all(
Radius.circular(20),
);
const double _chatSidebarCompactTileExtent = 76;
const double _chatSidebarCompactHeight = 84;
const double _chatMobileBreakpoint = 600;
/// One chat/activity message shown in the chat hub.
class ChatEntry {
@@ -616,12 +615,22 @@ class _ChatPageState extends State<ChatPage> {
final messages = _messages;
final currentChannelId = _currentChannelId;
final channelName = snapshotChannelName(snapshot, currentChannelId);
final l10n = AppL10n.of(context);
final detail = _ChatDetailView(
target: _selectedTarget,
clientName: _selectedClientName,
snapshot: snapshot,
messages: messages,
currentChannelId: currentChannelId,
channelName: channelName,
onTs3ServerLink: widget.onTs3ServerLink,
);
return Scaffold(
appBar: AppBar(
title: Text('Chat — ${snapshot.serverName}'),
title: Text('${l10n.chatAction} - ${snapshot.serverName}'),
actions: [
IconButton(
tooltip: 'Close chat',
tooltip: l10n.chatCloseAction,
icon: const Icon(Icons.close),
onPressed: _selectedPrivateClientId == null
? null
@@ -629,9 +638,10 @@ class _ChatPageState extends State<ChatPage> {
),
],
),
body: Row(
children: [
_ChatSidebar(
body: LayoutBuilder(
builder: (context, constraints) {
final sidebar = _ChatSidebar(
compact: constraints.maxWidth < _chatMobileBreakpoint,
selectedTarget: _selectedTarget,
privateChats: _privateChats,
onSelect: _selectTarget,
@@ -639,20 +649,24 @@ class _ChatPageState extends State<ChatPage> {
_closedPrivateChats.remove(id);
_selectTarget(rust.BridgeMessageTarget.client(id), name: name);
}),
),
const VerticalDivider(width: 1),
Expanded(
child: _ChatDetailView(
target: _selectedTarget,
clientName: _selectedClientName,
snapshot: snapshot,
messages: messages,
currentChannelId: currentChannelId,
channelName: channelName,
onTs3ServerLink: widget.onTs3ServerLink,
),
),
],
);
if (constraints.maxWidth < _chatMobileBreakpoint) {
return Column(
children: [
sidebar,
const Divider(height: 1),
Expanded(child: detail),
],
);
}
return Row(
children: [
sidebar,
const VerticalDivider(width: 1),
Expanded(child: detail),
],
);
},
),
);
}
@@ -740,12 +754,14 @@ class _PrivateChatItem {
class _ChatSidebar extends StatelessWidget {
const _ChatSidebar({
required this.compact,
required this.selectedTarget,
required this.privateChats,
required this.onSelect,
required this.onNewPrivateChat,
});
final bool compact;
final rust.BridgeMessageTarget selectedTarget;
final List<_PrivateChatItem> privateChats;
final void Function(rust.BridgeMessageTarget target, {String name}) onSelect;
@@ -753,64 +769,85 @@ class _ChatSidebar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final dividerColor = theme.colorScheme.outlineVariant.withValues(
alpha: 0.45,
final l10n = AppL10n.of(context);
final fixedItems = [
_ChatSidebarItem(
compact: compact,
icon: Icons.dns_outlined,
label: 'Server',
selected: selectedTarget is rust.BridgeMessageTarget_Server,
onTap: () => onSelect(const rust.BridgeMessageTarget.server()),
),
_ChatSidebarItem(
compact: compact,
icon: Icons.tag,
label: 'Channel',
selected: selectedTarget is rust.BridgeMessageTarget_Channel,
onTap: () => onSelect(const rust.BridgeMessageTarget.channel()),
),
];
final privateItems = [
for (final chat in privateChats)
_ChatSidebarItem(
compact: compact,
icon: Icons.person_outline,
label: chat.name.isNotEmpty ? chat.name : 'Direct',
selected: switch (selectedTarget) {
rust.BridgeMessageTarget_Client(:final field0) => field0 == chat.id,
_ => false,
},
onTap: () => onSelect(
rust.BridgeMessageTarget.client(chat.id),
name: chat.name,
),
),
];
final addButton = Padding(
padding: EdgeInsets.all(compact ? 4 : 8),
child: IconButton.filledTonal(
tooltip: l10n.chatNewPrivateAction,
icon: const Icon(Icons.add),
onPressed: onNewPrivateChat,
),
);
return SizedBox(
width: _chatSidebarTileExtent,
child: Material(
color: theme.colorScheme.surfaceContainerLow,
child: Column(
final compactDivider = ColoredBox(
color: Theme.of(context).dividerColor,
child: const SizedBox(width: 1, height: double.infinity),
);
if (compact) {
return SizedBox(
height: _chatSidebarCompactHeight,
child: Row(
children: [
const SizedBox(height: 8),
_ChatSidebarItem(
icon: Icons.dns_outlined,
label: 'Server',
selected: selectedTarget is rust.BridgeMessageTarget_Server,
onTap: () => onSelect(const rust.BridgeMessageTarget.server()),
),
_ChatSidebarItem(
icon: Icons.tag,
label: 'Channel',
selected: selectedTarget is rust.BridgeMessageTarget_Channel,
onTap: () => onSelect(const rust.BridgeMessageTarget.channel()),
),
Divider(height: 1, indent: 12, endIndent: 12, color: dividerColor),
...fixedItems,
compactDivider,
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 6),
itemCount: privateChats.length,
itemBuilder: (context, index) {
final chat = privateChats[index];
final selected = switch (selectedTarget) {
rust.BridgeMessageTarget_Client(:final field0) =>
field0 == chat.id,
_ => false,
};
return _ChatSidebarItem(
icon: Icons.person_outline,
label: chat.name.isNotEmpty ? chat.name : 'Direct',
selected: selected,
onTap: () => onSelect(
rust.BridgeMessageTarget.client(chat.id),
name: chat.name,
),
);
},
),
),
Divider(height: 1, indent: 12, endIndent: 12, color: dividerColor),
Padding(
padding: const EdgeInsets.fromLTRB(8, 10, 8, 12),
child: IconButton.filledTonal(
tooltip: 'New private chat',
icon: const Icon(Icons.add),
onPressed: onNewPrivateChat,
child: ListView(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.zero,
children: privateItems,
),
),
compactDivider,
addButton,
],
),
);
}
return SizedBox(
width: _chatSidebarTileExtent,
child: Column(
children: [
...fixedItems,
const Divider(height: 1),
Expanded(
child: ListView(padding: EdgeInsets.zero, children: privateItems),
),
const Divider(height: 1),
addButton,
],
),
);
}
@@ -818,12 +855,14 @@ class _ChatSidebar extends StatelessWidget {
class _ChatSidebarItem extends StatelessWidget {
const _ChatSidebarItem({
required this.compact,
required this.icon,
required this.label,
required this.selected,
required this.onTap,
});
final bool compact;
final IconData icon;
final String label;
final bool selected;
@@ -832,63 +871,57 @@ class _ChatSidebarItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final indicatorColor = selected
? scheme.primaryContainer
: Colors.transparent;
final contentColor = selected
? scheme.onPrimaryContainer
: scheme.onSurfaceVariant;
final labelStyle = theme.textTheme.labelSmall?.copyWith(
color: contentColor,
height: 1.15,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
);
return SizedBox(
width: _chatSidebarTileExtent,
height: _chatSidebarTileExtent,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: _chatSidebarIndicatorRadius,
child: Center(
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
width: _chatSidebarIndicatorExtent,
height: _chatSidebarIndicatorExtent,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: BoxDecoration(
color: indicatorColor,
borderRadius: _chatSidebarIndicatorRadius,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: _chatSidebarIconExtent,
height: _chatSidebarIconExtent,
child: Icon(
icon,
size: _chatSidebarIconSize,
color: contentColor,
),
final colorScheme = theme.colorScheme;
final fg = selected
? colorScheme.onSecondaryContainer
: colorScheme.onSurface;
final tileExtent = compact
? _chatSidebarCompactTileExtent
: _chatSidebarTileExtent;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Ink(
width: tileExtent,
height: compact ? 72 : tileExtent,
decoration: BoxDecoration(
color: selected
? colorScheme.secondaryContainer
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: compact ? 6 : 8,
vertical: compact ? 8 : 10,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 24,
height: 24,
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [Icon(icon, size: 18, color: fg)],
),
const SizedBox(height: 8),
SizedBox(
width: _chatSidebarIndicatorExtent - 16,
child: Text(
label,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: labelStyle,
),
),
const SizedBox(height: 8),
Text(
label,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: theme.textTheme.labelSmall?.copyWith(
color: fg,
height: 1.1,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
),
],
),
),
],
),
),
),
@@ -926,6 +959,7 @@ class _ClientPickerDialogState extends State<_ClientPickerDialog> {
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final groups = filterChatClientPickerGroups(
channels: widget.channels,
clientsByChannel: widget.byChannel,
@@ -943,10 +977,10 @@ class _ClientPickerDialogState extends State<_ClientPickerDialog> {
padding: const EdgeInsets.fromLTRB(20, 20, 20, 8),
child: TextField(
controller: _searchCtl,
decoration: const InputDecoration(
hintText: 'Search clients...',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
decoration: InputDecoration(
hintText: l10n.chatSearchClientsHint,
prefixIcon: const Icon(Icons.search),
border: const OutlineInputBorder(),
isDense: true,
),
onChanged: (v) => setState(() => _query = v),
@@ -1044,7 +1078,6 @@ class _ChatDetailViewState extends State<_ChatDetailView> {
final _scrollCtl = ScrollController();
int _lastRenderedMessageCount = -1;
rust.BridgeMessageTarget? _lastRenderedTarget;
bool _sending = false;
Iterable<ChatEntry> get _filtered {
if (widget.target is rust.BridgeMessageTarget_Channel) {
@@ -1074,51 +1107,30 @@ class _ChatDetailViewState extends State<_ChatDetailView> {
super.dispose();
}
Future<void> _send() async {
void _send() {
final text = _textCtl.text.trim();
if (text.isEmpty || !_canSend || _sending) return;
if (text.isEmpty || !_canSend) return;
_textCtl.clear();
setState(() => _sending = true);
try {
await rust.sendChatMessage(message: text, target: widget.target);
if (!mounted) return;
final ownId = widget.snapshot.ownClientId;
setState(() {
widget.messages.add(
ChatEntry(
senderId: ownId,
senderName: widget.target is rust.BridgeMessageTarget_Poke
? widget.clientName
: 'You',
message: text,
target: widget.target,
isSelf: true,
timestamp: DateTime.now(),
),
);
if (widget.messages.length > 200) {
widget.messages.removeRange(0, widget.messages.length - 200);
}
});
_scrollToBottom();
} catch (error) {
if (!mounted) return;
_textCtl.text = text;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
behavior: SnackBarBehavior.floating,
content: Text(
'Could not send message: $error',
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
unawaited(rust.sendChatMessage(message: text, target: widget.target));
final ownId = widget.snapshot.ownClientId;
setState(() {
widget.messages.add(
ChatEntry(
senderId: ownId,
senderName: widget.target is rust.BridgeMessageTarget_Poke
? widget.clientName
: 'You',
message: text,
target: widget.target,
isSelf: true,
timestamp: DateTime.now(),
),
);
} finally {
if (mounted) {
setState(() => _sending = false);
if (widget.messages.length > 200) {
widget.messages.removeRange(0, widget.messages.length - 200);
}
}
});
_scrollToBottom();
}
void _scrollToBottom() {
@@ -0,0 +1,491 @@
import 'package:flutter/material.dart';
import '../l10n/generated/app_localizations.dart';
import '../src/rust/api.dart' as rust;
/// Bottom sheet that presents richer TeamSpeak client profile data.
class ClientInfoSheet extends StatefulWidget {
/// Construct a client info sheet.
const ClientInfoSheet({
super.key,
required this.clientName,
required this.loadProfile,
});
/// Display name used while the profile request is loading.
final String clientName;
/// Fetch richer profile data from the active protocol connection.
final Future<rust.BridgeClientProfile> Function() loadProfile;
@override
State<ClientInfoSheet> createState() => _ClientInfoSheetState();
}
class _ClientInfoSheetState extends State<ClientInfoSheet> {
late Future<rust.BridgeClientProfile> _profileFuture;
@override
void initState() {
super.initState();
_profileFuture = widget.loadProfile();
}
void _retry() {
setState(() {
_profileFuture = widget.loadProfile();
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 10, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: theme.colorScheme.onSurfaceVariant.withValues(
alpha: 0.34,
),
borderRadius: BorderRadius.circular(999),
),
),
const SizedBox(height: 16),
Expanded(
child: FutureBuilder<rust.BridgeClientProfile>(
future: _profileFuture,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return _ClientInfoLoading(name: widget.clientName);
}
if (snapshot.hasError || !snapshot.hasData) {
return _ClientInfoError(
name: widget.clientName,
onRetry: _retry,
);
}
return _ClientInfoContent(profile: snapshot.data!);
},
),
),
],
),
),
);
}
}
class _ClientInfoLoading extends StatelessWidget {
const _ClientInfoLoading({required this.name});
final String name;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppL10n.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_ClientInfoHeader(name: name, subtitle: l10n.clientInfoFetchingProfile),
const SizedBox(height: 24),
LinearProgressIndicator(
minHeight: 3,
borderRadius: BorderRadius.circular(999),
),
const SizedBox(height: 16),
Text(l10n.clientInfoLoadingProfile, style: theme.textTheme.bodyMedium),
],
);
}
}
class _ClientInfoError extends StatelessWidget {
const _ClientInfoError({required this.name, required this.onRetry});
final String name;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppL10n.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_ClientInfoHeader(
name: name,
subtitle: l10n.clientInfoProfileUnavailable,
),
const SizedBox(height: 24),
Icon(Icons.info_outline, color: theme.colorScheme.error, size: 32),
const SizedBox(height: 12),
Text(
l10n.clientInfoProfileUnavailableBody,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: Text(l10n.retryAction),
),
],
);
}
}
class _ClientInfoContent extends StatelessWidget {
const _ClientInfoContent({required this.profile});
final rust.BridgeClientProfile profile;
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
return ListView(
children: [
_ClientInfoHeader(
name: profile.name,
subtitle: _joinNonEmpty([
profile.platform,
profile.version,
profile.countryCode,
]),
),
const SizedBox(height: 20),
_InfoSection(
title: l10n.clientInfoIdentitySection,
rows: [
_InfoRowData(l10n.clientInfoClientId, profile.id.toString()),
_InfoRowData(
l10n.clientInfoDatabaseId,
_formatBigInt(profile.databaseId, l10n),
),
_InfoRowData(
l10n.clientInfoUniqueId,
_emptyAsHidden(profile.uniqueId, l10n),
),
_InfoRowData(
l10n.clientInfoDescription,
_emptyAsHidden(profile.description, l10n),
),
_InfoRowData(
l10n.clientInfoAvatar,
_emptyAsNone(profile.avatarPath, l10n),
),
],
),
_InfoSection(
title: l10n.clientInfoMembershipSection,
rows: [
_InfoRowData(
l10n.clientInfoServerGroups,
profile.serverGroups.isEmpty
? l10n.clientInfoUnknown
: profile.serverGroups.join(', '),
),
_InfoRowData(
l10n.clientInfoChannelGroup,
_emptyAsHidden(profile.channelGroup, l10n),
),
_InfoRowData(l10n.clientInfoChannelId, profile.channel.toString()),
],
),
_InfoSection(
title: l10n.clientInfoConnectionSection,
rows: [
_InfoRowData(
l10n.clientInfoOnline,
_formatSeconds(profile.onlineSeconds, l10n),
),
_InfoRowData(
l10n.clientInfoIdle,
_formatMilliseconds(profile.idleMilliseconds, l10n),
),
_InfoRowData(
l10n.clientInfoPing,
_formatMilliseconds(profile.pingMilliseconds, l10n),
),
_InfoRowData(
l10n.clientInfoAddress,
_emptyAsHidden(profile.clientAddress, l10n),
),
_InfoRowData(
l10n.clientInfoPacketLossClientToServer,
_formatLoss(profile.packetLossClientToServerTotal),
),
_InfoRowData(
l10n.clientInfoPacketLossServerToClient,
_formatLoss(profile.packetLossServerToClientTotal),
),
],
),
_InfoSection(
title: l10n.clientInfoHistorySection,
rows: [
_InfoRowData(
l10n.clientInfoFirstConnected,
_formatUnix(profile.createdUnixSeconds, l10n),
),
_InfoRowData(
l10n.clientInfoLastConnected,
_formatUnix(profile.lastConnectedUnixSeconds, l10n),
),
_InfoRowData(
l10n.clientInfoConnections,
_formatBigInt(profile.connectionsTotal, l10n),
),
],
),
_InfoSection(
title: l10n.clientInfoTransferSection,
rows: [
_InfoRowData(
l10n.clientInfoDownloadedMonth,
_formatBytes(profile.bytesDownloadedMonth),
),
_InfoRowData(
l10n.clientInfoUploadedMonth,
_formatBytes(profile.bytesUploadedMonth),
),
_InfoRowData(
l10n.clientInfoDownloadedTotal,
_formatBytes(profile.bytesDownloadedTotal),
),
_InfoRowData(
l10n.clientInfoUploadedTotal,
_formatBytes(profile.bytesUploadedTotal),
),
],
),
],
);
}
}
class _ClientInfoHeader extends StatelessWidget {
const _ClientInfoHeader({required this.name, required this.subtitle});
final String name;
final String subtitle;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
CircleAvatar(
radius: 28,
backgroundColor: theme.colorScheme.primaryContainer,
foregroundColor: theme.colorScheme.onPrimaryContainer,
child: Text(
_initials(name),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
if (subtitle.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
],
),
),
],
);
}
}
class _InfoSection extends StatelessWidget {
const _InfoSection({required this.title, required this.rows});
final String title;
final List<_InfoRowData> rows;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
DecoratedBox(
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: theme.dividerColor),
bottom: BorderSide(color: theme.dividerColor),
),
),
child: Column(
children: [
for (var i = 0; i < rows.length; i++) ...[
_InfoRow(row: rows[i]),
if (i != rows.length - 1) const Divider(height: 1),
],
],
),
),
],
),
);
}
}
class _InfoRow extends StatelessWidget {
const _InfoRow({required this.row});
final _InfoRowData row;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 11),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 132,
child: Text(
row.label,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: 12),
Expanded(
child: SelectableText(
row.value,
style: theme.textTheme.bodyMedium?.copyWith(
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
],
),
);
}
}
class _InfoRowData {
const _InfoRowData(this.label, this.value);
final String label;
final String value;
}
String _initials(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty) return '?';
final parts = trimmed.split(RegExp(r'\s+')).where((p) => p.isNotEmpty);
final chars = parts.take(2).map((p) => p.characters.first.toUpperCase());
return chars.join();
}
String _joinNonEmpty(Iterable<String> values) {
return values.where((value) => value.trim().isNotEmpty).join(' / ');
}
String _emptyAsHidden(String value, AppL10n l10n) =>
value.trim().isEmpty ? l10n.clientInfoHidden : value;
String _emptyAsNone(String value, AppL10n l10n) =>
value.trim().isEmpty ? l10n.clientInfoNone : value;
String _formatBigInt(BigInt? value, AppL10n l10n) =>
value?.toString() ?? l10n.clientInfoUnknown;
String _formatUnix(Object? seconds, AppL10n l10n) {
final raw = _intFromPlatform(seconds);
if (raw == null || raw <= 0) return l10n.clientInfoUnknown;
final date = DateTime.fromMillisecondsSinceEpoch(
raw * 1000,
isUtc: true,
).toLocal();
return date.toString().split('.').first;
}
String _formatSeconds(Object? seconds, AppL10n l10n) {
final raw = _intFromPlatform(seconds);
if (raw == null) return l10n.clientInfoUnknown;
if (raw < 60) return '$raw s';
final minutes = raw ~/ 60;
final hours = minutes ~/ 60;
if (hours > 0) {
return '${hours}h ${minutes % 60}m ${raw % 60}s';
}
return '${minutes}m ${raw % 60}s';
}
String _formatMilliseconds(Object? milliseconds, AppL10n l10n) {
final raw = _intFromPlatform(milliseconds);
if (raw == null) return l10n.clientInfoUnknown;
if (raw < 1000) return '$raw ms';
return '${(raw / 1000).toStringAsFixed(2)} s';
}
String _formatBytes(BigInt? value) {
if (value == null) return 'Unknown';
final bytes = value.toDouble();
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
var amount = bytes;
var unitIndex = 0;
while (amount >= 1024 && unitIndex < units.length - 1) {
amount /= 1024;
unitIndex++;
}
final digits = unitIndex == 0 ? 0 : 1;
return '${amount.toStringAsFixed(digits)} ${units[unitIndex]}';
}
String _formatLoss(double? value) {
if (value == null) return 'Unknown';
return '${(value * 100).toStringAsFixed(2)}%';
}
int? _intFromPlatform(Object? value) {
if (value == null) return null;
if (value is int) return value;
if (value is BigInt) return value.toInt();
return int.tryParse(value.toString());
}
@@ -65,7 +65,6 @@ class _ConnectFormState extends State<ConnectForm> {
keyboardType: TextInputType.url,
textCapitalization: TextCapitalization.none,
textInputAction: TextInputAction.next,
onSubmitted: (_) => _nickFocus.requestFocus(),
autocorrect: false,
enableSuggestions: false,
inputFormatters: [
@@ -90,7 +89,6 @@ class _ConnectFormState extends State<ConnectForm> {
focusNode: _nickFocus,
onTapOutside: _onTapOutside,
textInputAction: TextInputAction.next,
onSubmitted: (_) => _passwordFocus.requestFocus(),
autocorrect: false,
enableSuggestions: false,
decoration: InputDecoration(
@@ -105,7 +103,6 @@ class _ConnectFormState extends State<ConnectForm> {
onTapOutside: _onTapOutside,
obscureText: true,
textInputAction: TextInputAction.done,
onSubmitted: (_) => widget.onConnect(),
autocorrect: false,
enableSuggestions: false,
decoration: InputDecoration(
@@ -115,22 +112,37 @@ class _ConnectFormState extends State<ConnectForm> {
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: FilledButton.icon(
icon: const Icon(Icons.login),
label: Text(l10n.connectAction),
onPressed: widget.onConnect,
),
),
const SizedBox(width: 8),
OutlinedButton.icon(
LayoutBuilder(
builder: (context, constraints) {
const stackedActionsMaxWidth = 400.0;
final connectButton = FilledButton.icon(
icon: const Icon(Icons.login),
label: Text(l10n.connectAction),
onPressed: widget.onConnect,
);
final bookmarkButton = OutlinedButton.icon(
icon: const Icon(Icons.bookmark_add_outlined),
label: Text(l10n.bookmarkAddAction),
onPressed: widget.onAddBookmark,
),
],
);
if (constraints.maxWidth <= stackedActionsMaxWidth) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
connectButton,
const SizedBox(height: 8),
bookmarkButton,
],
);
}
return Row(
children: [
Expanded(child: connectButton),
const SizedBox(width: 8),
Flexible(child: bookmarkButton),
],
);
},
),
],
);
@@ -4,9 +4,6 @@ import 'package:flutter/services.dart';
import '../l10n/generated/app_localizations.dart';
import '../src/rust/api.dart' as rust;
const int pttMouseBackButtonBitmask = 0x08;
const int pttMouseForwardButtonBitmask = 0x10;
/// Translate a [LogicalKeyboardKey] into the platform-neutral label
/// stored by the PTT binding flow.
String? pttDisplayLabelForKey(LogicalKeyboardKey k) {
@@ -49,27 +46,6 @@ String? pttDisplayLabelForKey(LogicalKeyboardKey k) {
return fallback;
}
String? pttMouseSideButtonPlatformKeyForLogicalKey(LogicalKeyboardKey key) {
return switch (key) {
LogicalKeyboardKey.browserBack ||
LogicalKeyboardKey.goBack => 'mouse-side-button:$pttMouseBackButtonBitmask',
LogicalKeyboardKey.browserForward =>
'mouse-side-button:$pttMouseForwardButtonBitmask',
_ => null,
};
}
String? pttMouseSideButtonPlatformKeyForButtons(int buttons) {
if ((buttons & pttMouseBackButtonBitmask) == pttMouseBackButtonBitmask) {
return 'mouse-side-button:$pttMouseBackButtonBitmask';
}
if ((buttons & pttMouseForwardButtonBitmask) ==
pttMouseForwardButtonBitmask) {
return 'mouse-side-button:$pttMouseForwardButtonBitmask';
}
return null;
}
/// Result of a successful PTT binding capture.
class CapturedBinding {
const CapturedBinding({required this.inputClass, required this.platformKey});
@@ -205,16 +181,6 @@ class _PttBindingCaptureDialogState extends State<PttBindingCaptureDialog> {
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
final mouseSideButton = pttMouseSideButtonPlatformKeyForLogicalKey(
event.logicalKey,
);
if (mouseSideButton != null) {
setState(() {
_captured = mouseSideButton;
_capturedClass = rust.BridgePttInputClass.mouseSideButton;
});
return KeyEventResult.handled;
}
final label = pttDisplayLabelForKey(event.logicalKey);
if (label == null) return KeyEventResult.ignored;
setState(() {
@@ -224,9 +190,9 @@ class _PttBindingCaptureDialogState extends State<PttBindingCaptureDialog> {
return KeyEventResult.handled;
}
void _captureMouseSideButton(String platformKey) {
void _captureMouseSideButton(int button) {
setState(() {
_captured = platformKey;
_captured = 'mouse-side-button:$button';
_capturedClass = rust.BridgePttInputClass.mouseSideButton;
});
}
@@ -237,57 +203,59 @@ class _PttBindingCaptureDialogState extends State<PttBindingCaptureDialog> {
final theme = Theme.of(context);
return AlertDialog(
title: Text(l10n.pttConfigureTitle),
content: SizedBox(
width: 360,
child: Focus(
focusNode: _focusNode,
onKeyEvent: _onKeyEvent,
autofocus: true,
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (e) {
final platformKey = pttMouseSideButtonPlatformKeyForButtons(
e.buttons,
);
if (platformKey != null) {
_captureMouseSideButton(platformKey);
}
},
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.pttConfigurePrompt,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 16,
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360),
child: SingleChildScrollView(
child: Focus(
focusNode: _focusNode,
onKeyEvent: _onKeyEvent,
autofocus: true,
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (e) {
const int back = 0x08;
const int forward = 0x10;
if (e.buttons == back || e.buttons == forward) {
_captureMouseSideButton(e.buttons);
}
},
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.pttConfigurePrompt,
style: theme.textTheme.bodyMedium,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Text(
_captured == null
? l10n.pttConfigureWaiting
: '${l10n.pttConfigureCaptured}: $_captured',
style: theme.textTheme.bodyMedium?.copyWith(
fontFamily: 'monospace',
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 16,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Text(
_captured == null
? l10n.pttConfigureWaiting
: '${l10n.pttConfigureCaptured}: $_captured',
style: theme.textTheme.bodyMedium?.copyWith(
fontFamily: 'monospace',
),
),
),
),
const SizedBox(height: 12),
Text(
l10n.pttConfigurePrivacyNote,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
const SizedBox(height: 12),
Text(
l10n.pttConfigurePrivacyNote,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
],
),
),
),
),
@@ -54,43 +54,48 @@ class PttCapabilityBadge extends StatelessWidget {
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
isScrollControlled: true,
builder: (sheetContext) {
final theme = Theme.of(sheetContext);
final maxHeight = MediaQuery.sizeOf(sheetContext).height * 0.72;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.pttCapabilityExplainTitle,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 12),
Text(
l10n.pttCapabilityExplainFocusedHeading,
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 4),
Text(
l10n.pttCapabilityExplainFocusedBody,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
Text(
_explainBodyForPlatform(l10n),
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
Align(
alignment: AlignmentDirectional.centerEnd,
child: TextButton(
onPressed: () => Navigator.of(sheetContext).pop(),
child: Text(l10n.closeAction),
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxHeight),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.pttCapabilityExplainTitle,
style: theme.textTheme.titleMedium,
),
),
],
const SizedBox(height: 12),
Text(
l10n.pttCapabilityExplainFocusedHeading,
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 4),
Text(
l10n.pttCapabilityExplainFocusedBody,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
Text(
_explainBodyForPlatform(l10n),
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
Align(
alignment: AlignmentDirectional.centerEnd,
child: TextButton(
onPressed: () => Navigator.of(sheetContext).pop(),
child: Text(l10n.closeAction),
),
),
],
),
),
),
);
@@ -1,5 +1,3 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -8,17 +6,10 @@ import '../services/channel_spacer.dart';
import '../services/link_trust_service.dart';
import '../services/snapshot_state_mapper.dart';
import '../services/ts3_server_link.dart';
import '../services/ui_preferences_service.dart';
import '../src/rust/api.dart' as rust;
import 'bbcode_text.dart';
import 'talk_power_warning.dart';
typedef ClientPlaybackPreferenceChanged =
Future<void> Function(
rust.BridgeClient client,
ClientPlaybackPreference preference,
);
/// Connected-server snapshot with welcome text, channels, and clients.
class SnapshotView extends StatefulWidget {
/// Construct a snapshot view.
@@ -34,8 +25,10 @@ class SnapshotView extends StatefulWidget {
required this.canJoinVoiceChannel,
required this.onJoinChannel,
required this.onJoinChannelWithPassword,
this.clientPlaybackPreferences = const {},
this.onClientPlaybackPreferenceChanged,
this.enableClientLongPressMenu = false,
this.onOpenClientInfo,
this.onOpenClientChat,
this.onOpenClientPoke,
this.onTs3ServerLink,
});
@@ -69,11 +62,17 @@ class SnapshotView extends StatefulWidget {
/// Join a password-protected channel.
final ValueChanged<rust.BridgeChannel> onJoinChannelWithPassword;
/// Persisted per-client playback preferences keyed by TeamSpeak UID.
final Map<String, ClientPlaybackPreference> clientPlaybackPreferences;
/// True on touch-only mobile hosts where long-press opens client actions.
final bool enableClientLongPressMenu;
/// Apply an updated per-client playback preference.
final ClientPlaybackPreferenceChanged? onClientPlaybackPreferenceChanged;
/// Open richer profile details for a non-self client.
final ValueChanged<rust.BridgeClient>? onOpenClientInfo;
/// Open a direct chat with a non-self client.
final ValueChanged<rust.BridgeClient>? onOpenClientChat;
/// Open a poke composer for a non-self client.
final ValueChanged<rust.BridgeClient>? onOpenClientPoke;
/// Handle TeamSpeak server links embedded in server-provided text.
final Ts3ServerLinkHandler? onTs3ServerLink;
@@ -88,11 +87,10 @@ class _SnapshotViewState extends State<SnapshotView> {
static const _channelIconColumnWidth = 24.0;
static const _channelTextGap = 8.0;
static const _userRowStartIndent = 32.0;
static const _clientPlaybackVolumePresets = [0.25, 0.5, 1.0, 2.0, 4.0];
final _scrollController = ScrollController();
final Map<BigInt, bool> _channelExpandedById = {};
bool _welcomeExpanded = true;
bool _welcomeExpanded = false;
double _welcomeHeight = 0;
final _welcomeKey = GlobalKey();
@@ -293,6 +291,12 @@ class _SnapshotViewState extends State<SnapshotView> {
fontWeight: FontWeight.w600,
)
: null;
final isSelf = client.id == widget.snapshot.ownClientId;
final canOpenPeerActions =
!isSelf &&
(widget.onOpenClientChat != null || widget.onOpenClientPoke != null);
final canOpenClientMenu =
widget.onOpenClientInfo != null || canOpenPeerActions;
final decoration = status.isSpeaking
? BoxDecoration(
@@ -311,40 +315,90 @@ class _SnapshotViewState extends State<SnapshotView> {
],
)
: null;
final canAdjustPlayback = _canAdjustClientPlayback(client);
Offset? secondaryTapPosition;
return Padding(
final tile = Padding(
padding: EdgeInsets.only(
left: channelIndent + _userRowStartIndent,
right: 8,
),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPress: canAdjustPlayback
? () => unawaited(_showClientPlaybackMenu(client, withHaptic: true))
: null,
onSecondaryTapDown: canAdjustPlayback
? (details) => secondaryTapPosition = details.globalPosition
: null,
onSecondaryTap: canAdjustPlayback
? () => unawaited(
_showClientPlaybackMenu(client, anchor: secondaryTapPosition),
)
: null,
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
decoration: decoration,
child: ListTile(
dense: true,
visualDensity: VisualDensity.compact,
leading: status.icon,
title: Text(client.name, style: nameStyle),
),
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
decoration: decoration,
child: ListTile(
dense: true,
visualDensity: VisualDensity.compact,
leading: status.icon,
title: Text(client.name, style: nameStyle),
),
),
);
if (!canOpenClientMenu) return tile;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onSecondaryTapDown: (details) =>
_showClientMenu(client, details.globalPosition),
onLongPressStart: widget.enableClientLongPressMenu
? (details) => _showClientMenu(client, details.globalPosition)
: null,
child: tile,
);
}
Future<void> _showClientMenu(
rust.BridgeClient client,
Offset globalPosition,
) async {
final overlay = Overlay.of(context).context.findRenderObject();
if (overlay is! RenderBox) return;
final isSelf = client.id == widget.snapshot.ownClientId;
final selected = await showMenu<_ClientMenuAction>(
context: context,
position: RelativeRect.fromRect(
Rect.fromLTWH(globalPosition.dx, globalPosition.dy, 0, 0),
Offset.zero & overlay.size,
),
items: [
if (widget.onOpenClientInfo != null)
PopupMenuItem(
value: _ClientMenuAction.info,
child: ListTile(
dense: true,
leading: const Icon(Icons.info_outline),
title: Text(AppL10n.of(context).clientInfoAction),
),
),
if (!isSelf && widget.onOpenClientChat != null)
PopupMenuItem(
value: _ClientMenuAction.directMessage,
child: ListTile(
dense: true,
leading: const Icon(Icons.chat_bubble_outline),
title: Text(AppL10n.of(context).chatDirectMessageAction),
),
),
if (!isSelf && widget.onOpenClientPoke != null)
PopupMenuItem(
value: _ClientMenuAction.poke,
child: ListTile(
dense: true,
leading: const Icon(Icons.notifications_active_outlined),
title: Text(AppL10n.of(context).chatPokeAction),
),
),
],
);
if (!mounted || selected == null) return;
switch (selected) {
case _ClientMenuAction.info:
widget.onOpenClientInfo?.call(client);
case _ClientMenuAction.directMessage:
widget.onOpenClientChat?.call(client);
case _ClientMenuAction.poke:
widget.onOpenClientPoke?.call(client);
}
}
Widget _expandButton(
@@ -386,81 +440,6 @@ class _SnapshotViewState extends State<SnapshotView> {
});
}
bool _canAdjustClientPlayback(rust.BridgeClient client) {
return widget.onClientPlaybackPreferenceChanged != null &&
client.id != widget.snapshot.ownClientId &&
!client.isServerQuery &&
client.uid.isNotEmpty;
}
ClientPlaybackPreference _clientPlaybackPreference(rust.BridgeClient client) {
return widget.clientPlaybackPreferences[client.uid] ??
const ClientPlaybackPreference();
}
Future<void> _showClientPlaybackMenu(
rust.BridgeClient client, {
Offset? anchor,
bool withHaptic = false,
}) async {
if (!_canAdjustClientPlayback(client)) return;
final onChanged = widget.onClientPlaybackPreferenceChanged;
if (onChanged == null) return;
if (withHaptic) {
unawaited(HapticFeedback.selectionClick().catchError((_) {}));
}
if (!mounted) return;
final preference = _clientPlaybackPreference(client);
final overlay = Overlay.of(context).context.findRenderObject() as RenderBox;
final position = anchor ?? overlay.size.center(Offset.zero);
final selected = await showMenu<_ClientPlaybackMenuAction>(
context: context,
position: RelativeRect.fromLTRB(
position.dx,
position.dy,
overlay.size.width - position.dx,
overlay.size.height - position.dy,
),
items: [
PopupMenuItem<_ClientPlaybackMenuAction>(
value: const _ToggleMuteClientPlaybackMenuAction(),
child: ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Icon(
preference.muted ? Icons.volume_off : Icons.volume_up,
),
title: Text(preference.muted ? 'Unmute playback' : 'Mute playback'),
subtitle: Text(client.name),
),
),
const PopupMenuDivider(),
..._clientPlaybackVolumePresets.map(
(preset) => CheckedPopupMenuItem<_ClientPlaybackMenuAction>(
value: _VolumeClientPlaybackMenuAction(preset),
checked:
!preference.muted && (preference.volume - preset).abs() < 0.001,
child: Text('Volume ${(preset * 100).round()}%'),
),
),
],
);
if (selected == null || !mounted) return;
final next = switch (selected) {
_ToggleMuteClientPlaybackMenuAction() => preference.copyWith(
muted: !preference.muted,
),
_VolumeClientPlaybackMenuAction(:final volume) => preference.copyWith(
volume: volume,
muted: false,
),
};
await onChanged(client, next);
}
({Widget icon, bool isSpeaking}) _clientVoiceStatusIcon(
ThemeData theme,
rust.BridgeClient client,
@@ -530,6 +509,8 @@ class _SnapshotViewState extends State<SnapshotView> {
}
}
enum _ClientMenuAction { info, directMessage, poke }
class _SpacerChannelContent extends StatelessWidget {
const _SpacerChannelContent({required this.spacer});
@@ -673,20 +654,6 @@ class _ChannelTreeNode {
final List<_ChannelTreeNode> children = [];
}
sealed class _ClientPlaybackMenuAction {
const _ClientPlaybackMenuAction();
}
class _ToggleMuteClientPlaybackMenuAction extends _ClientPlaybackMenuAction {
const _ToggleMuteClientPlaybackMenuAction();
}
class _VolumeClientPlaybackMenuAction extends _ClientPlaybackMenuAction {
const _VolumeClientPlaybackMenuAction(this.volume);
final double volume;
}
_ChannelTree _buildChannelTree(List<rust.BridgeChannel> channels) {
final byParent = <BigInt, List<rust.BridgeChannel>>{};
final knownIds = {for (final channel in channels) channel.id};
@@ -757,7 +724,7 @@ class _WelcomeMessageTile extends StatelessWidget {
),
const SizedBox(width: 4),
Text(
'Server welcome message',
AppL10n.of(context).serverWelcomeHeading,
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
@@ -766,20 +733,22 @@ class _WelcomeMessageTile extends StatelessWidget {
),
),
),
AnimatedCrossFade(
firstChild: Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: BbCodeText(
welcomeMessage,
linkTrust: LinkTrustService.instance,
onTs3ServerLink: onTs3ServerLink,
),
),
secondChild: const SizedBox(width: double.infinity),
crossFadeState: expanded
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: expanded
? Padding(
key: const ValueKey('welcome-expanded'),
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: BbCodeText(
welcomeMessage,
linkTrust: LinkTrustService.instance,
onTs3ServerLink: onTs3ServerLink,
),
)
: const SizedBox(
key: ValueKey('welcome-collapsed'),
width: double.infinity,
),
),
],
),
@@ -1,468 +0,0 @@
import 'dart:async' show unawaited;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart';
import '../services/startup_dependency_check.dart';
class StartupDependencyGate extends StatefulWidget {
const StartupDependencyGate({
required this.child,
this.checker = checkStartupDependencies,
this.logger = logStartupDependencyIssues,
super.key,
});
final Widget child;
final Future<StartupDependencyCheckResult> Function() checker;
final Future<void> Function(StartupDependencyCheckResult result) logger;
@override
State<StartupDependencyGate> createState() => _StartupDependencyGateState();
}
class StartupDependencyScope extends InheritedWidget {
const StartupDependencyScope({
required this.result,
required super.child,
super.key,
});
final StartupDependencyCheckResult result;
static StartupDependencyCheckResult? maybeOf(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<StartupDependencyScope>()
?.result;
}
@override
bool updateShouldNotify(StartupDependencyScope oldWidget) {
return result != oldWidget.result;
}
}
class _StartupDependencyGateState extends State<StartupDependencyGate> {
late Future<StartupDependencyCheckResult> _future;
bool _dismissedForSession = false;
String? _lastLoggedIssueSignature;
@override
void initState() {
super.initState();
_future = widget.checker();
}
void _recheck() {
setState(() {
_future = widget.checker();
_dismissedForSession = false;
});
}
@override
Widget build(BuildContext context) {
if (_dismissedForSession) {
return FutureBuilder<StartupDependencyCheckResult>(
future: _future,
builder: (context, snapshot) {
return StartupDependencyScope(
result:
snapshot.data ??
const StartupDependencyCheckResult(
issues: [],
platformLabel: 'default',
),
child: widget.child,
);
},
);
}
return FutureBuilder<StartupDependencyCheckResult>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const _StartupCheckLoadingView();
}
final result = snapshot.data;
if (result == null || !result.hasIssues) {
_lastLoggedIssueSignature = null;
return StartupDependencyScope(
result:
result ??
const StartupDependencyCheckResult(
issues: [],
platformLabel: 'default',
),
child: widget.child,
);
}
_logIssueScreenShown(result);
return StartupDependencyScope(
result: result,
child: StartupDependencyScreen(
result: result,
onContinue: () {
setState(() {
_dismissedForSession = true;
});
},
onRecheck: _recheck,
),
);
},
);
}
void _logIssueScreenShown(StartupDependencyCheckResult result) {
final signature = [
result.platformLabel,
for (final issue in result.issues)
'${issue.id}:${issue.isRequired ? 'required' : 'recommended'}',
].join('|');
if (_lastLoggedIssueSignature == signature) {
return;
}
_lastLoggedIssueSignature = signature;
unawaited(widget.logger(result));
}
}
class StartupDependencyScreen extends StatelessWidget {
const StartupDependencyScreen({
required this.result,
required this.onContinue,
required this.onRecheck,
super.key,
});
final StartupDependencyCheckResult result;
final VoidCallback onContinue;
final VoidCallback onRecheck;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final hasBlockingIssues = result.hasBlockingIssues;
final content = Column(
children: [
Icon(
hasBlockingIssues
? Icons.warning_amber_rounded
: Icons.info_outline_rounded,
size: 52,
color: hasBlockingIssues
? theme.colorScheme.error
: theme.colorScheme.primary,
),
const SizedBox(height: 16),
Text(
'Finish Linux setup',
style: theme.textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Text(
hasBlockingIssues
? 'Chanora started, but some Linux runtime packages are still missing. Install them, then recheck.'
: 'Chanora started, but a few optional Linux runtime components are still missing. You can install them now or continue with limited functionality.',
style: theme.textTheme.bodyLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Detected platform: ${result.platformLabel}',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
...result.issues.map((issue) => _DependencyIssueCard(issue: issue)),
const SizedBox(height: 16),
Wrap(
alignment: WrapAlignment.center,
spacing: 12,
runSpacing: 12,
children: [
FilledButton.icon(
onPressed: onRecheck,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Recheck'),
),
OutlinedButton.icon(
onPressed: onContinue,
icon: const Icon(Icons.arrow_forward_rounded),
label: Text(
hasBlockingIssues
? 'Continue with limited mode'
: 'Continue anyway',
),
),
],
),
],
);
return Scaffold(
body: SafeArea(
child: Scrollbar(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
children: [
Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 880),
child: content,
),
),
],
),
),
),
);
}
}
class _DependencyIssueCard extends StatelessWidget {
const _DependencyIssueCard({required this.issue});
final StartupDependencyIssue issue;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final containerColor = issue.isRequired
? scheme.errorContainer
: scheme.secondaryContainer;
final onContainerColor = issue.isRequired
? scheme.onErrorContainer
: scheme.onSecondaryContainer;
return Card(
margin: const EdgeInsets.only(bottom: 16),
color: containerColor,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
issue.isRequired
? Icons.error_outline_rounded
: Icons.settings_suggest_rounded,
color: onContainerColor,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
issue.title,
style: theme.textTheme.titleMedium?.copyWith(
color: onContainerColor,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
issue.summary,
style: theme.textTheme.bodyMedium?.copyWith(
color: onContainerColor,
),
),
],
),
),
const SizedBox(width: 12),
_SeverityBadge(issue: issue),
],
),
if (issue.details.isNotEmpty) ...[
const SizedBox(height: 16),
...issue.details.map(
(detail) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'$detail',
style: theme.textTheme.bodyMedium?.copyWith(
color: onContainerColor,
),
),
),
),
],
if (issue.installHints.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'Install help',
style: theme.textTheme.titleSmall?.copyWith(
color: onContainerColor,
),
),
const SizedBox(height: 12),
...issue.installHints.map(
(hint) => _InstallHintTile(
hint: hint,
foregroundColor: onContainerColor,
),
),
],
],
),
),
);
}
}
class _InstallHintTile extends StatelessWidget {
const _InstallHintTile({required this.hint, required this.foregroundColor});
final StartupInstallHint hint;
final Color foregroundColor;
@override
Widget build(BuildContext context) {
final messenger = ScaffoldMessenger.of(context);
final isUrl = _isWebUrl(hint.command);
final VoidCallback? onTap = isUrl
? () => unawaited(_openUrl(context, Uri.parse(hint.command)))
: null;
return InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: foregroundColor.withValues(alpha: 0.08),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
hint.label,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: foregroundColor,
fontWeight: FontWeight.w700,
),
),
),
TextButton.icon(
onPressed: () async {
if (isUrl) {
await _openUrl(context, Uri.parse(hint.command));
return;
}
await Clipboard.setData(ClipboardData(text: hint.command));
messenger.showSnackBar(
const SnackBar(content: Text('Install command copied')),
);
},
icon: Icon(
isUrl
? Icons.open_in_new_rounded
: Icons.content_copy_rounded,
),
label: Text(isUrl ? 'Open' : 'Copy'),
),
],
),
const SizedBox(height: 8),
SelectableText(
hint.command,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontFamily: 'monospace',
color: foregroundColor,
),
),
],
),
),
);
}
bool _isWebUrl(String value) {
final uri = Uri.tryParse(value);
return uri != null &&
(uri.scheme == 'http' || uri.scheme == 'https') &&
uri.hasAuthority;
}
Future<void> _openUrl(BuildContext context, Uri uri) async {
final messenger = ScaffoldMessenger.of(context);
final opened = await launchUrl(uri, mode: LaunchMode.externalApplication);
if (!opened && context.mounted) {
messenger.showSnackBar(
SnackBar(content: Text('Could not open ${uri.toString()}')),
);
}
}
}
class _SeverityBadge extends StatelessWidget {
const _SeverityBadge({required this.issue});
final StartupDependencyIssue issue;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final foregroundColor = issue.isRequired
? scheme.onErrorContainer
: scheme.onSecondaryContainer;
return DecoratedBox(
decoration: BoxDecoration(
color: foregroundColor.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: foregroundColor.withValues(alpha: 0.18)),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Text(
issue.isRequired ? 'Required' : 'Recommended',
style: theme.textTheme.labelMedium?.copyWith(
color: foregroundColor,
fontWeight: FontWeight.w600,
),
),
),
);
}
}
class _StartupCheckLoadingView extends StatelessWidget {
const _StartupCheckLoadingView();
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Checking Linux runtime dependencies…'),
],
),
),
);
}
}
@@ -358,7 +358,6 @@ Future<void> showVoiceDetailsSheet(
required String pttBoundInputClass,
required bool isTouchOnly,
required rust.BridgeAudioProcessingConfig initialAudioConfig,
bool onnxRuntimeAvailable = true,
required ValueChanged<rust.BridgeTransmitMode> onModeChanged,
required ValueChanged<int> onReleaseTailChanged,
required ValueChanged<rust.BridgeAudioProcessingConfig> onAudioConfigChanged,
@@ -389,7 +388,6 @@ Future<void> showVoiceDetailsSheet(
pttBoundInputClass: pttBoundInputClass,
isTouchOnly: isTouchOnly,
initialAudioConfig: initialAudioConfig,
onnxRuntimeAvailable: onnxRuntimeAvailable,
onModeChanged: onModeChanged,
onReleaseTailChanged: onReleaseTailChanged,
onAudioConfigChanged: onAudioConfigChanged,
@@ -413,7 +411,6 @@ class _VoiceSheetBody extends StatefulWidget {
required this.pttBoundInputClass,
required this.isTouchOnly,
required this.initialAudioConfig,
required this.onnxRuntimeAvailable,
required this.onModeChanged,
required this.onReleaseTailChanged,
required this.onAudioConfigChanged,
@@ -431,7 +428,6 @@ class _VoiceSheetBody extends StatefulWidget {
final String pttBoundInputClass;
final bool isTouchOnly;
final rust.BridgeAudioProcessingConfig initialAudioConfig;
final bool onnxRuntimeAvailable;
final ValueChanged<rust.BridgeTransmitMode> onModeChanged;
final ValueChanged<int> onReleaseTailChanged;
final ValueChanged<rust.BridgeAudioProcessingConfig> onAudioConfigChanged;
@@ -466,10 +462,6 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
_audioProcessing = AudioProcessingConfigState.fromConfig(
widget.initialAudioConfig,
);
_audioProcessing.vadBackend = normalizedVadBackend(
_audioProcessing.vadBackend,
onnxRuntimeAvailable: widget.onnxRuntimeAvailable,
);
// Poll audio stats at 250 ms so TX/RX counters and the level meter
// update in real time while the sheet is open, independent of the parent.
@@ -785,32 +777,15 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
const SizedBox(height: 2),
SegmentedButton<rust.BridgeVadBackend>(
style: voiceSegmentedButtonStyle(theme),
segments: vadBackendSegmentsForAvailability(
desktop: _isDesktopSileroVadHost,
onnxRuntimeAvailable: widget.onnxRuntimeAvailable,
),
segments: _isDesktopSileroVadHost
? desktopVadBackendSegments
: vadBackendSegments,
selected: {_audioProcessing.vadBackend},
onSelectionChanged: (s) {
setState(
() => _audioProcessing.vadBackend = normalizedVadBackend(
s.first,
onnxRuntimeAvailable: widget.onnxRuntimeAvailable,
),
);
setState(() => _audioProcessing.vadBackend = s.first);
_notifyAudioConfig();
},
),
if (_isDesktopSileroVadHost) ...[
const SizedBox(height: 4),
Text(
widget.onnxRuntimeAvailable
? 'Silero needs ONNX Runtime. WebRTC works without it.'
: 'Silero is unavailable because ONNX Runtime was not found. WebRTC is selected.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
// Debug.
const SizedBox(height: 8),
@@ -67,7 +67,6 @@ class VoiceSettingsDialog extends StatefulWidget {
this.pttLevel = '',
this.pttBackendId = '',
this.pttBoundInputClass = '',
this.onnxRuntimeAvailable = true,
this.talkPower,
this.neededTalkPower,
this.talkPowerGranted,
@@ -79,7 +78,6 @@ class VoiceSettingsDialog extends StatefulWidget {
final String pttLevel;
final String pttBackendId;
final String pttBoundInputClass;
final bool onnxRuntimeAvailable;
final int? talkPower;
final int? neededTalkPower;
final bool? talkPowerGranted;
@@ -103,10 +101,6 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
_audioProcessing = AudioProcessingConfigState.fromConfig(
widget.initialAudioConfig,
);
_audioProcessing.vadBackend = normalizedVadBackend(
_audioProcessing.vadBackend,
onnxRuntimeAvailable: widget.onnxRuntimeAvailable,
);
}
rust.BridgeAudioProcessingConfig _buildConfig() {
@@ -297,29 +291,14 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
const VoiceSubHeader('Backend'),
SegmentedButton<rust.BridgeVadBackend>(
style: voiceSegmentedButtonStyle(theme),
segments: vadBackendSegmentsForAvailability(
desktop: _isDesktopSileroVadHost,
onnxRuntimeAvailable: widget.onnxRuntimeAvailable,
),
segments: _isDesktopSileroVadHost
? desktopVadBackendSegments
: vadBackendSegments,
selected: {_audioProcessing.vadBackend},
onSelectionChanged: (s) => setState(
() => _audioProcessing.vadBackend = normalizedVadBackend(
s.first,
onnxRuntimeAvailable: widget.onnxRuntimeAvailable,
),
),
onSelectionChanged: (s) =>
setState(() => _audioProcessing.vadBackend = s.first),
),
const SizedBox(height: 8),
if (_isDesktopSileroVadHost)
Text(
widget.onnxRuntimeAvailable
? 'Silero gives the best quality when ONNX Runtime is installed. WebRTC works without ONNX Runtime and is the safer fallback if Linux setup is incomplete.'
: 'Silero is unavailable because ONNX Runtime was not found. WebRTC is selected until libonnxruntime.so is installed or bundled.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
if (_isDesktopSileroVadHost) const SizedBox(height: 8),
// ── PTT capability badge ────────────────────────────────
if (_mode == rust.BridgeTransmitMode.ptt &&
@@ -59,30 +59,15 @@ const vadBackendSegments = [
/// Desktop VAD selector segments.
///
/// Desktop keeps Silero as the default, but WebRTC remains a supported
/// manual fallback when ONNX Runtime is missing or when the user wants a
/// smaller dependency surface.
const desktopVadBackendSegments = vadBackendSegments;
List<ButtonSegment<rust.BridgeVadBackend>> vadBackendSegmentsForAvailability({
required bool desktop,
required bool onnxRuntimeAvailable,
}) {
final segments = desktop ? desktopVadBackendSegments : vadBackendSegments;
if (!desktop || onnxRuntimeAvailable) return segments;
return [
for (final segment in segments)
if (segment.value == rust.BridgeVadBackend.sileroOnnx)
ButtonSegment<rust.BridgeVadBackend>(
value: segment.value,
label: segment.label,
icon: segment.icon,
enabled: false,
)
else
segment,
];
}
/// Windows and Linux use Silero as the primary VAD. WebRTC remains an
/// internal runtime fallback when the model/runtime is unavailable.
const desktopVadBackendSegments = [
ButtonSegment(
value: rust.BridgeVadBackend.sileroOnnx,
label: Text('Silero'),
icon: Icon(Icons.psychology, size: 14),
),
];
/// Section subheader used by both voice settings surfaces.
class VoiceSubHeader extends StatelessWidget {
-60
View File
@@ -9,21 +9,6 @@ set(BINARY_NAME "chanora_flutter")
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
set(APPLICATION_ID "app.chanora.chanora_flutter")
# Repository root for Rust bridge builds / bundle staging.
set(CHANORA_REPO_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../..")
# Optional path to a Linux ONNX Runtime shared library for Silero VAD.
# When set, the bundle carries a local `lib/libonnxruntime.so` sidecar and the
# runner exports `ORT_DYLIB_PATH` to that file before Flutter starts.
set(CHANORA_ONNXRUNTIME_SHARED_LIB "$ENV{CHANORA_ONNXRUNTIME_SHARED_LIB}" CACHE FILEPATH
"Absolute path to libonnxruntime.so for Linux bundle packaging")
# Optional path to a prebuilt Rust bridge shared library. When unset, the Linux
# bundle build runs `cargo build -p chanora_bridge` for the host profile and
# stages the resulting `libchanora_bridge.so` automatically.
set(CHANORA_BRIDGE_SHARED_LIB "$ENV{CHANORA_BRIDGE_SHARED_LIB}" CACHE FILEPATH
"Absolute path to libchanora_bridge.so for Linux bundle packaging")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake.
cmake_policy(SET CMP0063 NEW)
@@ -68,7 +53,6 @@ add_subdirectory(${FLUTTER_MANAGED_DIR})
# System-level dependencies.
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
find_program(CARGO_EXECUTABLE cargo REQUIRED)
# Application build; see runner/CMakeLists.txt.
add_subdirectory("runner")
@@ -76,27 +60,6 @@ add_subdirectory("runner")
# Run the Flutter tool portions of the build. This must not be removed.
add_dependencies(${BINARY_NAME} flutter_assemble)
if(CMAKE_BUILD_TYPE MATCHES "Debug")
set(CHANORA_BRIDGE_PROFILE_DIR "debug")
set(CHANORA_BRIDGE_CARGO_ARGS build --package chanora_bridge)
else()
set(CHANORA_BRIDGE_PROFILE_DIR "release")
set(CHANORA_BRIDGE_CARGO_ARGS build --release --package chanora_bridge)
endif()
if(CHANORA_BRIDGE_SHARED_LIB)
set(CHANORA_BRIDGE_STAGED_LIB "${CHANORA_BRIDGE_SHARED_LIB}")
else()
set(CHANORA_BRIDGE_STAGED_LIB
"${CHANORA_REPO_ROOT}/target/${CHANORA_BRIDGE_PROFILE_DIR}/libchanora_bridge.so")
add_custom_target(chanora_bridge_bundle
COMMAND "${CARGO_EXECUTABLE}" ${CHANORA_BRIDGE_CARGO_ARGS}
WORKING_DIRECTORY "${CHANORA_REPO_ROOT}"
COMMENT "Building chanora_bridge for Linux bundle"
VERBATIM)
add_dependencies(${BINARY_NAME} chanora_bridge_bundle)
endif()
# Only the install-generated bundle's copy of the executable will launch
# correctly, since the resources must in the right relative locations. To avoid
# people trying to run the unbundled copy, put it in a subdirectory instead of
@@ -137,29 +100,6 @@ install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
if(EXISTS "${CHANORA_BRIDGE_STAGED_LIB}")
install(FILES "${CHANORA_BRIDGE_STAGED_LIB}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
else()
message(WARNING
"Chanora bridge shared library was not found for Linux bundling: "
"${CHANORA_BRIDGE_STAGED_LIB}")
endif()
if(CHANORA_ONNXRUNTIME_SHARED_LIB)
if(EXISTS "${CHANORA_ONNXRUNTIME_SHARED_LIB}")
install(FILES "${CHANORA_ONNXRUNTIME_SHARED_LIB}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
RENAME "libonnxruntime.so"
COMPONENT Runtime)
else()
message(WARNING
"CHANORA_ONNXRUNTIME_SHARED_LIB was set but the file was not found: "
"${CHANORA_ONNXRUNTIME_SHARED_LIB}")
endif()
endif()
foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
install(FILES "${bundled_library}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
@@ -14,27 +14,6 @@ struct _MyApplication {
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
static void configure_onnxruntime_dylib_path() {
const gchar* existing = g_getenv("ORT_DYLIB_PATH");
if (existing != nullptr && *existing != '\0') {
return;
}
g_autofree gchar* exe_path = g_file_read_link("/proc/self/exe", nullptr);
if (exe_path == nullptr) {
return;
}
g_autofree gchar* exe_dir = g_path_get_dirname(exe_path);
g_autofree gchar* ort_path =
g_build_filename(exe_dir, "lib", "libonnxruntime.so", nullptr);
if (!g_file_test(ort_path, G_FILE_TEST_IS_REGULAR)) {
return;
}
g_setenv("ORT_DYLIB_PATH", ort_path, TRUE);
}
// Called when first Flutter frame received.
static void first_frame_cb(MyApplication* self, FlView* view) {
gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view)));
@@ -131,7 +110,6 @@ static void my_application_startup(GApplication* application) {
// MyApplication* self = MY_APPLICATION(object);
// Perform any actions required at application startup.
configure_onnxruntime_dylib_path();
G_APPLICATION_CLASS(my_application_parent_class)->startup(application);
}
@@ -21,6 +21,69 @@ void main() {
expect(ConnectionPhase.connected.canDisconnect, isTrue);
});
test('server-reachable phases show loading until snapshot is available', () {
expect(
ConnectionPhase.connecting.shouldShowSnapshotLoading(hasSnapshot: false),
isFalse,
);
expect(
ConnectionPhase.synchronizing.shouldShowSnapshotLoading(
hasSnapshot: false,
),
isTrue,
);
expect(
ConnectionPhase.connected.shouldShowSnapshotLoading(hasSnapshot: false),
isTrue,
);
expect(
ConnectionPhase.reconnecting.shouldShowSnapshotLoading(
hasSnapshot: false,
),
isTrue,
);
expect(
ConnectionPhase.synchronizing.shouldShowSnapshotLoading(
hasSnapshot: true,
),
isFalse,
);
});
test('chat only opens after a snapshot is available', () {
expect(
ConnectionPhase.synchronizing.canOpenChatWithSnapshot(hasSnapshot: false),
isFalse,
);
expect(
ConnectionPhase.synchronizing.canOpenChatWithSnapshot(hasSnapshot: true),
isTrue,
);
expect(
ConnectionPhase.connected.canOpenChatWithSnapshot(hasSnapshot: true),
isTrue,
);
expect(
ConnectionPhase.reconnecting.canOpenChatWithSnapshot(hasSnapshot: true),
isFalse,
);
});
test('snapshot application completes synchronizing phase', () {
expect(
phaseAfterSnapshotApplied(ConnectionPhase.synchronizing),
ConnectionPhase.connected,
);
expect(
phaseAfterSnapshotApplied(ConnectionPhase.connected),
ConnectionPhase.connected,
);
expect(
phaseAfterSnapshotApplied(ConnectionPhase.reconnecting),
ConnectionPhase.reconnecting,
);
});
test('connection status text maps all phases', () {
expect(
connectionStatusText(phase: ConnectionPhase.idle, l10n: l10n),
@@ -78,4 +141,22 @@ void main() {
expect(ConnectionPhase.reconnecting.tokens(scheme).icon, Icons.restart_alt);
expect(ConnectionPhase.disconnected.tokens(scheme).icon, Icons.cloud_off);
});
test('connected event preserves connected phase when snapshot exists', () {
expect(
phaseAfterConnectedEvent(ConnectionPhase.connected, hasSnapshot: true),
ConnectionPhase.connected,
);
});
test('connected event synchronizes when snapshot is missing', () {
expect(
phaseAfterConnectedEvent(ConnectionPhase.connected, hasSnapshot: false),
ConnectionPhase.synchronizing,
);
expect(
phaseAfterConnectedEvent(ConnectionPhase.reconnecting, hasSnapshot: true),
ConnectionPhase.synchronizing,
);
});
}
@@ -0,0 +1,48 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/services/prefetch_debouncer.dart';
void main() {
test('debounces host edits and prefetches latest trimmed host', () async {
final calls = <String>[];
final debouncer = PrefetchDebouncer(
delay: const Duration(milliseconds: 20),
onPrefetch: (host) async => calls.add(host),
);
debouncer.schedule(' first.example.com ');
debouncer.schedule(' second.example.com ');
await Future<void>.delayed(const Duration(milliseconds: 35));
expect(calls, ['second.example.com']);
debouncer.dispose();
});
test('skips empty hosts', () async {
final calls = <String>[];
final debouncer = PrefetchDebouncer(
delay: const Duration(milliseconds: 10),
onPrefetch: (host) async => calls.add(host),
);
debouncer.schedule(' ');
await Future<void>.delayed(const Duration(milliseconds: 25));
expect(calls, isEmpty);
debouncer.dispose();
});
test('dispose cancels pending prefetch', () async {
final calls = <String>[];
final debouncer = PrefetchDebouncer(
delay: const Duration(milliseconds: 30),
onPrefetch: (host) async => calls.add(host),
);
debouncer.schedule('example.com');
debouncer.dispose();
await Future<void>.delayed(const Duration(milliseconds: 45));
expect(calls, isEmpty);
});
}
@@ -18,7 +18,6 @@ void main() {
rust.BridgeClient client({
BigInt? id,
BigInt? channelId,
String uid = '',
int talkPower = 0,
bool talkPowerGranted = false,
bool inputMuted = false,
@@ -26,7 +25,6 @@ void main() {
}) {
return rust.BridgeClient(
id: id ?? BigInt.from(7),
uid: uid,
channel: channelId ?? BigInt.one,
name: 'Me',
inputMuted: inputMuted,
@@ -1,167 +0,0 @@
import 'dart:ffi';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/services/startup_dependency_check.dart';
void main() {
tearDown(() {
debugResetStartupDependencyCheck();
});
test('non-linux hosts skip startup dependency issues', () async {
debugResetStartupDependencyCheck(platformIsLinux: () => false);
final result = await checkStartupDependencies();
expect(result.issues, isEmpty);
});
test('missing Linux audio runtimes are reported as recommended', () async {
debugResetStartupDependencyCheck(
platformIsLinux: () => true,
libraryProbe: (candidate) => false,
fileExists: (_) async => false,
osReleaseProvider: () async => 'ID=ubuntu\nID_LIKE=debian\n',
resolvedExecutableProvider: () => '/opt/chanora/chanora_flutter',
currentDirectoryProvider: () => '/tmp',
);
final result = await checkStartupDependencies();
expect(result.hasIssues, isTrue);
final pipewire = result.issues.firstWhere(
(issue) => issue.id == 'linux-pipewire-runtime',
);
final pulse = result.issues.firstWhere(
(issue) => issue.id == 'linux-pulseaudio-runtime',
);
expect(pipewire.isRequired, isFalse);
expect(pulse.isRequired, isFalse);
expect(
pipewire.installHints.single.command,
'sudo apt install libpipewire-0.3-0',
);
expect(pulse.installHints.single.command, 'sudo apt install libpulse0');
});
test('distro detection parses quoted ID_LIKE lists', () async {
debugResetStartupDependencyCheck(
platformIsLinux: () => true,
libraryProbe: (candidate) => false,
fileExists: (_) async => true,
osReleaseProvider: () async => 'ID=rocky\nID_LIKE="fedora rhel"\n',
resolvedExecutableProvider: () => '/opt/chanora/chanora_flutter',
currentDirectoryProvider: () => '/tmp',
);
final result = await checkStartupDependencies();
expect(result.platformLabel, 'Fedora');
final pipewire = result.issues.singleWhere(
(issue) => issue.id == 'linux-pipewire-runtime',
);
expect(
pipewire.installHints.single.command,
'sudo dnf install pipewire-libs',
);
});
test(
'missing ONNX runtime is reported as recommended when Linux audio runtimes are present',
() async {
debugResetStartupDependencyCheck(
platformIsLinux: () => true,
libraryProbe: (candidate) =>
candidate.contains('pipewire') || candidate.contains('pulse'),
fileExists: (_) async => false,
osReleaseProvider: () async => 'ID=fedora\n',
resolvedExecutableProvider: () => '/opt/chanora/chanora_flutter',
currentDirectoryProvider: () => '/tmp',
currentAbiProvider: () => Abi.linuxX64,
);
final result = await checkStartupDependencies();
expect(result.issues, hasLength(1));
final ort = result.issues.single;
expect(ort.id, 'linux-onnxruntime');
expect(ort.isRequired, isFalse);
expect(
ort.installHints.any(
(hint) =>
hint.command ==
'https://github.com/microsoft/onnxruntime/releases',
),
isTrue,
);
expect(
ort.installHints.any(
(hint) => hint.command == 'onnxruntime-linux-x64-<version>.tgz',
),
isTrue,
);
expect(
ort.installHints.any((hint) => hint.command.contains('ORT_DYLIB_PATH')),
isTrue,
);
expect(
ort.details,
contains(
'This machine needs the Linux x64 CPU archive (onnxruntime-linux-x64-<version>.tgz).',
),
);
},
);
test('bundle-local ONNX runtime clears the recommendation', () async {
debugResetStartupDependencyCheck(
platformIsLinux: () => true,
libraryProbe: (candidate) =>
candidate.contains('pipewire') || candidate.contains('pulse'),
fileExists: (path) async => path == '/opt/chanora/lib/libonnxruntime.so',
resolvedExecutableProvider: () => '/opt/chanora/chanora_flutter',
currentDirectoryProvider: () => '/tmp',
);
final result = await checkStartupDependencies();
expect(result.issues, isEmpty);
});
test('logging missing startup dependencies appends a log line', () async {
final tempDir = await Directory.systemTemp.createTemp(
'chanora-startup-log',
);
addTearDown(() => tempDir.delete(recursive: true));
final logFile = File('${tempDir.path}/chanora.log');
debugResetStartupDependencyCheck(logFilePathProvider: () => logFile.path);
const result = StartupDependencyCheckResult(
platformLabel: 'Fedora',
issues: [
StartupDependencyIssue(
id: 'linux-pipewire-runtime',
title: 'PipeWire runtime is missing',
summary: 'Primary Linux audio needs PipeWire.',
details: ['Install PipeWire and recheck.'],
severity: StartupDependencySeverity.recommended,
),
],
);
await logStartupDependencyIssues(result);
final text = await logFile.readAsString();
expect(text, contains('[startup_dependency_check]'));
expect(text, contains('platform="Fedora"'));
expect(
text,
contains(
'issues=[linux-pipewire-runtime:recommended:PipeWire runtime is missing]',
),
);
});
}
@@ -16,6 +16,7 @@ void main() {
expect(settings.host, isEmpty);
expect(settings.nickname, isEmpty);
expect(settings.themeMode, UiThemeMode.system);
});
test('saves and loads host and nickname independently', () async {
@@ -40,78 +41,30 @@ void main() {
expect(await service.hasExplainedPermissions(), isTrue);
});
test('stores per-user playback preferences per server host', () async {
await service.saveClientPlaybackPreference(
serverHost: 'Example.COM',
userUid: 'user-a',
volume: 0.5,
muted: true,
);
await service.saveClientPlaybackPreference(
serverHost: 'other.example',
userUid: 'user-a',
volume: 2.0,
muted: false,
);
test('saves and loads theme mode independently', () async {
await service.saveSettings(host: 'example.com', nickname: 'Chanora');
await service.saveThemeMode(UiThemeMode.dark);
final examplePrefs = await service.loadClientPlaybackPreferencesForServer(
'example.com',
);
final otherPrefs = await service.loadClientPlaybackPreferencesForServer(
'other.example',
);
var settings = await service.loadSettings();
expect(examplePrefs['user-a']?.volume, 0.5);
expect(examplePrefs['user-a']?.muted, isTrue);
expect(otherPrefs['user-a']?.volume, 2.0);
expect(otherPrefs['user-a']?.muted, isFalse);
expect(settings.host, 'example.com');
expect(settings.nickname, 'Chanora');
expect(settings.themeMode, UiThemeMode.dark);
await service.saveThemeMode(UiThemeMode.light);
settings = await service.loadSettings();
expect(settings.host, 'example.com');
expect(settings.nickname, 'Chanora');
expect(settings.themeMode, UiThemeMode.light);
});
test(
'drops default playback preferences instead of persisting them',
() async {
await service.saveClientPlaybackPreference(
serverHost: 'example.com',
userUid: 'user-a',
volume: 0.2,
muted: true,
);
await service.saveClientPlaybackPreference(
serverHost: 'example.com',
userUid: 'user-a',
volume: 1.0,
muted: false,
);
test('falls back to system theme mode for invalid stored values', () async {
SharedPreferences.setMockInitialValues({'ui.theme_mode': 'sepia'});
service = const UiPreferencesService();
final prefs = await service.loadClientPlaybackPreferencesForServer(
'example.com',
);
expect(prefs, isEmpty);
},
);
final settings = await service.loadSettings();
test('ignores corrupt playback preference JSON', () async {
SharedPreferences.setMockInitialValues({
'audio.client_playback_prefs': '{not-json',
});
final prefs = await service.loadClientPlaybackPreferencesForServer(
'example.com',
);
expect(prefs, isEmpty);
});
test('clamps invalid playback volumes to unity', () async {
SharedPreferences.setMockInitialValues({
'audio.client_playback_prefs':
'{"example.com|user-a":{"volume":"invalid","muted":false}}',
});
final prefs = await service.loadClientPlaybackPreferencesForServer(
'example.com',
);
expect(prefs['user-a']?.volume, 1.0);
expect(settings.themeMode, UiThemeMode.system);
});
}
@@ -18,12 +18,44 @@ import 'package:flutter_test/flutter_test.dart';
// ignore_for_file: deprecated_member_use
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
import 'package:chanora_flutter/main.dart';
import 'package:chanora_flutter/services/android_permissions_service.dart';
import 'package:chanora_flutter/services/ios_permissions_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:chanora_flutter/widgets/permission_state_banner.dart';
import 'package:chanora_flutter/widgets/voice_compact.dart';
void main() {
testWidgets('theme menu emits selected mode from user action', (tester) async {
SharedPreferences.setMockInitialValues({});
var selected = ThemeMode.system;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
appBar: AppBar(
actions: [
ChanoraThemeModeMenu(
themeMode: ThemeMode.system,
onThemeModeChanged: (mode) async {
selected = mode;
},
),
],
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.palette_outlined));
await tester.pumpAndSettle();
await tester.tap(find.text('Dark').last);
await tester.pumpAndSettle();
expect(selected, ThemeMode.dark);
});
testWidgets('renders English banner', (tester) async {
await tester.pumpWidget(
MaterialApp(
@@ -31,14 +31,14 @@ void main() {
expect(state.agcEnabled, isTrue);
});
test('desktop VAD normalization keeps explicit WebRTC selections', () {
test('normalizes desktop VAD backend to Silero', () {
expect(
normalizedVadBackend(
rust.BridgeVadBackend.webrtcVad,
isWindows: true,
isLinux: false,
),
rust.BridgeVadBackend.webrtcVad,
rust.BridgeVadBackend.sileroOnnx,
);
expect(
normalizedVadBackend(
@@ -46,33 +46,10 @@ void main() {
isWindows: false,
isLinux: true,
),
rust.BridgeVadBackend.webrtcVad,
);
expect(
normalizedVadBackend(
rust.BridgeVadBackend.energyDebug,
isWindows: true,
isLinux: false,
),
rust.BridgeVadBackend.sileroOnnx,
);
});
test(
'desktop VAD normalization falls back when ONNX Runtime is unavailable',
() {
expect(
normalizedVadBackend(
rust.BridgeVadBackend.sileroOnnx,
isWindows: false,
isLinux: true,
onnxRuntimeAvailable: false,
),
rust.BridgeVadBackend.webrtcVad,
);
},
);
test('default config matches the current platform fallback', () {
final fallback = defaultAudioProcessingConfig();
final desktop = Platform.isWindows || Platform.isLinux;
@@ -126,7 +103,6 @@ void main() {
test('builds Windows/Linux software WebRTC APM config consistently', () {
final state = AudioProcessingConfigState.fromConfig(baseConfig)
..vadBackend = rust.BridgeVadBackend.webrtcVad
..nsEnabled = true
..aecEnabled = false
..agcEnabled = true;
@@ -151,7 +127,7 @@ void main() {
for (final config in [windowsConfig, linuxConfig]) {
expect(config.processingBackend, rust.BridgeAudioBackend.webrtcApm);
expect(config.vadBackend, rust.BridgeVadBackend.webrtcVad);
expect(config.vadBackend, rust.BridgeVadBackend.sileroOnnx);
expect(config.aec, rust.BridgeEffectOwner.off);
expect(config.ns, rust.BridgeEffectOwner.webrtcApm);
expect(config.agc, rust.BridgeEffectOwner.webrtcApm);
@@ -36,12 +36,10 @@ void main() {
required BigInt id,
required String name,
required BigInt channelId,
String uid = '',
bool isServerQuery = false,
}) {
return rust.BridgeClient(
id: id,
uid: uid,
channel: channelId,
name: name,
inputMuted: false,
@@ -466,6 +464,8 @@ void main() {
) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: ChatPage(
messages: [
entry(rust.BridgeMessageTarget.poke(BigInt.from(2))),
@@ -498,15 +498,7 @@ void main() {
final privateTop = tester.getTopLeft(find.text('Alpha').first).dy;
final serverTileSize = tester.getSize(
find
.ancestor(
of: find.byIcon(Icons.dns_outlined),
matching: find.byWidgetPredicate(
(widget) =>
widget is SizedBox &&
widget.width == 92 &&
widget.height == 92,
),
)
.ancestor(of: find.text('Server').first, matching: find.byType(Ink))
.first,
);
@@ -516,63 +508,23 @@ void main() {
expect(serverTileSize.height, 92);
});
testWidgets('chat sidebar uses MD3-style selected container colors', (
testWidgets('chat page uses a compact top rail on phone width', (
tester,
) async {
final theme = ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
);
await tester.pumpWidget(
MaterialApp(
theme: theme,
home: ChatPage(
messages: const [],
snapshot: snapshot(channels: const [], clients: const []),
initialTarget: const rust.BridgeMessageTarget.server(),
),
),
);
final serverIndicator = find.ancestor(
of: find.byIcon(Icons.dns_outlined),
matching: find.byType(AnimatedContainer),
);
final channelIndicator = find.ancestor(
of: find.byIcon(Icons.tag),
matching: find.byType(AnimatedContainer),
);
final serverDecoration =
tester.widget<AnimatedContainer>(serverIndicator).decoration
as BoxDecoration;
final channelDecoration =
tester.widget<AnimatedContainer>(channelIndicator).decoration
as BoxDecoration;
final serverLabel = tester.widget<Text>(find.text('Server').last);
final channelLabel = tester.widget<Text>(find.text('Channel'));
expect(tester.getSize(serverIndicator), const Size(76, 76));
expect(tester.getSize(channelIndicator), const Size(76, 76));
expect(serverDecoration.color, theme.colorScheme.primaryContainer);
expect(channelDecoration.color, Colors.transparent);
expect(serverLabel.style?.color, theme.colorScheme.onPrimaryContainer);
expect(channelLabel.style?.color, theme.colorScheme.onSurfaceVariant);
});
testWidgets('long private chat labels stay within fixed rail indicator', (
tester,
) async {
const longName = 'Very Long Private Chat Name That Should Not Stretch';
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: ChatPage(
messages: [
ChatEntry(
senderId: BigInt.from(2),
senderName: longName,
senderName: 'Alpha',
message: 'Private',
target: rust.BridgeMessageTarget.client(BigInt.from(2)),
),
@@ -580,29 +532,24 @@ void main() {
snapshot: snapshot(
channels: const [],
clients: [
client(
id: BigInt.from(2),
name: longName,
channelId: BigInt.zero,
),
client(id: BigInt.from(2), name: 'Alpha', channelId: BigInt.zero),
],
),
initialTarget: rust.BridgeMessageTarget.client(BigInt.from(2)),
initialClientName: longName,
),
),
);
final selectedIndicator = find.ancestor(
of: find.text(longName).first,
matching: find.byType(AnimatedContainer),
);
final privateLabel = tester.widget<Text>(find.text(longName).first);
await tester.pumpAndSettle();
expect(tester.getSize(selectedIndicator), const Size(76, 76));
expect(privateLabel.maxLines, 2);
expect(privateLabel.overflow, TextOverflow.ellipsis);
expect(privateLabel.textAlign, TextAlign.center);
expect(find.byType(VerticalDivider), findsNothing);
final serverTileSize = tester.getSize(
find
.ancestor(of: find.text('Server').first, matching: find.byType(Ink))
.first,
);
expect(serverTileSize.width, lessThan(92));
expect(serverTileSize.height, lessThan(92));
expect(tester.getTopLeft(find.text('Private').first).dy, greaterThan(92));
});
testWidgets(
@@ -610,6 +557,8 @@ void main() {
(tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: ChatPage(
messages: [
ChatEntry(
@@ -715,6 +664,8 @@ void main() {
) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: ChatPage(
messages: [
ChatEntry(
@@ -755,6 +706,8 @@ void main() {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: ChatPage(
messages: messages,
snapshot: currentSnapshot,
@@ -822,6 +775,8 @@ void main() {
Ts3ServerLink? tapped;
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: ChatPage(
messages: [
ChatEntry(
@@ -0,0 +1,112 @@
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/client_info_sheet.dart';
void main() {
rust.BridgeClientProfile profile() {
return rust.BridgeClientProfile(
id: BigInt.from(101),
channel: BigInt.from(7),
name: 'Bob',
uniqueId: 'client-unique-id',
databaseId: BigInt.from(55),
countryCode: 'US',
description: 'Operator',
version: '3.6.2',
platform: 'Windows',
onlineSeconds: 3661,
idleMilliseconds: 42000,
pingMilliseconds: 38,
clientAddress: '203.0.113.24',
serverGroups: const ['Admin', 'Talk Power'],
channelGroup: 'Guest',
avatarPath: '/avatar_aabbcc',
bytesDownloadedMonth: BigInt.from(2048),
bytesUploadedMonth: BigInt.from(4096),
bytesDownloadedTotal: BigInt.from(1048576),
bytesUploadedTotal: BigInt.from(2097152),
packetLossClientToServerTotal: 0.0123,
packetLossServerToClientTotal: 0.0456,
);
}
testWidgets('renders loaded profile data in a modal sheet', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: FilledButton(
onPressed: () => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (context) => FractionallySizedBox(
heightFactor: 0.86,
child: ClientInfoSheet(
clientName: 'Bob',
loadProfile: () async => profile(),
),
),
),
child: const Text('Open'),
),
),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('Bob'), findsOneWidget);
expect(find.text('Identity'), findsOneWidget);
expect(find.text('Membership'), findsOneWidget);
expect(find.text('Admin, Talk Power'), findsOneWidget);
await tester.drag(find.byType(ListView), const Offset(0, -360));
await tester.pumpAndSettle();
expect(find.text('Connection'), findsOneWidget);
expect(find.text('1h 1m 1s'), findsOneWidget);
expect(find.text('42.00 s'), findsOneWidget);
expect(find.text('203.0.113.24'), findsOneWidget);
});
testWidgets('loading and error states use localized copy', (tester) async {
await tester.pumpWidget(
MaterialApp(
locale: const Locale('zh'),
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: SizedBox(
height: 500,
child: ClientInfoSheet(
clientName: 'Bob',
loadProfile: () => Future<rust.BridgeClientProfile>.error(
StateError('not available'),
),
),
),
),
),
);
expect(find.text('正在加载资料…'), findsOneWidget);
expect(find.text('Loading profile...'), findsNothing);
await tester.pumpAndSettle();
expect(find.text('资料不可用'), findsOneWidget);
expect(find.text('重试'), findsOneWidget);
expect(find.text('Profile unavailable'), findsNothing);
});
}
@@ -1,41 +0,0 @@
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/widgets/connect_widgets.dart';
void main() {
testWidgets('pressing enter in password field submits connection', (
tester,
) async {
var connectCount = 0;
final hostCtl = TextEditingController(text: 'example.com');
final nickCtl = TextEditingController(text: 'alice');
final passwordCtl = TextEditingController();
addTearDown(hostCtl.dispose);
addTearDown(nickCtl.dispose);
addTearDown(passwordCtl.dispose);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: ConnectForm(
hostCtl: hostCtl,
nickCtl: nickCtl,
passwordCtl: passwordCtl,
onConnect: () => connectCount += 1,
onAddBookmark: () {},
),
),
),
);
await tester.enterText(find.byType(TextField).last, 'secret');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
expect(connectCount, 1);
});
}
@@ -1,41 +0,0 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/widgets/input_dialogs.dart';
void main() {
test('browser back and forward logical keys map to mouse side bindings', () {
expect(
pttMouseSideButtonPlatformKeyForLogicalKey(
LogicalKeyboardKey.browserBack,
),
'mouse-side-button:8',
);
expect(
pttMouseSideButtonPlatformKeyForLogicalKey(LogicalKeyboardKey.goBack),
'mouse-side-button:8',
);
expect(
pttMouseSideButtonPlatformKeyForLogicalKey(
LogicalKeyboardKey.browserForward,
),
'mouse-side-button:16',
);
});
test('pointer button bitmasks map to mouse side bindings', () {
expect(
pttMouseSideButtonPlatformKeyForButtons(0x08),
'mouse-side-button:8',
);
expect(
pttMouseSideButtonPlatformKeyForButtons(0x10),
'mouse-side-button:16',
);
expect(
pttMouseSideButtonPlatformKeyForButtons(0x18),
'mouse-side-button:8',
);
expect(pttMouseSideButtonPlatformKeyForButtons(0x00), isNull);
});
}
@@ -0,0 +1,160 @@
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/main.dart';
import 'package:chanora_flutter/widgets/connect_widgets.dart';
import 'package:chanora_flutter/widgets/input_dialogs.dart';
import 'package:chanora_flutter/widgets/ptt_capability_badge.dart';
void main() {
Widget localizedHarness(Widget child, {Locale? locale}) {
return MaterialApp(
locale: locale,
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(body: child),
);
}
testWidgets('connect actions stack on narrow mobile widths', (tester) async {
await tester.pumpWidget(
localizedHarness(
SizedBox(
width: 320,
child: ConnectForm(
hostCtl: TextEditingController(text: 'server.example.com'),
nickCtl: TextEditingController(text: 'MobileUser'),
passwordCtl: TextEditingController(),
onConnect: () {},
onAddBookmark: () {},
),
),
),
);
await tester.pumpAndSettle();
final connectTop = tester.getTopLeft(find.text('Connect')).dy;
final bookmarkTop = tester.getTopLeft(find.text('Save bookmark')).dy;
expect(bookmarkTop, greaterThan(connectTop + 44));
});
testWidgets('connect actions stack at standard phone content widths', (
tester,
) async {
await tester.pumpWidget(
localizedHarness(
SizedBox(
width: 361,
child: ConnectForm(
hostCtl: TextEditingController(text: 'server.example.com'),
nickCtl: TextEditingController(text: 'MobileUser'),
passwordCtl: TextEditingController(),
onConnect: () {},
onAddBookmark: () {},
),
),
),
);
await tester.pumpAndSettle();
final connectTop = tester.getTopLeft(find.text('Connect')).dy;
final bookmarkTop = tester.getTopLeft(find.text('Save bookmark')).dy;
expect(bookmarkTop, greaterThan(connectTop + 44));
});
testWidgets('disconnected mobile chrome keeps content near the safe area', (
tester,
) async {
const primaryContentKey = Key('primary-content');
await tester.binding.setSurfaceSize(const Size(393, 852));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
localizedHarness(
ChanoraMobileScaffold(
compactIdleChrome: true,
canDisconnect: false,
title: null,
actions: [
IconButton(
tooltip: 'About',
icon: const Icon(Icons.info_outline),
onPressed: () {},
),
],
onDisconnect: () {},
compactHeader: const SizedBox(height: 44, child: Text('Chanora')),
body: const KeyedSubtree(
key: primaryContentKey,
child: Text('Primary content'),
),
),
),
);
await tester.pumpAndSettle();
expect(find.byType(AppBar), findsNothing);
expect(tester.getTopLeft(find.byKey(primaryContentKey)).dy, lessThan(120));
});
testWidgets('PTT explanation sheet is scrollable for mobile text scaling', (
tester,
) async {
await tester.pumpWidget(
localizedHarness(
MediaQuery(
data: const MediaQueryData(textScaler: TextScaler.linear(1.8)),
child: const SizedBox(
width: 320,
child: PttCapabilityBadge(
level: 'L0Focused',
backendId: 'focused',
boundInputClass: 'keyboard',
),
),
),
),
);
await tester.tap(find.byIcon(Icons.info_outline));
await tester.pumpAndSettle();
expect(find.byType(SingleChildScrollView), findsWidgets);
expect(tester.takeException(), isNull);
});
testWidgets('PTT capture dialog wraps content for small screens', (
tester,
) async {
await tester.pumpWidget(
localizedHarness(
Builder(
builder: (context) {
return Center(
child: FilledButton(
onPressed: () => showDialog<void>(
context: context,
builder: (_) => const PttBindingCaptureDialog(),
),
child: const Text('Open'),
),
);
},
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(find.byType(SingleChildScrollView), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
@@ -1,9 +1,8 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
import 'package:chanora_flutter/services/ui_preferences_service.dart';
import 'package:chanora_flutter/src/rust/api.dart' as rust;
import 'package:chanora_flutter/widgets/snapshot_view.dart';
@@ -30,14 +29,12 @@ void main() {
required int id,
required int channelId,
required String name,
String uid = '',
bool speaking = false,
int talkPower = 0,
bool talkPowerGranted = false,
}) {
return rust.BridgeClient(
id: BigInt.from(id),
uid: uid,
channel: BigInt.from(channelId),
name: name,
inputMuted: false,
@@ -52,11 +49,14 @@ void main() {
Widget snapshotHarness({
required List<rust.BridgeChannel> channels,
required List<rust.BridgeClient> clients,
String welcomeMessage = '',
BigInt? ownClientId,
BigInt? currentVoiceChannelId,
rust.BridgeAudioStats? audioStats,
Map<String, ClientPlaybackPreference> clientPlaybackPreferences = const {},
ClientPlaybackPreferenceChanged? onClientPlaybackPreferenceChanged,
bool enableClientLongPressMenu = false,
ValueChanged<rust.BridgeClient>? onOpenClientInfo,
ValueChanged<rust.BridgeClient>? onOpenClientChat,
ValueChanged<rust.BridgeClient>? onOpenClientPoke,
}) {
return MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
@@ -65,7 +65,7 @@ void main() {
body: SnapshotView(
snapshot: rust.BridgeSnapshot(
serverName: 'Server',
welcomeMessage: '',
welcomeMessage: welcomeMessage,
platform: '',
version: '',
channels: channels,
@@ -81,8 +81,10 @@ void main() {
canJoinVoiceChannel: true,
onJoinChannel: (_) {},
onJoinChannelWithPassword: (_) {},
clientPlaybackPreferences: clientPlaybackPreferences,
onClientPlaybackPreferenceChanged: onClientPlaybackPreferenceChanged,
enableClientLongPressMenu: enableClientLongPressMenu,
onOpenClientInfo: onOpenClientInfo,
onOpenClientChat: onOpenClientChat,
onOpenClientPoke: onOpenClientPoke,
),
),
);
@@ -197,6 +199,27 @@ void main() {
expect(childUserX - childX, inInclusiveRange(22, 28));
});
testWidgets('server welcome starts collapsed above channel tree', (
tester,
) async {
await tester.pumpWidget(
snapshotHarness(
welcomeMessage:
'This is a long server welcome that should not push channels '
'below the first connected screen.',
channels: [channel(id: 1, name: 'Default Channel')],
clients: [client(id: 100, channelId: 1, name: 'Alice')],
),
);
await tester.pumpAndSettle();
expect(find.text('Server welcome message'), findsOneWidget);
expect(find.textContaining('long server welcome'), findsNothing);
expect(find.text('Default Channel'), findsOneWidget);
expect(find.text('Alice'), findsOneWidget);
});
testWidgets('password channel shows lock at row end', (tester) async {
await tester.pumpWidget(
snapshotHarness(
@@ -242,6 +265,219 @@ void main() {
expect(userHighlight.decoration, isNull);
});
testWidgets('long press opens client menu for other users', (tester) async {
rust.BridgeClient? chatClient;
rust.BridgeClient? pokeClient;
await tester.pumpWidget(
snapshotHarness(
channels: [channel(id: 1, name: 'Default Channel')],
clients: [
client(id: 100, channelId: 1, name: 'Alice'),
client(id: 101, channelId: 1, name: 'Bob'),
],
ownClientId: BigInt.from(100),
enableClientLongPressMenu: true,
onOpenClientChat: (client) => chatClient = client,
onOpenClientPoke: (client) => pokeClient = client,
),
);
await tester.pumpAndSettle();
await tester.longPress(find.text('Bob'));
await tester.pumpAndSettle();
expect(find.text('Private message'), findsOneWidget);
expect(find.text('Poke'), findsOneWidget);
await tester.tap(find.text('Private message'));
await tester.pumpAndSettle();
expect(chatClient?.id, BigInt.from(101));
expect(chatClient?.name, 'Bob');
expect(pokeClient, isNull);
});
testWidgets('long press is ignored when mobile client menus are disabled', (
tester,
) async {
await tester.pumpWidget(
snapshotHarness(
channels: [channel(id: 1, name: 'Default Channel')],
clients: [
client(id: 100, channelId: 1, name: 'Alice'),
client(id: 101, channelId: 1, name: 'Bob'),
],
ownClientId: BigInt.from(100),
enableClientLongPressMenu: false,
onOpenClientChat: (_) {},
onOpenClientPoke: (_) {},
),
);
await tester.pumpAndSettle();
await tester.longPress(find.text('Bob'));
await tester.pumpAndSettle();
expect(find.text('Private message'), findsNothing);
expect(find.text('Poke'), findsNothing);
});
testWidgets('secondary click opens client menu for other users', (
tester,
) async {
rust.BridgeClient? chatClient;
await tester.pumpWidget(
snapshotHarness(
channels: [channel(id: 1, name: 'Default Channel')],
clients: [
client(id: 100, channelId: 1, name: 'Alice'),
client(id: 101, channelId: 1, name: 'Bob'),
],
ownClientId: BigInt.from(100),
onOpenClientChat: (client) => chatClient = client,
),
);
await tester.pumpAndSettle();
await tester.tap(
find.text('Bob'),
buttons: kSecondaryMouseButton,
kind: PointerDeviceKind.mouse,
);
await tester.pumpAndSettle();
expect(find.text('Private message'), findsOneWidget);
await tester.tap(find.text('Private message'));
await tester.pumpAndSettle();
expect(chatClient?.id, BigInt.from(101));
});
testWidgets('client menu poke action targets selected other user', (
tester,
) async {
rust.BridgeClient? pokeClient;
await tester.pumpWidget(
snapshotHarness(
channels: [channel(id: 1, name: 'Default Channel')],
clients: [
client(id: 100, channelId: 1, name: 'Alice'),
client(id: 101, channelId: 1, name: 'Bob'),
],
ownClientId: BigInt.from(100),
enableClientLongPressMenu: true,
onOpenClientPoke: (client) => pokeClient = client,
),
);
await tester.pumpAndSettle();
await tester.longPress(find.text('Bob'));
await tester.pumpAndSettle();
await tester.tap(find.text('Poke'));
await tester.pumpAndSettle();
expect(pokeClient?.id, BigInt.from(101));
expect(pokeClient?.name, 'Bob');
expect(find.text('Poke'), findsNothing);
});
testWidgets('client menu info action targets selected other user', (
tester,
) async {
rust.BridgeClient? infoClient;
await tester.pumpWidget(
snapshotHarness(
channels: [channel(id: 1, name: 'Default Channel')],
clients: [
client(id: 100, channelId: 1, name: 'Alice'),
client(id: 101, channelId: 1, name: 'Bob'),
],
ownClientId: BigInt.from(100),
enableClientLongPressMenu: true,
onOpenClientInfo: (client) => infoClient = client,
),
);
await tester.pumpAndSettle();
await tester.longPress(find.text('Bob'));
await tester.pumpAndSettle();
expect(find.text('Info'), findsOneWidget);
await tester.tap(find.text('Info'));
await tester.pumpAndSettle();
expect(infoClient?.id, BigInt.from(101));
expect(infoClient?.name, 'Bob');
});
testWidgets('client menu info action is available for self', (tester) async {
rust.BridgeClient? infoClient;
rust.BridgeClient? chatClient;
rust.BridgeClient? pokeClient;
await tester.pumpWidget(
snapshotHarness(
channels: [channel(id: 1, name: 'Default Channel')],
clients: [
client(id: 100, channelId: 1, name: 'Alice'),
client(id: 101, channelId: 1, name: 'Bob'),
],
ownClientId: BigInt.from(100),
enableClientLongPressMenu: true,
onOpenClientInfo: (client) => infoClient = client,
onOpenClientChat: (client) => chatClient = client,
onOpenClientPoke: (client) => pokeClient = client,
),
);
await tester.pumpAndSettle();
await tester.longPress(find.text('Alice'));
await tester.pumpAndSettle();
expect(find.text('Info'), findsOneWidget);
expect(find.text('Private message'), findsNothing);
expect(find.text('Poke'), findsNothing);
await tester.tap(find.text('Info'));
await tester.pumpAndSettle();
expect(infoClient?.id, BigInt.from(100));
expect(infoClient?.name, 'Alice');
expect(chatClient, isNull);
expect(pokeClient, isNull);
});
testWidgets('self menu is hidden when only peer actions are available', (
tester,
) async {
await tester.pumpWidget(
snapshotHarness(
channels: [channel(id: 1, name: 'Default Channel')],
clients: [
client(id: 100, channelId: 1, name: 'Alice'),
client(id: 101, channelId: 1, name: 'Bob'),
],
ownClientId: BigInt.from(100),
enableClientLongPressMenu: true,
onOpenClientChat: (_) {},
onOpenClientPoke: (_) {},
),
);
await tester.pumpAndSettle();
await tester.longPress(find.text('Alice'));
await tester.pumpAndSettle();
expect(find.text('Private message'), findsNothing);
expect(find.text('Poke'), findsNothing);
});
testWidgets(
'local voice activity does not light speaking state when blocked',
(tester) async {
@@ -270,114 +506,6 @@ void main() {
},
);
testWidgets('remote client row does not show playback ellipsis', (
tester,
) async {
await tester.pumpWidget(
snapshotHarness(
channels: [channel(id: 1, name: 'Default Channel')],
clients: [
client(id: 100, channelId: 1, name: 'Self', uid: 'self'),
client(id: 101, channelId: 1, name: 'Bob', uid: 'user-b'),
],
ownClientId: BigInt.from(100),
clientPlaybackPreferences: const {
'user-b': ClientPlaybackPreference(volume: 0.5, muted: false),
},
onClientPlaybackPreferenceChanged: (_, _) async {},
),
);
await tester.pumpAndSettle();
expect(find.byIcon(Icons.more_horiz), findsNothing);
});
testWidgets('remote client long press opens playback menu', (tester) async {
await tester.pumpWidget(
snapshotHarness(
channels: [channel(id: 1, name: 'Default Channel')],
clients: [
client(id: 100, channelId: 1, name: 'Self', uid: 'self'),
client(id: 101, channelId: 1, name: 'Bob', uid: 'user-b'),
],
ownClientId: BigInt.from(100),
onClientPlaybackPreferenceChanged: (_, _) async {},
),
);
await tester.pumpAndSettle();
await tester.longPress(find.widgetWithText(ListTile, 'Bob'));
await tester.pumpAndSettle();
expect(find.text('Mute playback'), findsOneWidget);
expect(find.text('Volume 100%'), findsOneWidget);
});
testWidgets('remote client secondary click opens playback menu', (
tester,
) async {
await tester.pumpWidget(
snapshotHarness(
channels: [channel(id: 1, name: 'Default Channel')],
clients: [
client(id: 100, channelId: 1, name: 'Self', uid: 'self'),
client(id: 101, channelId: 1, name: 'Bob', uid: 'user-b'),
],
ownClientId: BigInt.from(100),
onClientPlaybackPreferenceChanged: (_, _) async {},
),
);
await tester.pumpAndSettle();
await tester.tap(
find.widgetWithText(ListTile, 'Bob'),
buttons: kSecondaryButton,
);
await tester.pumpAndSettle();
expect(find.text('Mute playback'), findsOneWidget);
expect(find.text('Volume 100%'), findsOneWidget);
});
testWidgets('client playback menu forwards mute and volume changes', (
tester,
) async {
final changes = <ClientPlaybackPreference>[];
await tester.pumpWidget(
snapshotHarness(
channels: [channel(id: 1, name: 'Default Channel')],
clients: [
client(id: 100, channelId: 1, name: 'Self', uid: 'self'),
client(id: 101, channelId: 1, name: 'Bob', uid: 'user-b'),
],
ownClientId: BigInt.from(100),
onClientPlaybackPreferenceChanged: (_, preference) async {
changes.add(preference);
},
),
);
await tester.pumpAndSettle();
await tester.longPress(find.widgetWithText(ListTile, 'Bob'));
await tester.pumpAndSettle();
await tester.tap(find.text('Mute playback'));
await tester.pumpAndSettle();
expect(changes, isNotEmpty);
expect(changes.last.muted, isTrue);
await tester.longPress(find.widgetWithText(ListTile, 'Bob'));
await tester.pumpAndSettle();
await tester.tap(find.text('Volume 200%'), warnIfMissed: false);
await tester.pumpAndSettle();
expect(changes.last.volume, 2.0);
expect(changes.last.muted, isFalse);
});
testWidgets('spacer channels render as layout rows and keep channel taps', (
tester,
) async {
@@ -1,67 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/services/startup_dependency_check.dart';
import 'package:chanora_flutter/widgets/startup_dependency_screen.dart';
void main() {
testWidgets('startup gate shows install-help screen and can continue', (
tester,
) async {
tester.view.physicalSize = const Size(1200, 1800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final result = StartupDependencyCheckResult(
platformLabel: 'Fedora',
issues: const [
StartupDependencyIssue(
id: 'linux-pipewire-runtime',
title: 'PipeWire runtime is missing',
summary: 'Primary Linux audio needs PipeWire.',
details: ['Install PipeWire and recheck.'],
severity: StartupDependencySeverity.recommended,
installHints: [
StartupInstallHint(
label: 'Release downloads',
command: 'https://github.com/microsoft/onnxruntime/releases',
),
StartupInstallHint(
label: 'Fedora',
command: 'sudo dnf install pipewire-libs',
),
],
),
],
);
final loggedResults = <StartupDependencyCheckResult>[];
await tester.pumpWidget(
MaterialApp(
home: StartupDependencyGate(
checker: () async => result,
logger: (value) async {
loggedResults.add(value);
},
child: const Scaffold(body: Text('ready')),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Finish Linux setup'), findsOneWidget);
expect(find.text('PipeWire runtime is missing'), findsOneWidget);
expect(find.text('Continue anyway'), findsOneWidget);
expect(find.text('Open'), findsOneWidget);
expect(find.text('Copy'), findsOneWidget);
expect(loggedResults, [result]);
await tester.ensureVisible(find.text('Continue anyway'));
await tester.tap(find.text('Continue anyway'));
await tester.pumpAndSettle();
expect(find.text('ready'), findsOneWidget);
});
}
@@ -24,29 +24,12 @@ void main() {
]);
});
test('desktop VAD segments expose both Silero and WebRTC', () {
test('desktop VAD segments expose Silero as the primary backend', () {
expect(desktopVadBackendSegments.map((s) => s.value), [
rust.BridgeVadBackend.webrtcVad,
rust.BridgeVadBackend.sileroOnnx,
]);
});
test('desktop VAD segments disable Silero when ONNX Runtime is missing', () {
final segments = vadBackendSegmentsForAvailability(
desktop: true,
onnxRuntimeAvailable: false,
);
final silero = segments.singleWhere(
(segment) => segment.value == rust.BridgeVadBackend.sileroOnnx,
);
final webrtc = segments.singleWhere(
(segment) => segment.value == rust.BridgeVadBackend.webrtcVad,
);
expect(silero.enabled, isFalse);
expect(webrtc.enabled, isTrue);
});
testWidgets('shared segmented style applies compact visual density', (
tester,
) async {
+2
View File
@@ -15,6 +15,7 @@ chanora_state = { path = "../../crates/chanora_state" }
chanora_audio = { path = "../../crates/chanora_audio" }
chanora_storage = { path = "../../crates/chanora_storage" }
chanora_diagnostics = { path = "../../crates/chanora_diagnostics" }
chanora_server_prefetch = { path = "../../crates/chanora_server_prefetch" }
thiserror.workspace = true
tracing.workspace = true
tokio = { version = "1", features = ["sync", "rt", "macros"] }
@@ -22,5 +23,6 @@ tokio = { version = "1", features = ["sync", "rt", "macros"] }
[dev-dependencies]
# Used by integration tests to inspect the bookmark DB row layout
# without going through the public repository API.
chanora_server_prefetch = { path = "../../crates/chanora_server_prefetch", features = ["test-support"] }
rusqlite = { version = "0.32", features = ["bundled"] }
tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread"] }
+182 -9
View File
@@ -46,6 +46,7 @@ use tokio::sync::{broadcast, oneshot, watch, Mutex};
use tokio::task::JoinHandle;
use tracing::{info, warn};
use chanora_server_prefetch::ServerPrefetcher;
use chanora_state::channel_join::{
self, AuthoritativeSource, ChannelId as JoinChannelId, ChannelJoinEvent, ChannelJoinState,
ConnectionEpoch, JoinFailureKind,
@@ -64,8 +65,8 @@ pub use chanora_diagnostics::{
RedactingLogLayer, Redactor, DEFAULT_LOG_CAPACITY,
};
pub use chanora_protocol::{
ChannelInfo, ChatMessage, ClientInfo, ConnectConfig, DisconnectReason, MessageTarget,
ProtocolError, ServerActivity, ServerSnapshot,
ChannelInfo, ChatMessage, ClientInfo, ClientProfile, ConnectConfig, DisconnectReason,
MessageTarget, ProtocolError, ServerActivity, ServerSnapshot,
};
pub use chanora_storage::{Bookmark, BookmarkRepository, IdentityFileStore};
@@ -431,6 +432,9 @@ pub struct ChanoraSession {
/// extension). Lives alongside the identity file. Wired by
/// [`Self::init_storage`].
bookmark_store: Arc<Mutex<Option<BookmarkRepository>>>,
/// Invisible server-address prefetch cache. Warmed by Flutter typing
/// but validated by Rust before Connect can reuse it.
server_prefetch: ServerPrefetcher,
/// Single transmit-mode selector for the whole session
/// lifetime (SAD-083). Re-wired to a fresh
/// [`AudioTransmitGate`] every time the audio engine starts;
@@ -483,6 +487,7 @@ impl ChanoraSession {
network_tx,
identity_store: Arc::new(Mutex::new(None)),
bookmark_store: Arc::new(Mutex::new(None)),
server_prefetch: ServerPrefetcher::new(),
voice_selector: selector,
release_tail,
pending_binding: Arc::new(Mutex::new(None)),
@@ -628,6 +633,38 @@ impl ChanoraSession {
self.events_tx.subscribe()
}
/// Warm server-address resolution for a later user-initiated connect.
pub async fn prefetch_server(&self, host: String) -> Result<(), CoreError> {
let host = host.trim().to_lowercase();
if let Err(err) = self.server_prefetch.prefetch(host.clone()).await {
warn!(
target: "chanora_core",
host = %host,
error = %err,
"server prefetch setup failed"
);
}
Ok(())
}
async fn apply_prefetched_resolution(&self, cfg: &mut ConnectConfig) {
cfg.resolved_address = None;
let host = cfg.address.trim();
if host.is_empty() {
return;
}
if let Some(addr) = self.server_prefetch.fresh_match(host).await {
cfg.resolved_address = Some(addr);
}
}
async fn prepare_connect_configs(&self, cfg: ConnectConfig) -> (ConnectConfig, ConnectConfig) {
let stored_cfg = connect_config_without_prefetched_resolution(&cfg);
let mut dial_cfg = stored_cfg.clone();
self.apply_prefetched_resolution(&mut dial_cfg).await;
(stored_cfg, dial_cfg)
}
/// Connect to a server. Fails with [`CoreError::AlreadyConnected`]
/// if a connection is already active (DEC-006). Audio is not
/// started automatically; call [`Self::start_audio`] after.
@@ -679,7 +716,9 @@ impl ChanoraSession {
}
}
let client = chanora_protocol::ProtocolClient::connect(cfg.clone()).await?;
let (cfg, dial_cfg) = self.prepare_connect_configs(cfg).await;
let client = chanora_protocol::ProtocolClient::connect(dial_cfg).await?;
let snap = client.snapshot().await?;
let epoch = self.allocate_connection_epoch().await;
let mut join_state = ChannelJoinState::new(epoch);
@@ -896,6 +935,13 @@ impl ChanoraSession {
Ok(snap)
}
/// Fetch richer profile and live connection details for one online client.
pub async fn client_profile(&self, client_id: u64) -> Result<ClientProfile, CoreError> {
let guard = self.inner.lock().await;
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
Ok(state.protocol.client_profile(client_id).await?)
}
/// True if a connection is currently active.
pub async fn is_connected(&self) -> bool {
self.inner.lock().await.is_some()
@@ -928,6 +974,12 @@ impl ChanoraSession {
rec.drain()
}
/// Snapshot recorded protocol events without clearing them.
pub async fn protocol_events_snapshot(&self) -> Vec<String> {
let rec = self.event_recorder.lock().await;
rec.snapshot()
}
/// Record a platform lifecycle event (SRS-138).
pub async fn record_lifecycle_event(&self, state: &str) {
let mut rec = self.event_recorder.lock().await;
@@ -1234,11 +1286,11 @@ impl ChanoraSession {
audio.set_output_muted(muted);
}
}
// Muting the speaker is treated as a local deafen: it must
// also clamp microphone TX so users cannot keep talking while
// unable to hear replies. This does not send server-side
// input-mute; it only drives the local transmit gate.
let mic_disabled = state.local_input_muted || state.local_output_muted;
// Input mute must also stop local outbound voice production
// so the transmit selector stays in sync with the server-side
// mic mute. Output mute is playback-only and must not affect
// the mic gate.
let mic_disabled = state.local_input_muted;
self.voice_selector.set_hard_mute(mic_disabled);
Ok(())
}
@@ -1323,6 +1375,19 @@ impl ChanoraSession {
Ok(audio.audio_processing_stats())
}
/// Best-effort audio-processing diagnostics for user exports.
///
/// This intentionally never waits for the connection mutex. During
/// server join the connect path owns that mutex while awaiting network
/// I/O; diagnostics must stay responsive and omit live audio stats
/// rather than blocking the UI thread.
pub fn audio_processing_stats_if_ready(&self) -> Option<AudioProcessingStats> {
let guard = self.inner.try_lock().ok()?;
let state = guard.as_ref()?;
let audio = state.audio.as_ref()?;
Some(audio.audio_processing_stats())
}
/// Configure the preferred Silero ONNX VAD model path.
///
/// This does not require an active connection. Running iOS audio
@@ -2367,6 +2432,12 @@ fn map_join_error_code(code: channel_join::JoinErrorCode) -> VoiceJoinErrorCode
}
}
fn connect_config_without_prefetched_resolution(cfg: &ConnectConfig) -> ConnectConfig {
let mut cfg = cfg.clone();
cfg.resolved_address = None;
cfg
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2376,6 +2447,109 @@ mod tests {
let _ = ChanoraSession::new();
}
#[test]
fn audio_stats_nonblocking_under_lock() {
let s = ChanoraSession::new();
let _guard = s.inner.try_lock().unwrap();
assert!(s.audio_processing_stats_if_ready().is_none());
}
#[test]
fn audio_stats_absent_when_disconnected() {
let s = ChanoraSession::new();
assert!(s.audio_processing_stats_if_ready().is_none());
}
#[tokio::test]
async fn connect_uses_fresh_prefetch() {
let session = ChanoraSession::new();
let addr: std::net::SocketAddr = "127.0.0.1:9987".parse().unwrap();
let generation = session.server_prefetch.begin_for_test("example.com").await;
session
.server_prefetch
.store_success_for_test(generation, "example.com", addr, std::time::Instant::now())
.await;
let mut cfg = ConnectConfig::default();
cfg.address = " example.com ".to_string();
session.apply_prefetched_resolution(&mut cfg).await;
assert_eq!(cfg.resolved_address, Some(addr));
}
#[tokio::test]
async fn connect_clears_untrusted_resolved_address() {
let session = ChanoraSession::new();
let mut cfg = ConnectConfig::default();
cfg.address = "example.com".to_string();
cfg.resolved_address = Some("127.0.0.1:9987".parse().unwrap());
session.apply_prefetched_resolution(&mut cfg).await;
assert_eq!(cfg.resolved_address, None);
}
#[tokio::test]
async fn prefetch_swallows_setup_errors() {
let session = ChanoraSession::new();
session
.server_prefetch
.fail_next_prefetch_setup_for_test("resolver unavailable")
.await;
let result = session
.prefetch_server(" Example.COM ".to_string())
.await;
assert!(result.is_ok());
}
#[test]
fn sanitize_config_clears_resolved_address() {
let addr: std::net::SocketAddr = "127.0.0.1:9987".parse().unwrap();
let mut cfg = ConnectConfig::default();
cfg.address = "example.com".to_string();
cfg.nickname = "tester".to_string();
cfg.password = Some("secret".to_string());
cfg.identity = Some("identity".to_string());
cfg.ready_timeout = std::time::Duration::from_secs(42);
cfg.resolved_address = Some(addr);
let cleaned = connect_config_without_prefetched_resolution(&cfg);
assert_eq!(cleaned.address, cfg.address);
assert_eq!(cleaned.nickname, cfg.nickname);
assert_eq!(cleaned.password, cfg.password);
assert_eq!(cleaned.identity, cfg.identity);
assert_eq!(cleaned.ready_timeout, cfg.ready_timeout);
assert_eq!(cleaned.resolved_address, None);
}
#[tokio::test]
async fn prepare_configs_sanitizes_stored_keeps_dial() {
let session = ChanoraSession::new();
let cached: std::net::SocketAddr = "127.0.0.1:9987".parse().unwrap();
let untrusted: std::net::SocketAddr = "127.0.0.2:9987".parse().unwrap();
let generation = session.server_prefetch.begin_for_test("example.com").await;
session
.server_prefetch
.store_success_for_test(generation, "example.com", cached, std::time::Instant::now())
.await;
let mut cfg = ConnectConfig::default();
cfg.address = "example.com".to_string();
cfg.resolved_address = Some(untrusted);
let (stored_cfg, dial_cfg) = session.prepare_connect_configs(cfg).await;
assert_eq!(stored_cfg.resolved_address, None);
assert_eq!(dial_cfg.resolved_address, Some(cached));
}
#[tokio::test]
async fn disconnect_when_not_connected_is_noop() {
let s = ChanoraSession::new();
@@ -2411,7 +2585,6 @@ mod tests {
],
clients: vec![ClientInfo {
id: chanora_protocol::ClientId(10),
uid: "uid".into(),
channel: chanora_protocol::ChannelId(1),
name: "u".into(),
input_muted: false,
+1
View File
@@ -18,6 +18,7 @@ async fn alpha_smoke() {
password: None,
identity: None,
ready_timeout: Duration::from_secs(15),
resolved_address: None,
};
let snap = s.connect(cfg).await.expect("connect");
println!(
+36 -28
View File
@@ -9,10 +9,6 @@ license.workspace = true
repository.workspace = true
publish.workspace = true
[features]
default = []
bench-harness = ["dep:criterion", "dep:dhat", "dep:serde_json"]
[dependencies]
chanora_protocol = { path = "../chanora_protocol" }
thiserror.workspace = true
@@ -33,13 +29,10 @@ audiopus = "0.3.0-rc.0"
tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", default-features = false, features = ["audio"] }
tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
rustfft = "6.2.0"
criterion = { version = "0.5", optional = true }
dhat = { version = "0.3", optional = true }
serde_json = { version = "1", optional = true }
[target.'cfg(all(not(target_os = "android"), not(target_os = "ios"), not(target_os = "macos")))'.dependencies]
# Desktop audio I/O for Windows capture/playback and Linux fallback metadata.
# Linux voice I/O uses PipeWire first and PulseAudio as fallback; Apple platforms use direct VoiceProcessingIO
# Desktop audio I/O for Windows capture/playback and Linux capture.
# Linux playback uses SDL2; Apple platforms use direct VoiceProcessingIO
# AudioUnits via `coreaudio-rs` for the voice path.
cpal = "0.17.3"
@@ -84,7 +77,7 @@ ndk-context = "0.1"
# - get_raw_session_id() for JNI hardware effect binding
# - deduplicated macro impls
# - PowerSavingOffloaded PerformanceMode variant
oboe = { path = "../../../oboe-rs" }
oboe = { git = "https://github.com/EdisonJwa/oboe-rs", rev = "a14f9b83ecea8c93f5a692f2ee7808445b938c35" }
[target.'cfg(target_os = "windows")'.dependencies]
# Real Windows global PTT (SDD-083 / SDD-084): RegisterRawInputDevices
@@ -108,33 +101,34 @@ tokio = { version = "1", features = ["sync", "rt", "macros", "time", "test-util"
# Cross-platform recording Layer for the SDD-090 / DEC-027 privacy
# invariant integration test (`tests/ptt_privacy.rs`).
tracing-subscriber = { version = "0.3", features = ["registry"] }
# SDD-120 §3 — criterion bench harness (realtime_capture / opus_codec /
# resampler). `harness = false` per bench entry below disables the
# default libtest harness so criterion can install its own.
criterion = "0.5"
# SDD-120 §3 item 1 — dhat is used as the global allocator inside
# `benches/realtime_capture.rs` to count post-warmup heap allocations
# on the realtime capture path. Dev-dep only — does NOT affect
# production builds.
dhat = "0.3"
# SDD-120 §5 / §8 — JSON serialization for `emit_baseline` /
# `compare_baseline` binaries that consume criterion's per-bench
# `estimates.json` outputs and emit the SRS-217 baseline schema.
serde_json = "1"
[[bench]]
name = "realtime_capture"
harness = false
path = "benches/realtime_capture.rs"
required-features = ["bench-harness"]
[[bench]]
name = "opus_codec"
harness = false
path = "benches/opus_codec.rs"
required-features = ["bench-harness"]
[[bench]]
name = "resampler"
harness = false
path = "benches/resampler.rs"
required-features = ["bench-harness"]
[[example]]
name = "compare_baseline"
path = "examples/compare_baseline.rs"
required-features = ["bench-harness"]
[[example]]
name = "emit_baseline"
path = "examples/emit_baseline.rs"
required-features = ["bench-harness"]
[target.'cfg(target_os = "linux")'.dependencies]
# GNOME-on-Wayland Global Push-to-Talk uses the freedesktop
@@ -154,8 +148,22 @@ futures-util = { version = "0.3", default-features = false, features = ["std"] }
# options. The portal recommends fresh tokens to scope its own
# object paths per call.
rand = "0.8"
# Linux-native voice I/O. PipeWire is primary on modern desktops;
# PulseAudio is retained as fallback and for older environments.
pipewire = { version = "0.10", features = ["v0_3_44"] }
libpulse-binding = "2.30"
libpulse-simple-binding = "2.29"
# SDL2 audio for Linux. Replaces the cpal playback path on Linux only;
# cpal stays in use for Linux capture and Windows capture/playback.
# Apple platforms use direct VoiceProcessingIO AudioUnits. Rationale: the
# cpal Linux backend opens raw ALSA `default`, which on most Arch
# / Fedora / Debian installs routes through `dmix` + `plug` with
# nearest-neighbour resampling and very small period sizes — the
# combination produces audible crackling/popping. SDL2 on the same
# systems routes through PipeWire's PulseAudio compat bridge (or
# real PulseAudio), both of which carry a high-quality resampler
# and a sensible default period. The upstream tsclientlib audio
# example (`tsclientlib/examples/audio_utils/ts_to_audio.rs`) and
# the official Qint client both use SDL2 in exactly this shape;
# this dep brings Chanora in line with that pattern.
#
# `bundled` is OFF deliberately — we link against the system
# libSDL2.so. Arch ships `sdl2-compat`; Debian/Ubuntu ship
# `libsdl2-2.0-0`; Fedora ships `SDL2`. The chanora-flutter Linux
# build documentation lists this as a runtime dependency.
sdl2 = { version = "0.37", default-features = false }
+82 -261
View File
@@ -5,35 +5,28 @@
//! and stopped before disconnect. It does not retry on device
//! change.
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
#[cfg(target_os = "linux")]
use cpal::traits::{DeviceTrait, HostTrait};
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
use cpal::{SampleFormat, SizedSample};
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
use std::collections::hash_map::DefaultHasher;
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
@@ -110,7 +103,6 @@ pub struct AudioDeviceInfo {
}
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
@@ -120,7 +112,6 @@ fn desktop_device_id(device: &cpal::Device) -> Option<String> {
}
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
@@ -132,7 +123,6 @@ fn short_device_id(id: &str) -> String {
}
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
@@ -188,53 +178,29 @@ pub fn list_audio_devices() -> AudioDeviceList {
output_devices: Vec::new(),
};
let host = cpal::default_host();
#[cfg(target_os = "linux")]
{
let default_input_name = host.default_input_device().and_then(|device| {
device
.description()
.ok()
.map(|description| description.name().trim().to_owned())
.filter(|name| !name.is_empty())
});
let default_output_name = host.default_output_device().and_then(|device| {
device
.description()
.ok()
.map(|description| description.name().trim().to_owned())
.filter(|name| !name.is_empty())
});
list.input_devices =
crate::linux_pipewire_input::list_input_devices(default_input_name.as_deref());
list.output_devices =
crate::linux_pipewire_input::list_output_devices(default_output_name.as_deref());
}
#[cfg(not(target_os = "linux"))]
{
let default_in = host
.default_input_device()
.and_then(|device| desktop_device_id(&device));
if let Ok(devices) = host.input_devices() {
for d in devices {
if let Some(mut device) = describe_device(&d) {
device.is_default = default_in
.as_ref()
.is_some_and(|default_id| default_id == &device.id);
list.input_devices.push(device);
}
let default_in = host
.default_input_device()
.and_then(|device| desktop_device_id(&device));
let default_out = host
.default_output_device()
.and_then(|device| desktop_device_id(&device));
if let Ok(devices) = host.input_devices() {
for d in devices {
if let Some(mut device) = describe_device(&d) {
device.is_default = default_in
.as_ref()
.is_some_and(|default_id| default_id == &device.id);
list.input_devices.push(device);
}
}
let default_out = host
.default_output_device()
.and_then(|device| desktop_device_id(&device));
if let Ok(devices) = host.output_devices() {
for d in devices {
if let Some(mut device) = describe_device(&d) {
device.is_default = default_out
.as_ref()
.is_some_and(|default_id| default_id == &device.id);
list.output_devices.push(device);
}
}
if let Ok(devices) = host.output_devices() {
for d in devices {
if let Some(mut device) = describe_device(&d) {
device.is_default = default_out
.as_ref()
.is_some_and(|default_id| default_id == &device.id);
list.output_devices.push(device);
}
}
}
@@ -340,23 +306,31 @@ pub struct AudioEngine {
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
client_volume_overrides: Arc<Mutex<HashMap<SessionAudioId, f32>>>,
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
voice_out_tx: mpsc::Sender<OutPacket>,
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
mic_gain: f32,
// Streams must be dropped to stop audio. Linux owns native
// PipeWire/PulseAudio threads; Windows owns cpal streams. On iOS both
// sides collapse into a single `IosVoiceUnit` (one VoiceProcessingIO
// AudioUnit hosts mic + speaker).
#[cfg(target_os = "linux")]
_input_stream: Mutex<Option<crate::linux_pipewire_input::LinuxInput>>,
#[cfg(target_os = "windows")]
#[cfg(any(target_os = "ios", target_os = "macos"))]
// Streams must be dropped to stop audio. Both are `!Send` because
// cpal's Stream isn't Send on some backends; we keep them in an
// Option wrapped by Mutex so stop() can move them out. On Linux
// the output side is `crate::sdl_output::SdlOutput` instead of a
// cpal Stream (see the SDD note inside `sdl_output.rs`); the
// same unsafe Send/Sync impl below covers both. On iOS both
// sides collapse into a single `IosVoiceUnit` (one
// VoiceProcessingIO AudioUnit hosts mic + speaker) — cpal is
// unused on iOS for the reasons documented in
// `ios_voice_unit.rs`.
#[cfg(all(
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
_input_stream: Mutex<Option<cpal::Stream>>,
#[cfg(target_os = "linux")]
_output_stream: Mutex<Option<crate::linux_pipewire_input::LinuxOutput>>,
_output_stream: Mutex<Option<crate::sdl_output::SdlOutput>>,
#[cfg(all(
not(target_os = "linux"),
not(target_os = "ios"),
@@ -483,31 +457,6 @@ fn open_ios_voice_backend(
Ok(IosVoiceBackend::Vpio(unit))
}
fn apply_saved_client_volume(
audio_handler: &mut AudioHandler<SessionAudioId>,
client_volume_overrides: &Mutex<HashMap<SessionAudioId, f32>>,
client_id: SessionAudioId,
) {
let override_volume = match client_volume_overrides.lock() {
Ok(overrides) => overrides.get(&client_id).copied(),
Err(error) => {
tracing::warn!(
target: "chanora_audio",
client_id = client_id.0,
error = %error,
"client volume overrides lock poisoned — saved volume not applied"
);
None
}
};
if let Some(volume) = override_volume {
if let Some(queue) = audio_handler.get_mut_queues().get_mut(&client_id) {
queue.volume = volume;
}
}
}
impl AudioEngine {
#[cfg(target_os = "android")]
fn spawn_android_backend_event_task(
@@ -743,7 +692,7 @@ impl AudioEngine {
Self::start_with_gate_cpal(cfg, voice_out_tx, voice_in_rx, transmit_gate)
}
/// Non-Apple/non-Android implementation: native Linux voice I/O or cpal on Windows.
/// Non-Apple/non-Android implementation: cpal capture + (cpal | SDL2) output.
/// Kept as a separate function so the Apple and Android paths can
/// short-circuit at the top of `start_with_gate` without dragging a 200-line
/// cfg-gated block. Body is the pre-iOS-port code, unchanged
@@ -762,16 +711,7 @@ impl AudioEngine {
"starting audio engine: cpal host selected"
);
fn device_name(device: &cpal::Device) -> Option<String> {
device
.description()
.ok()
.map(|description| description.name().trim().to_owned())
.filter(|name| !name.is_empty())
}
/// Helper: find a device by stable id, falling back to default.
#[cfg(not(target_os = "linux"))]
fn find_device<DefaultFn, AllFn, Devices>(
host: &cpal::Host,
default_fn: DefaultFn,
@@ -795,30 +735,28 @@ impl AudioEngine {
default_fn(host)
}
#[cfg(target_os = "linux")]
let in_dev = host.default_input_device();
#[cfg(not(target_os = "linux"))]
let in_dev = find_device(
&host,
cpal::Host::default_input_device,
cpal::Host::input_devices,
cfg.input_device_id.as_deref(),
);
)
.ok_or(AudioError::NoInputDevice)?;
#[cfg(target_os = "linux")]
let out_dev = host.default_output_device();
#[cfg(not(target_os = "linux"))]
let out_dev = find_device(
&host,
cpal::Host::default_output_device,
cpal::Host::output_devices,
cfg.output_device_id.as_deref(),
);
)
.ok_or(AudioError::NoOutputDevice)?;
#[cfg(not(target_os = "linux"))]
let in_dev = in_dev.ok_or(AudioError::NoInputDevice)?;
#[cfg(not(target_os = "linux"))]
let out_dev = out_dev.ok_or(AudioError::NoOutputDevice)?;
info!(
target: "chanora_audio",
in_device = %in_dev.description().map(|d| d.name().to_owned()).unwrap_or_default(),
out_device = %out_dev.description().map(|d| d.name().to_owned()).unwrap_or_default(),
"starting audio engine"
);
// Log the cpal-reported default configs *before* trying to
// open streams, so a downstream stream-build failure can
@@ -830,29 +768,6 @@ impl AudioEngine {
// "Start audio button not work"). Promote what would
// otherwise be silent or laconic errors into structured
// log records the user can paste back.
#[cfg(target_os = "linux")]
if let Some(in_dev) = in_dev.as_ref() {
match in_dev.default_input_config() {
Ok(c) => info!(
target: "chanora_audio",
channels = c.channels(),
sample_rate = c.sample_rate(),
sample_format = ?c.sample_format(),
"default_input_config reported"
),
Err(e) => warn!(
target: "chanora_audio",
error = %e,
"default_input_config FAILED — capture will be disabled"
),
}
} else {
warn!(
target: "chanora_audio",
"cpal default_input_device unavailable; Linux PipeWire capture will be tried directly"
);
}
#[cfg(not(target_os = "linux"))]
match in_dev.default_input_config() {
Ok(c) => info!(
target: "chanora_audio",
@@ -867,24 +782,6 @@ impl AudioEngine {
"default_input_config FAILED — capture will be disabled"
),
}
#[cfg(target_os = "linux")]
if let Some(out_dev) = out_dev.as_ref() {
match out_dev.default_output_config() {
Ok(c) => info!(
target: "chanora_audio",
channels = c.channels(),
sample_rate = c.sample_rate(),
sample_format = ?c.sample_format(),
"default_output_config reported"
),
Err(e) => warn!(
target: "chanora_audio",
error = %e,
"default_output_config FAILED — output stream will fail to build"
),
}
}
#[cfg(not(target_os = "linux"))]
match out_dev.default_output_config() {
Ok(c) => info!(
target: "chanora_audio",
@@ -900,7 +797,6 @@ impl AudioEngine {
),
}
#[cfg(target_os = "windows")]
let transmit_flag_for_capture = transmit_gate.flag_arc();
let frames_sent = Arc::new(AtomicU32::new(0));
let frames_received = Arc::new(AtomicU32::new(0));
@@ -908,7 +804,6 @@ impl AudioEngine {
let output_muted = Arc::new(AtomicBool::new(false));
let audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::default()));
let audio_processing_stats = Arc::new(crate::SharedAudioProcessingStats::default());
let client_volume_overrides = Arc::new(Mutex::new(HashMap::new()));
// ---------- Capture ----------
// Capture is best-effort. If the platform default input
@@ -916,52 +811,24 @@ impl AudioEngine {
// headless null sources or for users who deny the mic
// permission) we log and continue — playback alone is
// still useful. PTT becomes a no-op in that case.
#[cfg(target_os = "linux")]
let capture_result = match crate::linux_pipewire_input::start_capture(
cfg.input_device_id.as_deref(),
in_dev.as_ref().and_then(device_name).as_deref(),
voice_out_tx.clone(),
transmit_gate.clone(),
frames_sent.clone(),
cfg.mic_gain,
) {
Ok((stream, device_info)) => Ok((Some(stream), true, device_info.name)),
Err(linux_error) => {
warn!(
target: "chanora_audio",
error = %linux_error,
"Linux native capture unavailable"
);
Err(linux_error)
}
};
#[cfg(target_os = "windows")]
let capture_result = try_open_capture(
&in_dev,
voice_out_tx,
transmit_flag_for_capture,
frames_sent.clone(),
cfg.mic_gain,
)
.map(|stream| {
(
Some(stream),
true,
device_name(&in_dev).unwrap_or_else(|| "cpal capture".to_string()),
)
});
let (input_stream, capture_active, capture_device_name) = match capture_result {
Ok((stream, active, device_name)) => (stream, active, device_name),
);
let (input_stream, capture_active) = match capture_result {
Ok(s) => (Some(s), true),
Err(e) => {
warn!(
target: "chanora_audio",
error = %e,
"capture stream unavailable; continuing with playback only"
);
(None, false, "<capture unavailable>".to_string())
(None, false)
}
};
#[cfg(target_os = "windows")]
if let Some(s) = &input_stream {
s.play()
.map_err(|e| AudioError::Backend(format!("input play: {e}")))?;
@@ -971,18 +838,21 @@ impl AudioEngine {
let audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> =
Arc::new(Mutex::new(AudioHandler::new()));
// Linux uses native PipeWire/PulseAudio playback. PipeWire is primary;
// PulseAudio is fallback and can also be selected explicitly by id.
// Linux uses SDL2 for output (Qint / upstream tsclientlib
// pattern). cpal's Linux backend opens raw ALSA which routes
// through `dmix`+`plug` and produces audible crackling /
// popping on the 48 kHz → device-rate step. SDL2 on the same
// box routes through PipeWire's PA bridge (or PulseAudio)
// whose resampler is high-quality. We keep cpal on Windows
// and macOS — both have native backends (WASAPI / CoreAudio)
// without this problem. See `crates/chanora_audio/src/sdl_output.rs`
// for the full rationale.
#[cfg(target_os = "linux")]
let (output_stream, output_device_info) = crate::linux_pipewire_input::start_output(
cfg.output_device_id.as_deref(),
out_dev.as_ref().and_then(device_name).as_deref(),
let output_stream = crate::sdl_output::SdlOutput::start(
audio_handler.clone(),
output_gain.clone(),
output_muted.clone(),
)?;
#[cfg(target_os = "linux")]
let output_device_name = output_device_info.name;
#[cfg(not(target_os = "linux"))]
let output_stream = {
@@ -1057,20 +927,10 @@ impl AudioEngine {
.map_err(|e| AudioError::Backend(format!("output play: {e}")))?;
stream
};
#[cfg(not(target_os = "linux"))]
let output_device_name = device_name(&out_dev).unwrap_or_default();
info!(
target: "chanora_audio",
in_device = %capture_device_name,
out_device = %output_device_name,
"starting audio engine"
);
// ---------- Inbound forwarder ----------
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
let handler_for_task = audio_handler.clone();
let client_volume_overrides_for_task = client_volume_overrides.clone();
let frames_received_for_task = frames_received.clone();
tokio::spawn(async move {
loop {
@@ -1087,11 +947,6 @@ impl AudioEngine {
if let Err(e) = h.handle_packet(id, v.packet) {
debug!(target: "chanora_audio", error = %e, "decode failed");
} else {
apply_saved_client_volume(
&mut h,
&client_volume_overrides_for_task,
id,
);
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
}
}
@@ -1111,10 +966,6 @@ impl AudioEngine {
audio_processing_config,
audio_processing_stats,
audio_handler,
client_volume_overrides,
#[cfg(target_os = "linux")]
_input_stream: Mutex::new(input_stream),
#[cfg(target_os = "windows")]
_input_stream: Mutex::new(input_stream),
_output_stream: Mutex::new(Some(output_stream)),
shutdown_tx: Some(shutdown_tx),
@@ -1140,7 +991,6 @@ impl AudioEngine {
let output_muted = Arc::new(AtomicBool::new(false));
let audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::default()));
let audio_processing_stats = Arc::new(crate::SharedAudioProcessingStats::default());
let client_volume_overrides = Arc::new(Mutex::new(HashMap::new()));
let audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> =
Arc::new(Mutex::new(AudioHandler::new()));
@@ -1287,7 +1137,6 @@ impl AudioEngine {
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
let handler_for_task = audio_handler.clone();
let client_volume_overrides_for_task = client_volume_overrides.clone();
let frames_received_for_task = frames_received.clone();
tokio::spawn(async move {
loop {
@@ -1304,11 +1153,6 @@ impl AudioEngine {
if let Err(e) = h.handle_packet(id, v.packet) {
debug!(target: "chanora_audio", error = %e, "decode failed");
} else {
apply_saved_client_volume(
&mut h,
&client_volume_overrides_for_task,
id,
);
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
}
}
@@ -1328,7 +1172,6 @@ impl AudioEngine {
audio_processing_config,
audio_processing_stats,
audio_handler,
client_volume_overrides,
voice_out_tx,
voice_activity_selector: cfg.voice_activity_selector.clone(),
mic_gain: cfg.mic_gain,
@@ -1381,7 +1224,6 @@ impl AudioEngine {
let output_muted = Arc::new(AtomicBool::new(false));
let audio_processing_config = Arc::new(Mutex::new(crate::AudioProcessingConfig::default()));
let audio_processing_stats = Arc::new(crate::SharedAudioProcessingStats::default());
let client_volume_overrides = Arc::new(Mutex::new(HashMap::new()));
let audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>> =
Arc::new(Mutex::new(AudioHandler::new()));
@@ -1416,7 +1258,6 @@ impl AudioEngine {
// waiting.
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
let handler_for_task = audio_handler.clone();
let client_volume_overrides_for_task = client_volume_overrides.clone();
let frames_received_for_task = frames_received.clone();
tokio::spawn(async move {
loop {
@@ -1433,11 +1274,6 @@ impl AudioEngine {
if let Err(e) = h.handle_packet(id, v.packet) {
debug!(target: "chanora_audio", error = %e, "decode failed");
} else {
apply_saved_client_volume(
&mut h,
&client_volume_overrides_for_task,
id,
);
frames_received_for_task.fetch_add(1, Ordering::Relaxed);
}
}
@@ -1457,7 +1293,6 @@ impl AudioEngine {
audio_processing_config,
audio_processing_stats,
audio_handler,
client_volume_overrides,
voice_out_tx,
voice_activity_selector: cfg.voice_activity_selector.clone(),
mic_gain: cfg.mic_gain,
@@ -1477,8 +1312,12 @@ impl AudioEngine {
// common contract is that dropping the wrapper stops
// audio. iOS collapses input + output into one
// `IosVoiceUnit` (see `ios_voice_unit.rs`); every other
// platform has separate input + output stream owners.
#[cfg(any(target_os = "linux", target_os = "windows"))]
// platform has separate cpal input + cpal/SDL output.
#[cfg(all(
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
{
let _ = self._input_stream.lock().unwrap().take();
let _ = self._output_stream.lock().unwrap().take();
@@ -1774,11 +1613,11 @@ impl AudioEngine {
}
/// Latest Android voice-audio diagnostics snapshot (SDD-112 item
/// 10 / SDD-113 item 7 / SDD-116 item 3). Returns `Some(...)`
/// 10 / SDD-113 item 7 / SDD-116 item 3). On non-Android targets
/// this always returns `None`. On Android it returns `Some(...)`
/// once `AndroidVoiceUnit::open()` has published a snapshot; the
/// slot is cleared on `close()` / `Drop`. Per SDD-090 the snapshot
/// contains only device-side technical scalars — no PII.
#[cfg(target_os = "android")]
/// slot is cleared on `close()` / `Drop`. Per SDD-090 the
/// snapshot contains only device-side technical scalars — no PII.
pub fn android_diagnostics(
&self,
) -> Option<crate::mobile_voice_backend::AndroidAudioDiagnostics> {
@@ -1830,25 +1669,6 @@ impl AudioEngine {
);
}
}
match self.client_volume_overrides.lock() {
Ok(mut overrides) => {
if (clamped - 1.0).abs() <= f32::EPSILON {
overrides.remove(&SessionAudioId(client_id));
} else {
overrides.insert(SessionAudioId(client_id), clamped);
}
}
Err(e) => {
tracing::warn!(
target: "chanora_audio",
client_id,
volume = clamped,
error = %e,
"set_client_volume: client volume overrides lock poisoned"
);
}
}
}
}
@@ -1860,7 +1680,7 @@ impl Drop for AudioEngine {
// ---------- Capture pipeline ----------
#[cfg(target_os = "windows")]
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
fn try_open_capture(
in_dev: &cpal::Device,
voice_out_tx: mpsc::Sender<OutPacket>,
@@ -1879,8 +1699,9 @@ fn try_open_capture(
// jitter of WASAPI shared mode.
// * macOS / iOS: CoreAudio picks a HAL-friendly default;
// iOS RemoteIO rejects arbitrary buffer-size requests.
// * Linux does not reach this cpal capture path in production;
// it uses native PipeWire/PulseAudio capture instead.
// * Linux: same SDL2-vs-cpal split as the output path; we
// still use cpal for capture but leave Default since
// PipeWire's ALSA shim works well there.
let mut in_stream_cfg: cpal::StreamConfig = in_cfg.into();
#[cfg(target_os = "windows")]
{
@@ -1917,7 +1738,7 @@ fn try_open_capture(
}
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
pub(crate) struct CaptureState {
struct CaptureState {
encoder: OpusEncoder,
in_sample_rate: u32,
in_channels: usize,
@@ -1956,7 +1777,7 @@ pub(crate) struct CaptureState {
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
impl CaptureState {
pub(crate) fn new(
fn new(
encoder: OpusEncoder,
in_sample_rate: u32,
in_channels: usize,
@@ -1989,7 +1810,7 @@ impl CaptureState {
/// Consume an arbitrary-rate, multichannel cpal buffer; produce
/// 48 kHz mono frames; encode and send when `transmit_active`
/// is true (PTT engaged).
pub(crate) fn ingest<T: ToF32 + Copy>(&mut self, buf: &[T]) {
fn ingest<T: ToF32 + Copy>(&mut self, buf: &[T]) {
if !self.transmit_active.load(Ordering::Relaxed) {
// Drain accumulator while muted so we don't pop on PTT release.
self.pcm_accum.clear();
@@ -2138,7 +1959,7 @@ impl CaptureState {
/// Per-sample format conversion to f32 in the range [-1.0, 1.0].
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
pub(crate) trait ToF32 {
trait ToF32 {
fn to_f32_sample(self) -> f32;
}
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
@@ -2160,7 +1981,7 @@ impl ToF32 for u16 {
}
}
#[cfg(target_os = "windows")]
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
fn build_input_stream<T>(
device: &cpal::Device,
config: &cpal::StreamConfig,
+15 -11
View File
@@ -25,7 +25,7 @@
//! stream-format requests on bus 0 (output) and bus 1 (input).
//!
//! This file replaces the cpal capture + playback streams on iOS and macOS.
//! Linux uses native PipeWire/PulseAudio voice I/O;
//! Linux uses SDL2 for output (see `sdl_output.rs`) and cpal for capture;
//! Windows uses cpal's WASAPI backend.
//!
//! ## What VPIO gives us
@@ -53,7 +53,7 @@
//! thread that owns the unit. We open the unit on the same thread
//! that calls `Self::start` (the tokio worker that runs
//! `chanora_core::ChanoraSession::start_audio`, the same pattern
//! the desktop backends use) and never move it. The outer `AudioEngine`
//! cpal + SDL use) and never move it. The outer `AudioEngine`
//! already carries an `unsafe impl Send` to satisfy the same
//! constraint for cpal's `!Send` Stream type; that impl covers
//! VPIO too.
@@ -589,16 +589,16 @@ impl IosVoiceUnit {
/// captures samples, render callback fires when the hardware
/// needs samples to play.
///
/// Parameters mirror the capture + playback inputs the desktop
/// backends accept so engine.rs can swap backends with
/// Parameters mirror the capture + playback inputs the cpal
/// and SDL backends accept so engine.rs can swap backends with
/// a `cfg`.
///
/// * `handler` — shared AudioHandler the inbound forwarder
/// feeds Opus packets into. The output render callback pulls
/// decoded f32 frames from it (48 kHz stereo) and downmixes
/// to the i16 mono buffer VPIO expects.
/// * `output_gain` / `output_muted` — same atomics the desktop
/// output paths read on every callback so the master
/// * `output_gain` / `output_muted` — same atomics the cpal
/// and SDL output paths read on every callback so the master
/// volume + local-mute UI works identically across backends.
/// * `voice_out_tx` — channel the capture pipeline sends
/// encoded `OutPacket`s on.
@@ -734,7 +734,7 @@ impl IosVoiceUnit {
// 1. Lock the AudioHandler, ask it to fill a scratch
// f32 stereo buffer (length = 2 * num_frames). The
// handler runs Opus decode + per-client jitter
// buffer + mix. Same primitive desktop output
// buffer + mix. Same primitive cpal + SDL output
// paths use; this is the platform-neutral playback
// contract from `tsclientlib::audio::AudioHandler`.
// 2. Downmix to i16 mono with master gain. VPIO expects
@@ -748,10 +748,12 @@ impl IosVoiceUnit {
// AudioHandler in step 1 so its jitter buffer
// doesn't grow unbounded while muted. This is the
// contract every other backend follows (matches
// Linux PipeWire/PulseAudio and the cpal output stream).
// SdlOutput::callback and the cpal output stream).
//
// Build the playback pipeline (direct fill_buffer in
// render callback; matches the desktop voice backends).
// render callback; matches tsclientlib's reference SDL
// example at
// tsclientlib/examples/audio_utils/ts_to_audio.rs).
//
// The earlier ring-buffer attempt (rc.8+73..+74) decoupled
// AudioHandler from the render callback via a 50 Hz
@@ -764,12 +766,14 @@ impl IosVoiceUnit {
// consumer pull being larger, the ring averaged out empty
// — fill_buffer was returning silence 65-84% of ticks
// because we drained it too aggressively before packets
// arrived. Direct desktop callbacks follow the same pattern.
// arrived. Linux/SDL's same pattern works fine because
// SDL calls fill_buffer at exactly the device callback
// rate.
//
// Revert to direct call: render callback locks
// AudioHandler, asks for `num_frames` stereo frames, and
// immediately downmixes to i16 mono into the output
// buffer. Same as desktop output, just stereo-f32 -> mono-i16
// buffer. Same as Linux/SDL, just stereo-f32 -> mono-i16
// converted at the boundary.
let mut scratch_stereo: Vec<f32> = Vec::with_capacity(2048);
let handler_for_render = params.handler.clone();
+2 -4
View File
@@ -6,7 +6,7 @@
//! ## What's wired in this Beta
//!
//! * Default-input capture via platform audio backends (Oboe on Android,
//! VoiceProcessingIO on Apple platforms, PipeWire/PulseAudio on Linux, cpal on Windows)
//! VoiceProcessingIO on Apple platforms, cpal/SDL elsewhere)
//! * Frame-aligned 20 ms / 48 kHz mono Opus encoding via `audiopus`
//! * Forward encoded frames to the protocol crate as `OutPacket`s
//! * Inbound voice packets fed to `tsclientlib::audio::AudioHandler`
@@ -32,7 +32,6 @@ pub mod audio_processing;
pub mod debug_wav;
mod engine;
pub mod frame;
#[cfg(any(target_os = "android", target_os = "ios", target_os = "macos"))]
pub mod mobile_voice_backend;
pub mod mode_stack;
pub(crate) mod opus_voice;
@@ -45,11 +44,10 @@ pub mod transmit_mode;
pub mod transmit_selector;
pub mod vad;
pub mod voice_activity;
#[cfg(any(target_os = "android", target_os = "ios", test))]
pub(crate) mod voice_render;
#[cfg(target_os = "linux")]
mod linux_pipewire_input;
mod sdl_output;
#[cfg(any(target_os = "ios", target_os = "macos"))]
mod ios_voice_unit;
@@ -1,967 +0,0 @@
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{mpsc as std_mpsc, Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::Duration;
use libpulse_binding as pulse;
use libpulse_simple_binding as psimple;
use pipewire as pw;
use pulse::callbacks::ListResult;
use pulse::context::{self, Context};
use pulse::mainloop::threaded::Mainloop;
use pulse::proplist::Proplist;
use pulse::sample::{Format, Spec};
use pulse::stream::Direction;
use pw::spa::pod::Pod;
use pw::{properties::properties, spa};
use tokio::sync::mpsc;
use tracing::{info, warn};
use tsclientlib::audio::AudioHandler;
use crate::engine::{AudioDeviceInfo, CaptureState, SessionAudioId};
use crate::{AudioError, AudioTransmitGate};
use chanora_protocol::OutPacket;
const INPUT_ID_PREFIX: &str = "pw-input:";
const OUTPUT_ID_PREFIX: &str = "pw-output:";
const PULSE_INPUT_ID_PREFIX: &str = "pulse-input:";
const PULSE_OUTPUT_ID_PREFIX: &str = "pulse-output:";
const SAMPLE_RATE: u32 = 48_000;
const CAPTURE_CHANNELS: u32 = 1;
const PLAYBACK_CHANNELS: u32 = 2;
const PLAYBACK_FRAMES: usize = 960;
#[derive(Clone, Debug)]
struct LinuxDevice {
backend: LinuxBackend,
direction: LinuxDirection,
raw_id: String,
label: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum LinuxBackend {
PipeWire,
PulseAudio,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum LinuxDirection {
Input,
Output,
}
#[derive(Clone, Debug)]
struct PipeWireDevice {
node_id: u32,
label: String,
}
impl LinuxDevice {
fn to_audio_device_info(&self, default_label: Option<&str>) -> AudioDeviceInfo {
let is_default = default_label.is_some_and(|label| label == self.label);
let (backend, id) = match (self.backend, self.direction) {
(LinuxBackend::PipeWire, LinuxDirection::Input) => (
"PipeWire",
input_device_id(self.raw_id.parse::<u32>().unwrap_or_default()),
),
(LinuxBackend::PipeWire, LinuxDirection::Output) => (
"PipeWire",
output_device_id(self.raw_id.parse::<u32>().unwrap_or_default()),
),
(LinuxBackend::PulseAudio, LinuxDirection::Input) => {
("PulseAudio", pulse_input_device_id(&self.raw_id))
}
(LinuxBackend::PulseAudio, LinuxDirection::Output) => {
("PulseAudio", pulse_output_device_id(&self.raw_id))
}
};
AudioDeviceInfo {
id,
name: self.label.clone(),
details: format!("{backend} · id={}", self.raw_id),
is_default,
}
}
}
pub(crate) fn input_device_id(node_id: u32) -> String {
format!("{INPUT_ID_PREFIX}{node_id}")
}
#[cfg(test)]
pub(crate) fn input_device_node_id_from_id(id: &str) -> Option<u32> {
id.strip_prefix(INPUT_ID_PREFIX)?.parse().ok()
}
pub(crate) fn output_device_id(node_id: u32) -> String {
format!("{OUTPUT_ID_PREFIX}{node_id}")
}
#[cfg(test)]
pub(crate) fn output_device_node_id_from_id(id: &str) -> Option<u32> {
id.strip_prefix(OUTPUT_ID_PREFIX)?.parse().ok()
}
fn pulse_input_device_id(name: &str) -> String {
format!("{PULSE_INPUT_ID_PREFIX}{name}")
}
fn pulse_output_device_id(name: &str) -> String {
format!("{PULSE_OUTPUT_ID_PREFIX}{name}")
}
#[cfg(test)]
fn pulse_input_name_from_id(id: &str) -> Option<&str> {
id.strip_prefix(PULSE_INPUT_ID_PREFIX)
}
#[cfg(test)]
fn pulse_output_name_from_id(id: &str) -> Option<&str> {
id.strip_prefix(PULSE_OUTPUT_ID_PREFIX)
}
pub(crate) fn list_input_devices(default_label: Option<&str>) -> Vec<AudioDeviceInfo> {
list_devices(LinuxDirection::Input)
.iter()
.map(|device| device.to_audio_device_info(default_label))
.collect()
}
pub(crate) fn list_output_devices(default_label: Option<&str>) -> Vec<AudioDeviceInfo> {
list_devices(LinuxDirection::Output)
.iter()
.map(|device| device.to_audio_device_info(default_label))
.collect()
}
pub(crate) fn start_capture(
prefer: Option<&str>,
default_label: Option<&str>,
voice_out_tx: mpsc::Sender<OutPacket>,
transmit_gate: AudioTransmitGate,
frames_sent: Arc<AtomicU32>,
mic_gain: f32,
) -> Result<(LinuxInput, AudioDeviceInfo), AudioError> {
let devices = list_devices(LinuxDirection::Input);
let device = select_device(&devices, prefer, default_label).ok_or_else(|| {
AudioError::Backend("Linux audio did not expose a usable capture source".to_string())
})?;
let info = device.to_audio_device_info(default_label);
let encoder = crate::opus_voice::new_voip_encoder("pipewire capture")?;
let capture_state = Arc::new(Mutex::new(CaptureState::new(
encoder,
SAMPLE_RATE,
CAPTURE_CHANNELS as usize,
mic_gain,
voice_out_tx,
transmit_gate.flag_arc(),
frames_sent,
)));
let stream = match device.backend {
LinuxBackend::PipeWire => LinuxInput::PipeWire(PipeWireInput::start(
PipeWireDevice {
node_id: device.raw_id.parse().map_err(|_| {
AudioError::Backend(format!("invalid PipeWire source id `{}`", device.raw_id))
})?,
label: device.label.clone(),
},
capture_state,
)?),
LinuxBackend::PulseAudio => LinuxInput::PulseAudio(PulseAudioInput::start(
device.raw_id.clone(),
capture_state,
)?),
};
Ok((stream, info))
}
pub(crate) fn start_output(
prefer: Option<&str>,
default_label: Option<&str>,
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>,
) -> Result<(LinuxOutput, AudioDeviceInfo), AudioError> {
let devices = list_devices(LinuxDirection::Output);
let device = select_device(&devices, prefer, default_label).ok_or_else(|| {
AudioError::Backend("Linux audio did not expose a usable playback sink".to_string())
})?;
let info = device.to_audio_device_info(default_label);
let stream = match device.backend {
LinuxBackend::PipeWire => LinuxOutput::PipeWire(PipeWireOutput::start(
PipeWireDevice {
node_id: device.raw_id.parse().map_err(|_| {
AudioError::Backend(format!("invalid PipeWire sink id `{}`", device.raw_id))
})?,
label: device.label.clone(),
},
handler,
output_gain,
output_muted,
)?),
LinuxBackend::PulseAudio => LinuxOutput::PulseAudio(PulseAudioOutput::start(
device.raw_id.clone(),
handler,
output_gain,
output_muted,
)?),
};
Ok((stream, info))
}
fn select_device(
devices: &[LinuxDevice],
prefer: Option<&str>,
default_label: Option<&str>,
) -> Option<LinuxDevice> {
if devices.is_empty() {
return None;
}
if let Some(prefer) = prefer {
if let Some(device) = devices
.iter()
.find(|device| device.to_audio_device_info(default_label).id == prefer)
{
return Some(device.clone());
}
}
if let Some(default_label) = default_label {
if let Some(device) = devices.iter().find(|device| device.label == default_label) {
return Some(device.clone());
}
}
devices.first().cloned()
}
fn list_devices(direction: LinuxDirection) -> Vec<LinuxDevice> {
match list_pipewire_nodes(direction) {
Ok(pipewire) if !pipewire.is_empty() => return pipewire,
Ok(_) => {
warn!(target: "chanora_audio", "PipeWire device enumeration returned no devices; trying PulseAudio fallback")
}
Err(error) => {
warn!(target: "chanora_audio", error = %error, "PipeWire device enumeration failed")
}
}
match list_pulse_devices(direction) {
Ok(pulse) => pulse,
Err(error) => {
warn!(target: "chanora_audio", error = %error, "PulseAudio device enumeration failed");
Vec::new()
}
}
}
fn list_pipewire_nodes(direction: LinuxDirection) -> Result<Vec<LinuxDevice>, String> {
pw::init();
let mainloop = pw::main_loop::MainLoopRc::new(None).map_err(|err| err.to_string())?;
let context = pw::context::ContextRc::new(&mainloop, None).map_err(|err| err.to_string())?;
let core = context.connect_rc(None).map_err(|err| err.to_string())?;
let registry = core.get_registry().map_err(|err| err.to_string())?;
let done = Rc::new(Cell::new(false));
let devices = Rc::new(RefCell::new(Vec::<LinuxDevice>::new()));
let class_prefix = match direction {
LinuxDirection::Input => "Audio/Source",
LinuxDirection::Output => "Audio/Sink",
};
let devices_for_registry = devices.clone();
let _registry_listener = registry
.add_listener_local()
.global(move |global| {
if global.type_ != pw::types::ObjectType::Node {
return;
}
let Some(props) = global.props else {
return;
};
let Some(media_class) = props.get(*pw::keys::MEDIA_CLASS) else {
return;
};
if !media_class.starts_with(class_prefix) {
return;
}
let label = props
.get(*pw::keys::NODE_DESCRIPTION)
.or_else(|| props.get(*pw::keys::NODE_NICK))
.or_else(|| props.get(*pw::keys::NODE_NAME))
.unwrap_or("Unnamed PipeWire node")
.to_string();
devices_for_registry.borrow_mut().push(LinuxDevice {
backend: LinuxBackend::PipeWire,
direction,
raw_id: global.id.to_string(),
label,
});
})
.register();
let pending = core.sync(0).map_err(|err| err.to_string())?;
let done_for_core = done.clone();
let loop_for_core = mainloop.clone();
let _core_listener = core
.add_listener_local()
.done(move |id, seq| {
if id == pw::core::PW_ID_CORE && seq == pending {
done_for_core.set(true);
loop_for_core.quit();
}
})
.error(move |_id, _seq, _res, message| {
warn!(
target: "chanora_audio",
message,
"PipeWire core error during input enumeration"
);
})
.register();
while !done.get() {
mainloop.run();
}
let mut devices = devices.borrow().clone();
devices.sort_by(|left, right| {
left.label
.cmp(&right.label)
.then_with(|| left.raw_id.cmp(&right.raw_id))
});
Ok(devices)
}
fn list_pulse_devices(direction: LinuxDirection) -> Result<Vec<LinuxDevice>, String> {
with_pulse_context(|context, mainloop| {
let done = Rc::new(Cell::new(false));
let devices = Rc::new(RefCell::new(Vec::<LinuxDevice>::new()));
let done_for_cb = done.clone();
let devices_for_cb = devices.clone();
let mainloop_for_cb = mainloop.clone();
let introspector = context.borrow().introspect();
match direction {
LinuxDirection::Input => {
let _operation = introspector.get_source_info_list(move |result| match result {
ListResult::Item(info) => {
if info.monitor_of_sink.is_some() {
return;
}
let Some(name) = info.name.as_ref() else {
return;
};
let label = info
.description
.as_ref()
.map(|value| value.to_string())
.unwrap_or_else(|| name.to_string());
devices_for_cb.borrow_mut().push(LinuxDevice {
backend: LinuxBackend::PulseAudio,
direction,
raw_id: name.to_string(),
label,
});
}
ListResult::End | ListResult::Error => {
done_for_cb.set(true);
unsafe { (*mainloop_for_cb.as_ptr()).signal(false) };
}
});
while !done.get() {
mainloop.borrow_mut().wait();
}
}
LinuxDirection::Output => {
let _operation = introspector.get_sink_info_list(move |result| match result {
ListResult::Item(info) => {
let Some(name) = info.name.as_ref() else {
return;
};
let label = info
.description
.as_ref()
.map(|value| value.to_string())
.unwrap_or_else(|| name.to_string());
devices_for_cb.borrow_mut().push(LinuxDevice {
backend: LinuxBackend::PulseAudio,
direction,
raw_id: name.to_string(),
label,
});
}
ListResult::End | ListResult::Error => {
done_for_cb.set(true);
unsafe { (*mainloop_for_cb.as_ptr()).signal(false) };
}
});
while !done.get() {
mainloop.borrow_mut().wait();
}
}
}
let mut devices = devices.borrow().clone();
devices.sort_by(|left, right| {
left.label
.cmp(&right.label)
.then_with(|| left.raw_id.cmp(&right.raw_id))
});
Ok(devices)
})
}
fn with_pulse_context<T, F>(operation: F) -> Result<T, String>
where
F: FnOnce(&Rc<RefCell<Context>>, &Rc<RefCell<Mainloop>>) -> Result<T, String>,
{
let proplist = build_pulse_proplist()?;
let mainloop = Rc::new(RefCell::new(
Mainloop::new().ok_or("failed to create PulseAudio mainloop")?,
));
let context = Rc::new(RefCell::new(
Context::new_with_proplist(&*mainloop.borrow(), "chanora", &proplist)
.ok_or("failed to create PulseAudio context")?,
));
let mainloop_for_cb = mainloop.clone();
let context_for_cb = context.clone();
context
.borrow_mut()
.set_state_callback(Some(Box::new(move || {
let state = unsafe { (*context_for_cb.as_ptr()).get_state() };
match state {
context::State::Ready | context::State::Failed | context::State::Terminated => {
unsafe { (*mainloop_for_cb.as_ptr()).signal(false) };
}
_ => {}
}
})));
context
.borrow_mut()
.connect(None, context::FlagSet::NOFLAGS, None)
.map_err(pa_error)?;
mainloop.borrow_mut().lock();
mainloop.borrow_mut().start().map_err(pa_error)?;
loop {
match context.borrow().get_state() {
context::State::Ready => break,
context::State::Failed | context::State::Terminated => {
mainloop.borrow_mut().unlock();
mainloop.borrow_mut().stop();
return Err(format!(
"PulseAudio context state {:?}",
context.borrow().get_state()
));
}
_ => mainloop.borrow_mut().wait(),
}
}
context.borrow_mut().set_state_callback(None);
let result = operation(&context, &mainloop);
mainloop.borrow_mut().unlock();
mainloop.borrow_mut().stop();
result
}
fn build_pulse_proplist() -> Result<Proplist, String> {
let mut proplist = Proplist::new().ok_or("failed to create PulseAudio proplist")?;
proplist
.set_str(pulse::proplist::properties::APPLICATION_NAME, "chanora")
.map_err(|_| "failed to set PulseAudio application name".to_string())?;
Ok(proplist)
}
fn pulse_capture_spec() -> Spec {
Spec {
format: Format::F32le,
channels: CAPTURE_CHANNELS as u8,
rate: SAMPLE_RATE,
}
}
fn pulse_playback_spec() -> Spec {
Spec {
format: Format::F32le,
channels: PLAYBACK_CHANNELS as u8,
rate: SAMPLE_RATE,
}
}
fn pa_error(err: pulse::error::PAErr) -> String {
err.to_string()
.unwrap_or_else(|| format!("PulseAudio error {err:?}"))
}
fn wait_for_startup(
ready_rx: std_mpsc::Receiver<Result<(), String>>,
handle: JoinHandle<Result<(), String>>,
label: &str,
) -> Result<JoinHandle<Result<(), String>>, AudioError> {
match ready_rx.recv() {
Ok(Ok(())) => Ok(handle),
Ok(Err(err)) => {
let _ = handle.join();
Err(AudioError::Backend(err))
}
Err(err) => {
let _ = handle.join();
Err(AudioError::Backend(format!(
"{label} startup failed: {err}"
)))
}
}
}
pub(crate) enum LinuxInput {
#[allow(dead_code)]
PipeWire(PipeWireInput),
#[allow(dead_code)]
PulseAudio(PulseAudioInput),
}
pub(crate) enum LinuxOutput {
#[allow(dead_code)]
PipeWire(PipeWireOutput),
#[allow(dead_code)]
PulseAudio(PulseAudioOutput),
}
pub(crate) struct PipeWireInput {
device: PipeWireDevice,
stop: Arc<AtomicBool>,
handle: Option<JoinHandle<Result<(), String>>>,
}
impl PipeWireInput {
fn start(
device: PipeWireDevice,
capture_state: Arc<Mutex<CaptureState>>,
) -> Result<Self, AudioError> {
let stop = Arc::new(AtomicBool::new(false));
let (ready_tx, ready_rx) = std_mpsc::sync_channel::<Result<(), String>>(1);
let stop_for_thread = stop.clone();
let device_for_thread = device.clone();
let handle = thread::spawn(move || {
pw::init();
let mainloop = pw::main_loop::MainLoopRc::new(None).map_err(|err| err.to_string())?;
let context =
pw::context::ContextRc::new(&mainloop, None).map_err(|err| err.to_string())?;
let core = context.connect_rc(None).map_err(|err| err.to_string())?;
let mut props = properties! {
*pw::keys::MEDIA_TYPE => "Audio",
*pw::keys::MEDIA_CATEGORY => "Capture",
*pw::keys::MEDIA_ROLE => "Communication",
};
let target = device_for_thread.node_id.to_string();
props.insert(*pw::keys::TARGET_OBJECT, target.as_str());
let stream = pw::stream::StreamBox::new(&core, "chanora-pipewire-capture", props)
.map_err(|err| err.to_string())?;
let _listener = stream
.add_local_listener_with_user_data(capture_state)
.process(|stream, state| {
if let Some(mut buffer) = stream.dequeue_buffer() {
let datas = buffer.datas_mut();
if datas.is_empty() {
return;
}
let data_ref = &mut datas[0];
let byte_count = data_ref.chunk().size() as usize;
let Some(bytes) = data_ref.data() else {
return;
};
let byte_count = byte_count.min(bytes.len());
if byte_count < 4 {
return;
}
let mut scratch = Vec::with_capacity(byte_count / 4);
for chunk in bytes[..byte_count].chunks_exact(4) {
scratch
.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
}
state.lock().unwrap().ingest(&scratch);
}
})
.register()
.map_err(|err| err.to_string())?;
let values = build_format_param()?;
let mut params =
[Pod::from_bytes(&values).ok_or("invalid PipeWire capture format pod")?];
stream
.connect(
spa::utils::Direction::Input,
None,
pw::stream::StreamFlags::AUTOCONNECT
| pw::stream::StreamFlags::MAP_BUFFERS
| pw::stream::StreamFlags::RT_PROCESS,
&mut params,
)
.map_err(|err| err.to_string())?;
let _ = ready_tx.send(Ok(()));
while !stop_for_thread.load(Ordering::SeqCst) {
let _ = mainloop
.loop_()
.iterate(pw::loop_::Timeout::Finite(Duration::from_millis(100)));
}
mainloop.quit();
Ok(())
});
let handle = wait_for_startup(ready_rx, handle, "PipeWire capture")?;
info!(target: "chanora_audio", device = %device.label, node_id = device.node_id, "PipeWire capture backend initialised");
Ok(Self {
device,
stop,
handle: Some(handle),
})
}
}
pub(crate) struct PipeWireOutput {
device: PipeWireDevice,
stop: Arc<AtomicBool>,
handle: Option<JoinHandle<Result<(), String>>>,
}
impl PipeWireOutput {
fn start(
device: PipeWireDevice,
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>,
) -> Result<Self, AudioError> {
let stop = Arc::new(AtomicBool::new(false));
let (ready_tx, ready_rx) = std_mpsc::sync_channel::<Result<(), String>>(1);
let stop_for_thread = stop.clone();
let device_for_thread = device.clone();
let handle = thread::spawn(move || {
pw::init();
let mainloop = pw::main_loop::MainLoopRc::new(None).map_err(|err| err.to_string())?;
let context =
pw::context::ContextRc::new(&mainloop, None).map_err(|err| err.to_string())?;
let core = context.connect_rc(None).map_err(|err| err.to_string())?;
let mut props = properties! {
*pw::keys::MEDIA_TYPE => "Audio",
*pw::keys::MEDIA_CATEGORY => "Playback",
*pw::keys::MEDIA_ROLE => "Communication",
};
let target = device_for_thread.node_id.to_string();
props.insert(*pw::keys::TARGET_OBJECT, target.as_str());
let stream = pw::stream::StreamBox::new(&core, "chanora-pipewire-playback", props)
.map_err(|err| err.to_string())?;
let _listener = stream
.add_local_listener_with_user_data((handler, output_gain, output_muted))
.process(|stream, state| {
if let Some(mut buffer) = stream.dequeue_buffer() {
let datas = buffer.datas_mut();
if datas.is_empty() {
return;
}
let data_ref = &mut datas[0];
let byte_len = {
let Some(bytes) = data_ref.data() else {
return;
};
let samples = bytes.len() / std::mem::size_of::<f32>();
let mut scratch = vec![0.0_f32; samples];
if let Ok(mut handler) = state.0.lock() {
handler.fill_buffer(&mut scratch);
}
apply_output_controls(&mut scratch, &state.1, &state.2);
for (chunk, sample) in
bytes.chunks_exact_mut(4).zip(scratch.into_iter())
{
chunk.copy_from_slice(&sample.to_le_bytes());
}
bytes.len()
};
let chunk = data_ref.chunk_mut();
*chunk.offset_mut() = 0;
*chunk.stride_mut() =
(PLAYBACK_CHANNELS * std::mem::size_of::<f32>() as u32) as i32;
*chunk.size_mut() = byte_len as u32;
}
})
.register()
.map_err(|err| err.to_string())?;
let values = build_playback_format_param()?;
let mut params =
[Pod::from_bytes(&values).ok_or("invalid PipeWire playback format pod")?];
stream
.connect(
spa::utils::Direction::Output,
None,
pw::stream::StreamFlags::AUTOCONNECT
| pw::stream::StreamFlags::MAP_BUFFERS
| pw::stream::StreamFlags::RT_PROCESS,
&mut params,
)
.map_err(|err| err.to_string())?;
let _ = ready_tx.send(Ok(()));
while !stop_for_thread.load(Ordering::SeqCst) {
let _ = mainloop
.loop_()
.iterate(pw::loop_::Timeout::Finite(Duration::from_millis(100)));
}
mainloop.quit();
Ok(())
});
let handle = wait_for_startup(ready_rx, handle, "PipeWire playback")?;
info!(target: "chanora_audio", device = %device.label, node_id = device.node_id, "PipeWire playback backend initialised");
Ok(Self {
device,
stop,
handle: Some(handle),
})
}
}
fn apply_output_controls(samples: &mut [f32], output_gain: &AtomicU32, output_muted: &AtomicBool) {
if output_muted.load(Ordering::Relaxed) {
samples.fill(0.0);
return;
}
let gain = f32::from_bits(output_gain.load(Ordering::Relaxed));
if (gain - 1.0).abs() > f32::EPSILON {
for sample in samples.iter_mut() {
*sample *= gain;
}
}
}
pub(crate) struct PulseAudioInput {
device_name: String,
stop: Arc<AtomicBool>,
handle: Option<JoinHandle<Result<(), String>>>,
}
impl PulseAudioInput {
fn start(
device_name: String,
capture_state: Arc<Mutex<CaptureState>>,
) -> Result<Self, AudioError> {
let stop = Arc::new(AtomicBool::new(false));
let (ready_tx, ready_rx) = std_mpsc::sync_channel::<Result<(), String>>(1);
let stop_for_thread = stop.clone();
let device_for_thread = device_name.clone();
let handle = thread::spawn(move || {
let spec = pulse_capture_spec();
let stream = psimple::Simple::new(
None,
"chanora",
Direction::Record,
Some(device_for_thread.as_str()),
"chanora-record",
&spec,
None,
None,
)
.map_err(pa_error)?;
let _ = ready_tx.send(Ok(()));
let mut bytes =
vec![
0_u8;
PLAYBACK_FRAMES * CAPTURE_CHANNELS as usize * std::mem::size_of::<f32>()
];
while !stop_for_thread.load(Ordering::SeqCst) {
stream.read(&mut bytes).map_err(pa_error)?;
let mut scratch = Vec::with_capacity(bytes.len() / 4);
for chunk in bytes.chunks_exact(4) {
scratch.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
}
capture_state.lock().unwrap().ingest(&scratch);
}
stream.flush().map_err(pa_error)?;
Ok(())
});
let handle = wait_for_startup(ready_rx, handle, "PulseAudio capture")?;
info!(target: "chanora_audio", device = %device_name, "PulseAudio capture backend initialised");
Ok(Self {
device_name,
stop,
handle: Some(handle),
})
}
}
pub(crate) struct PulseAudioOutput {
device_name: String,
stop: Arc<AtomicBool>,
handle: Option<JoinHandle<Result<(), String>>>,
}
impl PulseAudioOutput {
fn start(
device_name: String,
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>,
) -> Result<Self, AudioError> {
let stop = Arc::new(AtomicBool::new(false));
let (ready_tx, ready_rx) = std_mpsc::sync_channel::<Result<(), String>>(1);
let stop_for_thread = stop.clone();
let device_for_thread = device_name.clone();
let handle = thread::spawn(move || {
let spec = pulse_playback_spec();
let stream = psimple::Simple::new(
None,
"chanora",
Direction::Playback,
Some(device_for_thread.as_str()),
"chanora-playback",
&spec,
None,
None,
)
.map_err(pa_error)?;
let _ = ready_tx.send(Ok(()));
let mut samples = vec![0.0_f32; PLAYBACK_FRAMES * PLAYBACK_CHANNELS as usize];
let mut bytes = vec![0_u8; samples.len() * std::mem::size_of::<f32>()];
while !stop_for_thread.load(Ordering::SeqCst) {
samples.fill(0.0);
if let Ok(mut handler) = handler.lock() {
handler.fill_buffer(&mut samples);
}
apply_output_controls(&mut samples, &output_gain, &output_muted);
for (chunk, sample) in bytes.chunks_exact_mut(4).zip(samples.iter().copied()) {
chunk.copy_from_slice(&sample.to_le_bytes());
}
stream.write(&bytes).map_err(pa_error)?;
}
stream.drain().map_err(pa_error)?;
Ok(())
});
let handle = wait_for_startup(ready_rx, handle, "PulseAudio playback")?;
info!(target: "chanora_audio", device = %device_name, "PulseAudio playback backend initialised");
Ok(Self {
device_name,
stop,
handle: Some(handle),
})
}
}
impl Drop for PipeWireInput {
fn drop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
if let Some(handle) = self.handle.take() {
if let Err(_panic) = handle.join() {
warn!(
target: "chanora_audio",
device = %self.device.label,
node_id = self.device.node_id,
"PipeWire capture thread panicked during shutdown"
);
}
}
}
}
impl Drop for PipeWireOutput {
fn drop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
if let Some(handle) = self.handle.take() {
if handle.join().is_err() {
warn!(target: "chanora_audio", device = %self.device.label, node_id = self.device.node_id, "PipeWire playback thread panicked during shutdown");
}
}
}
}
impl Drop for PulseAudioInput {
fn drop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
if let Some(handle) = self.handle.take() {
if handle.join().is_err() {
warn!(target: "chanora_audio", device = %self.device_name, "PulseAudio capture thread panicked during shutdown");
}
}
}
}
impl Drop for PulseAudioOutput {
fn drop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
if let Some(handle) = self.handle.take() {
if handle.join().is_err() {
warn!(target: "chanora_audio", device = %self.device_name, "PulseAudio playback thread panicked during shutdown");
}
}
}
}
fn build_format_param() -> Result<Vec<u8>, String> {
let mut audio_info = spa::param::audio::AudioInfoRaw::new();
audio_info.set_format(spa::param::audio::AudioFormat::F32LE);
audio_info.set_rate(SAMPLE_RATE);
audio_info.set_channels(CAPTURE_CHANNELS);
build_format_param_for_audio_info(audio_info)
}
fn build_playback_format_param() -> Result<Vec<u8>, String> {
let mut audio_info = spa::param::audio::AudioInfoRaw::new();
audio_info.set_format(spa::param::audio::AudioFormat::F32LE);
audio_info.set_rate(SAMPLE_RATE);
audio_info.set_channels(PLAYBACK_CHANNELS);
build_format_param_for_audio_info(audio_info)
}
fn build_format_param_for_audio_info(
audio_info: spa::param::audio::AudioInfoRaw,
) -> Result<Vec<u8>, String> {
let object = pw::spa::pod::Object {
type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
id: pw::spa::param::ParamType::EnumFormat.as_raw(),
properties: audio_info.into(),
};
pw::spa::pod::serialize::PodSerializer::serialize(
std::io::Cursor::new(Vec::new()),
&pw::spa::pod::Value::Object(object),
)
.map_err(|err| err.to_string())
.map(|serializer| serializer.0.into_inner())
}
#[cfg(test)]
mod tests {
use super::{
input_device_id, input_device_node_id_from_id, output_device_id,
output_device_node_id_from_id, pulse_input_device_id, pulse_input_name_from_id,
pulse_output_device_id, pulse_output_name_from_id,
};
#[test]
fn input_device_id_round_trip() {
let id = input_device_id(43);
assert_eq!(input_device_node_id_from_id(&id), Some(43));
assert_eq!(input_device_node_id_from_id("legacy-id"), None);
}
#[test]
fn output_device_id_round_trip() {
let id = output_device_id(44);
assert_eq!(output_device_node_id_from_id(&id), Some(44));
assert_eq!(output_device_node_id_from_id("legacy-id"), None);
}
#[test]
fn pulse_device_id_round_trip() {
let input = pulse_input_device_id("alsa_input.pci-0000_00_1f.3");
let output = pulse_output_device_id("alsa_output.pci-0000_00_1f.3");
assert_eq!(
pulse_input_name_from_id(&input),
Some("alsa_input.pci-0000_00_1f.3")
);
assert_eq!(
pulse_output_name_from_id(&output),
Some("alsa_output.pci-0000_00_1f.3")
);
}
}
+35 -51
View File
@@ -17,13 +17,11 @@
//! * `try_select` runs a synchronous portal-version probe on
//! the blocking proxy. Failure → `None` → Focused fallback.
//! * `start(gate, binding)` spawns a worker tokio task that
//! owns an async `zbus::Connection` and calls `CreateSession`.
//! If a binding was already persisted, it also calls
//! `BindShortcuts` (sentinel id `"chanora-ptt"`). Otherwise
//! `BindShortcuts` waits for the user's explicit Configure
//! action, which is when the portal opens its system dialog.
//! While the dialog is open the engine continues at L0Focused —
//! the audio path is not blocked.
//! owns an async `zbus::Connection`, calls `CreateSession`,
//! then `BindShortcuts` (sentinel id `"chanora-ptt"`). The
//! portal opens its own system dialog asking the user to
//! pick a key; while the dialog is open the engine continues
//! at L0Focused — the audio path is not blocked.
//! * Once the user accepts, the task subscribes to
//! `Activated`/`Deactivated` signals scoped to the session
//! handle and calls `gate.set(true/false)` accordingly.
@@ -107,7 +105,7 @@ fn is_gnome_on_wayland() -> bool {
trait BlockingGlobalShortcuts {
/// Version property (read on the blocking proxy as a fast
/// reachability probe).
#[zbus(property, name = "version")]
#[zbus(property)]
fn version(&self) -> zbus::Result<u32>;
}
@@ -221,7 +219,7 @@ struct BackendInner {
enum WorkerCmd {
/// Rebind: re-issue `BindShortcuts` on the same session.
Bind,
Rebind,
/// Stop: close the session and exit.
Stop,
}
@@ -298,10 +296,9 @@ impl DesktopPttBackend for LinuxGnomeWaylandBackend {
// the bridge's tokio runtime, so this is satisfied.
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
let desc_tx = self.desc_tx.clone();
let bind_on_start = binding.input_class != PttInputClass::None;
let class_hint = binding.input_class;
let worker = tokio::spawn(async move {
run_worker(gate, cmd_rx, desc_tx, class_hint, bind_on_start).await;
run_worker(gate, cmd_rx, desc_tx, class_hint).await;
});
// Stash command + worker handles. `try_lock` is fine: the
// backend isn't yet shared, and `start` is called once at
@@ -353,7 +350,7 @@ impl DesktopPttBackend for LinuxGnomeWaylandBackend {
// only — the portal decides the actual binding.
if let Ok(inner) = self.inner.try_lock() {
if let Some(tx) = inner.cmd_tx.as_ref() {
let _ = tx.send(WorkerCmd::Bind);
let _ = tx.send(WorkerCmd::Rebind);
return Ok(());
}
}
@@ -376,7 +373,6 @@ async fn run_worker(
mut cmd_rx: mpsc::UnboundedReceiver<WorkerCmd>,
desc_tx: watch::Sender<PttBackendDescriptor>,
initial_class_hint: PttInputClass,
bind_on_start: bool,
) {
// Open an async D-Bus session connection. If this fails we
// emit a warning and exit; the descriptor stays at L0Focused
@@ -421,13 +417,26 @@ async fn run_worker(
"linux ptt: portal session created"
);
if bind_on_start {
run_bind_shortcut(&proxy, &conn, &session_handle, &desc_tx, initial_class_hint).await;
} else {
info!(
target: "chanora_audio",
"linux ptt: portal session ready; waiting for explicit BindShortcuts request"
);
// Bind the initial shortcut. The portal opens its own dialog.
match bind_shortcut(&proxy, &conn, &session_handle).await {
Ok(class) => publish_bound(&desc_tx, class.unwrap_or(initial_class_hint)),
Err(BindError::Cancelled) => {
warn!(
target: "chanora_audio",
bind_status = "cancelled",
"linux ptt: user cancelled BindShortcuts; descriptor stays at L0Focused"
);
publish_l0(&desc_tx);
}
Err(BindError::Failed(e)) => {
warn!(
target: "chanora_audio",
bind_status = "failed",
error = %e,
"linux ptt: BindShortcuts failed; descriptor stays at L0Focused"
);
publish_l0(&desc_tx);
}
}
// Subscribe to Activated / Deactivated signals scoped to the
@@ -459,8 +468,12 @@ async fn run_worker(
tokio::select! {
cmd = cmd_rx.recv() => {
match cmd {
Some(WorkerCmd::Bind) => {
run_bind_shortcut(&proxy, &conn, &session_handle, &desc_tx, initial_class_hint).await;
Some(WorkerCmd::Rebind) => {
match bind_shortcut(&proxy, &conn, &session_handle).await {
Ok(class) => publish_bound(&desc_tx, class.unwrap_or(initial_class_hint)),
Err(BindError::Cancelled) => publish_l0(&desc_tx),
Err(BindError::Failed(_)) => publish_l0(&desc_tx),
}
}
Some(WorkerCmd::Stop) | None => {
// Close the portal session via the
@@ -573,35 +586,6 @@ async fn bind_shortcut(
Ok(class)
}
async fn run_bind_shortcut(
proxy: &GlobalShortcutsProxy<'_>,
conn: &AsyncConnection,
session_handle: &zbus::zvariant::OwnedObjectPath,
desc_tx: &watch::Sender<PttBackendDescriptor>,
class_hint: PttInputClass,
) {
match bind_shortcut(proxy, conn, session_handle).await {
Ok(class) => publish_bound(desc_tx, class.unwrap_or(class_hint)),
Err(BindError::Cancelled) => {
warn!(
target: "chanora_audio",
bind_status = "cancelled",
"linux ptt: user cancelled BindShortcuts; descriptor stays at L0Focused"
);
publish_l0(desc_tx);
}
Err(BindError::Failed(e)) => {
warn!(
target: "chanora_audio",
bind_status = "failed",
error = %e,
"linux ptt: BindShortcuts failed; descriptor stays at L0Focused"
);
publish_l0(desc_tx);
}
}
}
/// Wait for the `Response` signal on `request_path`. Returns the
/// `results` dict on success (response code 0) or an Error
/// otherwise.
+203
View File
@@ -0,0 +1,203 @@
//! Linux output stream via SDL2 (Qint / upstream `tsclientlib`
//! `ts_to_audio` pattern).
//!
//! ## Why SDL2 and not cpal on Linux
//!
//! cpal's Linux backend opens raw ALSA's `default` PCM. On most
//! modern distributions (Arch with `pipewire-alsa`, Fedora 38+,
//! Debian/Ubuntu with PipeWire) that virtual device still goes
//! through ALSA's `dmix` + `plug` layers when bypassing the
//! PulseAudio/PipeWire client. The `plug` layer's default
//! resampler is **nearest-neighbour**, which causes pronounced
//! aliasing on the 48 kHz → 44.1 kHz step that the
//! `tsclientlib::AudioHandler` output requires. Users perceive
//! this as constant crackling and popping. cpal also picks a
//! small default period size (≈256 frames), which leaves no
//! headroom for kernel scheduler jitter and causes additional
//! xruns.
//!
//! SDL2 on the same systems opens the audio device through the
//! SDL audio driver — which prefers PulseAudio when available
//! and falls back to ALSA otherwise. On a PipeWire box the SDL
//! PulseAudio driver lands inside PipeWire's PulseAudio
//! compatibility layer, whose resampler is high quality. SDL
//! also defaults to a buffer ≈ samples-requested, so we get
//! exactly one Opus frame (20 ms) per callback.
//!
//! This file replaces the cpal output path on Linux only. The
//! capture path stays on cpal until we have a reason to swap it
//! (the user reports outbound is currently fine). Windows continues
//! to use cpal's WASAPI backend; Apple platforms use direct
//! VoiceProcessingIO AudioUnits for the voice path.
//!
//! ## Threading & lifecycle
//!
//! `sdl2::AudioDevice<CB>` is `!Send + !Sync` — the SDL audio
//! lock is associated with the calling thread. We open the device
//! on the same thread that calls `Self::start_with_gate` (the
//! tokio worker that runs `chanora_core::ChanoraSession::start_audio`)
//! and never move it. The outer `AudioEngine` already carries an
//! `unsafe impl Send` to bypass cpal's identical constraint; we
//! reuse that and stash the SDL device behind a `Mutex<Option<…>>`
//! the same way cpal does.
//!
//! Dropping the `SdlOutput` closes the SDL device cleanly and
//! drops the playback callback — that releases the
//! `Arc<Mutex<AudioHandler>>` clone the callback held.
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use sdl2::audio::{AudioCallback, AudioDevice, AudioSpecDesired};
use sdl2::AudioSubsystem;
use tracing::{info, warn};
use tsclientlib::audio::AudioHandler;
use crate::engine::SessionAudioId;
use crate::AudioError;
/// 20 ms at 48 kHz mono == one Opus frame's worth of samples.
/// Stereo doubles the byte count but the frame-count stays the
/// same. Aligning the callback size to the Opus frame keeps the
/// jitter-buffer / playback handshake tight (no fractional frame
/// reads inside fill_buffer).
const FRAME_SAMPLES: u16 = 960;
/// SDL output device wrapper. Holds the live `AudioDevice` so its
/// callback keeps firing for the engine's lifetime, plus a
/// reference to the same `AudioHandler` the inbound forwarder
/// pushes into.
pub struct SdlOutput {
// Drop order: device first (stops the callback), then any
// remaining references release naturally. We keep `_subsystem`
// alive because dropping the AudioSubsystem before the device
// would invalidate SDL's internal state.
device: AudioDevice<TsPlaybackCallback>,
_subsystem: AudioSubsystem,
}
impl SdlOutput {
/// Open the default SDL playback device at the AudioHandler's
/// native format (48 kHz stereo f32) and start the device
/// playing immediately. The callback drains the AudioHandler
/// directly — no user-side resampling.
///
/// `output_gain` is read on every callback to apply the
/// master-volume slider; `output_muted` zeroes the output (but
/// still drains AudioHandler so its jitter buffer doesn't grow
/// unbounded while muted). These two atomics share the same
/// definitions the cpal path uses, so the same FFI surface
/// (`set_output_gain` / `set_output_muted`) drives both.
pub fn start(
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>,
) -> Result<Self, AudioError> {
let sdl = sdl2::init().map_err(|e| AudioError::Backend(format!("sdl init: {e}")))?;
let subsystem = sdl
.audio()
.map_err(|e| AudioError::Backend(format!("sdl audio subsystem: {e}")))?;
info!(
target: "chanora_audio",
driver = subsystem.current_audio_driver(),
"sdl audio subsystem initialised"
);
let desired = AudioSpecDesired {
freq: Some(48_000),
channels: Some(2),
samples: Some(FRAME_SAMPLES),
};
let device = AudioDevice::open_playback(&subsystem, None, &desired, |spec| {
info!(
target: "chanora_audio",
freq = spec.freq,
channels = spec.channels,
samples = spec.samples,
"sdl playback spec accepted"
);
TsPlaybackCallback {
handler,
output_gain,
output_muted,
}
})
.map_err(|e| AudioError::Backend(format!("sdl open_playback: {e}")))?;
// Begin pumping audio frames out. SDL's device starts paused;
// resume() flips it into the playing state. The callback
// will fire repeatedly at ~50 Hz (every 20 ms) thereafter.
device.resume();
Ok(Self {
device,
_subsystem: subsystem,
})
}
}
impl Drop for SdlOutput {
fn drop(&mut self) {
// AudioDevice::drop closes the device which stops the
// callback. We log so the chanora.log timeline matches
// engine shutdown.
warn!(target: "chanora_audio", "sdl playback device closing");
}
}
/// Playback callback invoked by SDL's audio thread. The shape
/// mirrors the upstream `tsclientlib` example's `SdlCallback`:
/// zero the output buffer (so silent regions emit silence rather
/// than stale memory), then ask the `AudioHandler` to fill in
/// whatever decoded frames it has buffered.
struct TsPlaybackCallback {
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>,
}
impl AudioCallback for TsPlaybackCallback {
type Channel = f32;
fn callback(&mut self, buffer: &mut [f32]) {
// The buffer is interleaved stereo at 48 kHz from SDL.
// Length is FRAME_SAMPLES * 2 (= 1920 f32) per the spec
// we requested.
for sample in buffer.iter_mut() {
*sample = 0.0;
}
// Lock window kept as tight as the upstream example —
// fill_buffer does the actual Opus decode + jitter logic.
// Contention with the inbound forwarder is the same as
// in the cpal path, but the upstream design has shipped
// this way for years.
{
let mut data = self.handler.lock().unwrap();
let _removed_ids = data.fill_buffer(buffer);
// `_removed_ids` is the list of clients whose stream the
// handler just finished draining. We could publish that
// upward as a "stopped speaking" hint, but the existing
// BridgeEvent::VoiceState already covers that case via
// the bridge layer; ignoring matches upstream behaviour.
}
// Apply local mute + master gain after fill so the jitter
// buffer still drains while muted (matching the cpal path's
// contract). `output_gain` is encoded as f32 bits inside an
// AtomicU32 — same encoding the cpal path uses.
if self.output_muted.load(Ordering::Relaxed) {
for sample in buffer.iter_mut() {
*sample = 0.0;
}
return;
}
let gain = f32::from_bits(self.output_gain.load(Ordering::Relaxed));
if (gain - 1.0).abs() > f32::EPSILON {
for sample in buffer.iter_mut() {
*sample *= gain;
}
}
}
}
+2 -137
View File
@@ -33,16 +33,12 @@
//! ## Fallback
//!
//! `try_new` returns `None` when the model file is missing, the ONNX
//! Runtime is unavailable, or the platform cannot load ONNX Runtime. The caller
//! Runtime is unavailable, or the platform is not iOS/macOS. The caller
//! falls back to `WebRtcFallbackVad`.
use super::{VadOutput, VoiceActivityDetector};
#[cfg(target_os = "linux")]
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::Arc;
#[cfg(target_os = "linux")]
use std::sync::OnceLock;
use std::thread::JoinHandle;
/// 16 kHz frame size for Silero VAD v6 (32 ms).
@@ -92,7 +88,7 @@ impl SileroOnnxVad {
/// Attempt to load the Silero v6 ONNX model from `model_path`.
///
/// Returns `None` when the model file is missing, the ONNX Runtime
/// is unavailable, or the platform cannot initialize ONNX Runtime.
/// is unavailable, or the platform does not support ONNX.
pub fn try_new(model_path: &str) -> Option<Self> {
Self::try_new_onnx(model_path)
}
@@ -109,17 +105,6 @@ impl SileroOnnxVad {
return None;
}
#[cfg(target_os = "linux")]
if let Err(err) = ensure_linux_onnxruntime_loaded() {
error!(
target: "chanora_audio",
path = model_path,
error = %err,
"SileroOnnxVad: ONNX Runtime is unavailable on Linux; falling back to WebRtcFallbackVad"
);
return None;
}
let session_result = std::panic::catch_unwind(|| {
ort::session::Session::builder().and_then(|mut b| b.commit_from_file(model_path))
});
@@ -316,111 +301,6 @@ impl VoiceActivityDetector for SileroOnnxVad {
}
}
#[cfg(target_os = "linux")]
fn ensure_linux_onnxruntime_loaded() -> Result<(), String> {
static ORT_INIT: OnceLock<Result<(), String>> = OnceLock::new();
ORT_INIT
.get_or_init(|| {
let dylib_path = linux_onnxruntime_dylib_path();
match dylib_path.as_ref() {
Some(path) => {
tracing::info!(
target: "chanora_audio",
path = %path.display(),
"SileroOnnxVad: loading Linux ONNX Runtime from discovered shared library"
);
ort::init_from(path)
.map_err(|e| format!("init_from({}): {e}", path.display()))?
.with_name("chanora_audio")
.commit()
.then_some(())
.ok_or_else(|| {
format!("commit(init_from {}): environment already initialized", path.display())
})
}
None => {
tracing::warn!(
target: "chanora_audio",
"SileroOnnxVad: no explicit Linux ONNX Runtime shared library path found; trying loader default"
);
ort::init()
.with_name("chanora_audio")
.commit()
.then_some(())
.ok_or_else(|| "commit(init): environment already initialized".to_string())
}
}
})
.clone()
}
#[cfg(target_os = "linux")]
fn linux_onnxruntime_dylib_path() -> Option<PathBuf> {
if let Some(path) = existing_env_file("ORT_DYLIB_PATH") {
return Some(path);
}
let mut candidates = Vec::new();
if let Ok(exe) = std::env::current_exe() {
if let Some(exe_dir) = exe.parent() {
candidates.push(exe_dir.join("lib").join("libonnxruntime.so"));
candidates.push(exe_dir.join("libonnxruntime.so"));
}
}
if let Ok(cwd) = std::env::current_dir() {
candidates.push(cwd.join("libonnxruntime.so"));
}
candidates.extend([
PathBuf::from("/usr/lib/libonnxruntime.so"),
PathBuf::from("/usr/lib64/libonnxruntime.so"),
PathBuf::from("/usr/local/lib/libonnxruntime.so"),
PathBuf::from("/lib/x86_64-linux-gnu/libonnxruntime.so"),
PathBuf::from("/usr/lib/x86_64-linux-gnu/libonnxruntime.so"),
PathBuf::from("/lib/aarch64-linux-gnu/libonnxruntime.so"),
PathBuf::from("/usr/lib/aarch64-linux-gnu/libonnxruntime.so"),
]);
for candidate in candidates {
if candidate.is_file() {
return Some(candidate);
}
}
first_matching_dir_entry("/usr/lib", "libonnxruntime.so")
.or_else(|| first_matching_dir_entry("/usr/lib64", "libonnxruntime.so"))
.or_else(|| first_matching_dir_entry("/usr/local/lib", "libonnxruntime.so"))
.or_else(|| first_matching_dir_entry("/usr/lib/x86_64-linux-gnu", "libonnxruntime.so"))
.or_else(|| first_matching_dir_entry("/usr/lib/aarch64-linux-gnu", "libonnxruntime.so"))
}
#[cfg(target_os = "linux")]
fn existing_env_file(name: &str) -> Option<PathBuf> {
let path = std::env::var_os(name).map(PathBuf::from)?;
path.is_file().then_some(path)
}
#[cfg(target_os = "linux")]
fn first_matching_dir_entry(dir: &str, prefix: &str) -> Option<PathBuf> {
let mut matches: Vec<PathBuf> = std::fs::read_dir(dir)
.ok()?
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| {
path.is_file()
&& path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with(prefix))
})
.collect();
matches.sort();
matches.into_iter().next()
}
// SAFETY: ONNX Runtime sessions are thread-safe for inference.
// State arrays are owned by this struct and accessed only from
// the single capture callback thread.
@@ -521,8 +401,6 @@ impl Drop for SileroOnnxVadWorker {
#[cfg(test)]
mod tests {
use super::*;
#[cfg(target_os = "linux")]
use std::path::PathBuf;
fn make_stub_vad() -> SileroOnnxVad {
SileroOnnxVad {
@@ -618,17 +496,4 @@ mod tests {
&completed_frame[SILERO_FRAME_16K - SILERO_CONTEXT_16K..]
);
}
#[cfg(target_os = "linux")]
#[test]
fn existing_env_file_ignores_missing_paths() {
let var_name = format!("CHANORA_TEST_ORT_{}", std::process::id());
std::env::remove_var(&var_name);
assert!(existing_env_file(&var_name).is_none());
let missing = PathBuf::from("/definitely/missing/libonnxruntime.so");
std::env::set_var(&var_name, &missing);
assert!(existing_env_file(&var_name).is_none());
std::env::remove_var(&var_name);
}
}
+117 -13
View File
@@ -355,8 +355,6 @@ pub struct BridgeChannel {
pub struct BridgeClient {
/// Stable client id.
pub id: u64,
/// Stable TeamSpeak unique identifier.
pub uid: String,
/// Channel id the client is currently in.
pub channel: u64,
/// Nickname.
@@ -375,6 +373,61 @@ pub struct BridgeClient {
pub talk_power_granted: bool,
}
/// Rich profile and live connection details for one online client.
#[derive(Debug, Clone)]
pub struct BridgeClientProfile {
/// Stable online client id.
pub id: u64,
/// Channel the client is currently in.
pub channel: u64,
/// Nickname.
pub name: String,
/// TeamSpeak unique id.
pub unique_id: String,
/// Stable TeamSpeak database id, when visible.
pub database_id: Option<u64>,
/// ISO country code, when visible.
pub country_code: String,
/// User description, when visible.
pub description: String,
/// Client version string.
pub version: String,
/// Client platform string.
pub platform: String,
/// Account creation time as Unix seconds.
pub created_unix_seconds: Option<i64>,
/// Last connection time as Unix seconds.
pub last_connected_unix_seconds: Option<i64>,
/// Total historical connections.
pub connections_total: Option<u64>,
/// Current online duration in seconds.
pub online_seconds: Option<i64>,
/// Current idle time in milliseconds.
pub idle_milliseconds: Option<i64>,
/// Current ping in milliseconds.
pub ping_milliseconds: Option<i64>,
/// Client address. Empty when permission-gated.
pub client_address: String,
/// Resolved server group names.
pub server_groups: Vec<String>,
/// Resolved channel group name.
pub channel_group: String,
/// TeamSpeak avatar file path suffix.
pub avatar_path: String,
/// Downloaded bytes this month.
pub bytes_downloaded_month: Option<u64>,
/// Uploaded bytes this month.
pub bytes_uploaded_month: Option<u64>,
/// Downloaded bytes across all time.
pub bytes_downloaded_total: Option<u64>,
/// Uploaded bytes across all time.
pub bytes_uploaded_total: Option<u64>,
/// Client-to-server total packet loss ratio.
pub packet_loss_client_to_server_total: Option<f32>,
/// Server-to-client total packet loss ratio.
pub packet_loss_server_to_client_total: Option<f32>,
}
/// Server snapshot as seen by Dart.
#[derive(Debug, Clone)]
pub struct BridgeSnapshot {
@@ -396,6 +449,38 @@ pub struct BridgeSnapshot {
pub own_client_id: u64,
}
impl From<chanora_core::ClientProfile> for BridgeClientProfile {
fn from(profile: chanora_core::ClientProfile) -> Self {
Self {
id: profile.id.0,
channel: profile.channel.0,
name: profile.name,
unique_id: profile.unique_id,
database_id: profile.database_id,
country_code: profile.country_code,
description: profile.description,
version: profile.version,
platform: profile.platform,
created_unix_seconds: profile.created_unix_seconds,
last_connected_unix_seconds: profile.last_connected_unix_seconds,
connections_total: profile.connections_total,
online_seconds: profile.online_seconds,
idle_milliseconds: profile.idle_milliseconds,
ping_milliseconds: profile.ping_milliseconds,
client_address: profile.client_address,
server_groups: profile.server_groups,
channel_group: profile.channel_group,
avatar_path: profile.avatar_path,
bytes_downloaded_month: profile.bytes_downloaded_month,
bytes_uploaded_month: profile.bytes_uploaded_month,
bytes_downloaded_total: profile.bytes_downloaded_total,
bytes_uploaded_total: profile.bytes_uploaded_total,
packet_loss_client_to_server_total: profile.packet_loss_client_to_server_total,
packet_loss_server_to_client_total: profile.packet_loss_server_to_client_total,
}
}
}
impl From<chanora_core::ServerSnapshot> for BridgeSnapshot {
fn from(s: chanora_core::ServerSnapshot) -> Self {
Self {
@@ -420,7 +505,6 @@ impl From<chanora_core::ServerSnapshot> for BridgeSnapshot {
.into_iter()
.map(|c| BridgeClient {
id: c.id.0,
uid: c.uid,
channel: c.channel.0,
name: c.name,
input_muted: c.input_muted,
@@ -459,6 +543,7 @@ pub async fn connect(
},
identity: None,
ready_timeout: Duration::from_secs(15),
resolved_address: None,
};
let snap = match runtime()
.spawn(async move { session().connect(cfg).await })
@@ -475,6 +560,17 @@ pub async fn connect(
Ok(snap.into())
}
/// Warm server address resolution for the active host field. This is
/// intentionally fire-and-forget from the UI perspective: it schedules
/// Rust-side prefetch work and never opens a TS3 session.
pub async fn prefetch_server(host: String) -> Result<(), BridgeError> {
runtime()
.spawn(async move { session().prefetch_server(host).await })
.await
.map_err(|e| task_join_error("prefetch_server", e))??;
Ok(())
}
/// Re-fetch a fresh snapshot from the active connection.
pub async fn snapshot() -> Result<BridgeSnapshot, BridgeError> {
let snap = runtime()
@@ -484,6 +580,15 @@ pub async fn snapshot() -> Result<BridgeSnapshot, BridgeError> {
Ok(snap.into())
}
/// Fetch richer profile and live connection details for one online client.
pub async fn client_profile(client_id: u64) -> Result<BridgeClientProfile, BridgeError> {
let profile = runtime()
.spawn(async move { session().client_profile(client_id).await })
.await
.map_err(|e| task_join_error("client_profile", e))??;
Ok(profile.into())
}
/// Disconnect from the server. No-op if not connected.
pub async fn disconnect() -> Result<(), BridgeError> {
runtime()
@@ -816,9 +921,10 @@ pub async fn set_input_muted(muted: bool) -> Result<(), BridgeError> {
Ok(())
}
/// Toggle self output-mute (speaker). Mutes locally, informs the
/// server, and clamps local microphone transmit while speaker mute
/// is active so the user cannot continue talking while deafened.
/// Toggle self output-mute (speaker). Mutes locally *and* informs
/// the server. The server uses this for the channel icon next to
/// the client name; the local mute kicks in immediately even
/// before the server acknowledges.
pub async fn set_output_muted(muted: bool) -> Result<(), BridgeError> {
runtime()
.spawn(async move { session().set_self_muted(None, Some(muted)).await })
@@ -1229,7 +1335,8 @@ impl From<chanora_core::AudioProcessingStats> for BridgeAudioProcessingStats {
/// On non-Android targets or before a voice session opens the
/// section is omitted. Per SDD-090 every field in that section is
/// a device-side technical scalar — no PII admitted.
pub async fn export_diagnostics() -> String {
#[frb(sync)]
pub fn export_diagnostics() -> String {
let metadata = vec![
(
"crate_version".to_string(),
@@ -1245,15 +1352,12 @@ pub async fn export_diagnostics() -> String {
// diagnostics snapshot from the process-global slot published
// by AndroidVoiceUnit::open(). Returns None on non-Android and
// before any voice session has opened.
#[cfg(target_os = "android")]
let android_audio_yaml =
chanora_audio::mobile_voice_backend::current_android_audio_diagnostics()
.map(|d| d.to_yaml_fragment());
#[cfg(not(target_os = "android"))]
let android_audio_yaml: Option<String> = None;
let audio_health = session().audio_processing_stats().await.ok();
let network_info = session().network_diagnostics_summary().await;
let protocol_events = session().drain_protocol_events().await;
let audio_health = session().audio_processing_stats_if_ready();
let network_info = runtime().block_on(async { session().network_diagnostics_summary().await });
let protocol_events = runtime().block_on(async { session().protocol_events_snapshot().await });
let android_audio_yaml = android_audio_yaml.map(|mut yaml| {
if let Some(stats) = audio_health {
yaml.push_str(&format!(
+329 -62
View File
@@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
default_rust_auto_opaque = RustAutoOpaqueMoi,
);
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -560177922;
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 281698435;
// Section: executor
@@ -186,6 +186,42 @@ fn wire__crate__api__bridge_init_impl(
},
)
}
fn wire__crate__api__client_profile_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: "client_profile",
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_client_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::client_profile(api_client_id).await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__api__connect_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -370,16 +406,15 @@ fn wire__crate__api__events_stream_impl(
)
}
fn wire__crate__api__export_diagnostics_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::WireSyncRust2DartSse {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::<flutter_rust_bridge::for_generated::SseCodec, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "export_diagnostics",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
port: None,
mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync,
},
move || {
let message = unsafe {
@@ -392,15 +427,10 @@ fn wire__crate__api__export_diagnostics_impl(
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::export_diagnostics().await;
Ok(output_ok)
})()
.await,
)
}
transform_result_sse::<_, ()>((move || {
let output_ok = Result::<_, ()>::Ok(crate::api::export_diagnostics())?;
Ok(output_ok)
})())
},
)
}
@@ -880,6 +910,42 @@ fn wire__crate__api__move_to_channel_impl(
},
)
}
fn wire__crate__api__prefetch_server_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: "prefetch_server",
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_host = <String>::sse_decode(&mut deserializer);
deserializer.end();
move |context| async move {
transform_result_sse::<_, crate::BridgeError>(
(move || async move {
let output_ok = crate::api::prefetch_server(api_host).await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__api__ptt_descriptor_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -1946,7 +2012,6 @@ impl SseDecode for crate::api::BridgeClient {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut var_id = <u64>::sse_decode(deserializer);
let mut var_uid = <String>::sse_decode(deserializer);
let mut var_channel = <u64>::sse_decode(deserializer);
let mut var_name = <String>::sse_decode(deserializer);
let mut var_inputMuted = <bool>::sse_decode(deserializer);
@@ -1957,7 +2022,6 @@ impl SseDecode for crate::api::BridgeClient {
let mut var_talkPowerGranted = <bool>::sse_decode(deserializer);
return crate::api::BridgeClient {
id: var_id,
uid: var_uid,
channel: var_channel,
name: var_name,
input_muted: var_inputMuted,
@@ -1970,6 +2034,64 @@ impl SseDecode for crate::api::BridgeClient {
}
}
impl SseDecode for crate::api::BridgeClientProfile {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut var_id = <u64>::sse_decode(deserializer);
let mut var_channel = <u64>::sse_decode(deserializer);
let mut var_name = <String>::sse_decode(deserializer);
let mut var_uniqueId = <String>::sse_decode(deserializer);
let mut var_databaseId = <Option<u64>>::sse_decode(deserializer);
let mut var_countryCode = <String>::sse_decode(deserializer);
let mut var_description = <String>::sse_decode(deserializer);
let mut var_version = <String>::sse_decode(deserializer);
let mut var_platform = <String>::sse_decode(deserializer);
let mut var_createdUnixSeconds = <Option<i64>>::sse_decode(deserializer);
let mut var_lastConnectedUnixSeconds = <Option<i64>>::sse_decode(deserializer);
let mut var_connectionsTotal = <Option<u64>>::sse_decode(deserializer);
let mut var_onlineSeconds = <Option<i64>>::sse_decode(deserializer);
let mut var_idleMilliseconds = <Option<i64>>::sse_decode(deserializer);
let mut var_pingMilliseconds = <Option<i64>>::sse_decode(deserializer);
let mut var_clientAddress = <String>::sse_decode(deserializer);
let mut var_serverGroups = <Vec<String>>::sse_decode(deserializer);
let mut var_channelGroup = <String>::sse_decode(deserializer);
let mut var_avatarPath = <String>::sse_decode(deserializer);
let mut var_bytesDownloadedMonth = <Option<u64>>::sse_decode(deserializer);
let mut var_bytesUploadedMonth = <Option<u64>>::sse_decode(deserializer);
let mut var_bytesDownloadedTotal = <Option<u64>>::sse_decode(deserializer);
let mut var_bytesUploadedTotal = <Option<u64>>::sse_decode(deserializer);
let mut var_packetLossClientToServerTotal = <Option<f32>>::sse_decode(deserializer);
let mut var_packetLossServerToClientTotal = <Option<f32>>::sse_decode(deserializer);
return crate::api::BridgeClientProfile {
id: var_id,
channel: var_channel,
name: var_name,
unique_id: var_uniqueId,
database_id: var_databaseId,
country_code: var_countryCode,
description: var_description,
version: var_version,
platform: var_platform,
created_unix_seconds: var_createdUnixSeconds,
last_connected_unix_seconds: var_lastConnectedUnixSeconds,
connections_total: var_connectionsTotal,
online_seconds: var_onlineSeconds,
idle_milliseconds: var_idleMilliseconds,
ping_milliseconds: var_pingMilliseconds,
client_address: var_clientAddress,
server_groups: var_serverGroups,
channel_group: var_channelGroup,
avatar_path: var_avatarPath,
bytes_downloaded_month: var_bytesDownloadedMonth,
bytes_uploaded_month: var_bytesUploadedMonth,
bytes_downloaded_total: var_bytesDownloadedTotal,
bytes_uploaded_total: var_bytesUploadedTotal,
packet_loss_client_to_server_total: var_packetLossClientToServerTotal,
packet_loss_server_to_client_total: var_packetLossServerToClientTotal,
};
}
}
impl SseDecode for crate::api::BridgeEffectOwner {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -2351,6 +2473,18 @@ impl SseDecode for i64 {
}
}
impl SseDecode for Vec<String> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut len_ = <i32>::sse_decode(deserializer);
let mut ans_ = Vec::with_capacity(len_ as usize);
for idx_ in 0..len_ {
ans_.push(<String>::sse_decode(deserializer));
}
return ans_;
}
}
impl SseDecode for Vec<crate::api::BridgeAudioDevice> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -2435,6 +2569,17 @@ impl SseDecode for Option<crate::api::BridgeVoiceJoinErrorCode> {
}
}
impl SseDecode for Option<f32> {
// 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(<f32>::sse_decode(deserializer));
} else {
return None;
}
}
}
impl SseDecode for Option<i32> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -2446,6 +2591,17 @@ impl SseDecode for Option<i32> {
}
}
impl SseDecode for Option<i64> {
// 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(<i64>::sse_decode(deserializer));
} else {
return None;
}
}
}
impl SseDecode for Option<u64> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -2510,43 +2666,44 @@ fn pde_ffi_dispatcher_primary_impl(
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),
4 => wire__crate__api__bridge_init_impl(port, ptr, rust_vec_len, data_len),
5 => wire__crate__api__connect_impl(port, ptr, rust_vec_len, data_len),
6 => wire__crate__api__delete_bookmark_impl(port, ptr, rust_vec_len, data_len),
7 => wire__crate__api__disconnect_impl(port, ptr, rust_vec_len, data_len),
8 => wire__crate__api__enable_audio_debug_wav_dump_impl(port, ptr, rust_vec_len, data_len),
9 => wire__crate__api__events_stream_impl(port, ptr, rust_vec_len, data_len),
10 => wire__crate__api__export_diagnostics_impl(port, ptr, rust_vec_len, data_len),
11 => wire__crate__api__get_audio_processing_config_impl(port, ptr, rust_vec_len, data_len),
12 => wire__crate__api__get_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
13 => wire__crate__api__get_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
14 => wire__crate__api__get_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
19 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len),
20 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
21 => wire__crate__api__list_audio_devices_impl(port, ptr, rust_vec_len, data_len),
22 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len),
24 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len),
25 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len),
27 => wire__crate__api__send_chat_message_impl(port, ptr, rust_vec_len, data_len),
29 => wire__crate__api__set_audio_processing_config_impl(port, ptr, rust_vec_len, data_len),
30 => wire__crate__api__set_client_volume_impl(port, ptr, rust_vec_len, data_len),
31 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len),
32 => wire__crate__api__set_input_device_impl(port, ptr, rust_vec_len, data_len),
33 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len),
34 => {
5 => wire__crate__api__client_profile_impl(port, ptr, rust_vec_len, data_len),
6 => wire__crate__api__connect_impl(port, ptr, rust_vec_len, data_len),
7 => wire__crate__api__delete_bookmark_impl(port, ptr, rust_vec_len, data_len),
8 => wire__crate__api__disconnect_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),
10 => wire__crate__api__events_stream_impl(port, ptr, rust_vec_len, data_len),
12 => wire__crate__api__get_audio_processing_config_impl(port, ptr, rust_vec_len, data_len),
13 => wire__crate__api__get_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
14 => wire__crate__api__get_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
15 => wire__crate__api__get_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
20 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len),
21 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
22 => wire__crate__api__list_audio_devices_impl(port, ptr, rust_vec_len, data_len),
23 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len),
25 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len),
26 => wire__crate__api__prefetch_server_impl(port, ptr, rust_vec_len, data_len),
27 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len),
29 => wire__crate__api__send_chat_message_impl(port, ptr, rust_vec_len, data_len),
31 => wire__crate__api__set_audio_processing_config_impl(port, ptr, rust_vec_len, data_len),
32 => wire__crate__api__set_client_volume_impl(port, ptr, rust_vec_len, data_len),
33 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len),
34 => wire__crate__api__set_input_device_impl(port, ptr, rust_vec_len, data_len),
35 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len),
36 => {
wire__crate__api__set_ios_voice_processing_mode_impl(port, ptr, rust_vec_len, data_len)
}
36 => wire__crate__api__set_output_device_impl(port, ptr, rust_vec_len, data_len),
37 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len),
38 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len),
39 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
40 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
41 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
42 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
43 => wire__crate__api__set_vad_model_path_impl(port, ptr, rust_vec_len, data_len),
44 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
45 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len),
46 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len),
47 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len),
38 => wire__crate__api__set_output_device_impl(port, ptr, rust_vec_len, data_len),
39 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len),
40 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len),
41 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
42 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
43 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
44 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
45 => wire__crate__api__set_vad_model_path_impl(port, ptr, rust_vec_len, data_len),
46 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
47 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len),
48 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len),
49 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@@ -2559,18 +2716,19 @@ fn pde_ffi_dispatcher_sync_impl(
) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse {
// Codec=Pde (Serialization + dispatch), see doc to use other codecs
match func_id {
15 => wire__crate__api__handle_interruption_began_impl(ptr, rust_vec_len, data_len),
16 => wire__crate__api__handle_interruption_ended_impl(ptr, rust_vec_len, data_len),
17 => wire__crate__api__handle_media_services_reset_with_route_impl(
11 => 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),
17 => wire__crate__api__handle_interruption_ended_impl(ptr, rust_vec_len, data_len),
18 => wire__crate__api__handle_media_services_reset_with_route_impl(
ptr,
rust_vec_len,
data_len,
),
18 => wire__crate__api__handle_route_change_impl(ptr, rust_vec_len, data_len),
23 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len),
26 => wire__crate__api__record_lifecycle_event_impl(ptr, rust_vec_len, data_len),
28 => wire__crate__api__set_audio_output_route_impl(ptr, rust_vec_len, data_len),
35 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
19 => wire__crate__api__handle_route_change_impl(ptr, rust_vec_len, data_len),
24 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len),
28 => wire__crate__api__record_lifecycle_event_impl(ptr, rust_vec_len, data_len),
30 => wire__crate__api__set_audio_output_route_impl(ptr, rust_vec_len, data_len),
37 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@@ -2804,7 +2962,6 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeClient {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
[
self.id.into_into_dart().into_dart(),
self.uid.into_into_dart().into_dart(),
self.channel.into_into_dart().into_dart(),
self.name.into_into_dart().into_dart(),
self.input_muted.into_into_dart().into_dart(),
@@ -2824,6 +2981,56 @@ impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeClient> for crate::api:
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::BridgeClientProfile {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
[
self.id.into_into_dart().into_dart(),
self.channel.into_into_dart().into_dart(),
self.name.into_into_dart().into_dart(),
self.unique_id.into_into_dart().into_dart(),
self.database_id.into_into_dart().into_dart(),
self.country_code.into_into_dart().into_dart(),
self.description.into_into_dart().into_dart(),
self.version.into_into_dart().into_dart(),
self.platform.into_into_dart().into_dart(),
self.created_unix_seconds.into_into_dart().into_dart(),
self.last_connected_unix_seconds
.into_into_dart()
.into_dart(),
self.connections_total.into_into_dart().into_dart(),
self.online_seconds.into_into_dart().into_dart(),
self.idle_milliseconds.into_into_dart().into_dart(),
self.ping_milliseconds.into_into_dart().into_dart(),
self.client_address.into_into_dart().into_dart(),
self.server_groups.into_into_dart().into_dart(),
self.channel_group.into_into_dart().into_dart(),
self.avatar_path.into_into_dart().into_dart(),
self.bytes_downloaded_month.into_into_dart().into_dart(),
self.bytes_uploaded_month.into_into_dart().into_dart(),
self.bytes_downloaded_total.into_into_dart().into_dart(),
self.bytes_uploaded_total.into_into_dart().into_dart(),
self.packet_loss_client_to_server_total
.into_into_dart()
.into_dart(),
self.packet_loss_server_to_client_total
.into_into_dart()
.into_dart(),
]
.into_dart()
}
}
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive
for crate::api::BridgeClientProfile
{
}
impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeClientProfile>
for crate::api::BridgeClientProfile
{
fn into_into_dart(self) -> crate::api::BridgeClientProfile {
self
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::BridgeEffectOwner {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
match self {
@@ -3443,7 +3650,6 @@ impl SseEncode for crate::api::BridgeClient {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<u64>::sse_encode(self.id, serializer);
<String>::sse_encode(self.uid, serializer);
<u64>::sse_encode(self.channel, serializer);
<String>::sse_encode(self.name, serializer);
<bool>::sse_encode(self.input_muted, serializer);
@@ -3455,6 +3661,37 @@ impl SseEncode for crate::api::BridgeClient {
}
}
impl SseEncode for crate::api::BridgeClientProfile {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<u64>::sse_encode(self.id, serializer);
<u64>::sse_encode(self.channel, serializer);
<String>::sse_encode(self.name, serializer);
<String>::sse_encode(self.unique_id, serializer);
<Option<u64>>::sse_encode(self.database_id, serializer);
<String>::sse_encode(self.country_code, serializer);
<String>::sse_encode(self.description, serializer);
<String>::sse_encode(self.version, serializer);
<String>::sse_encode(self.platform, serializer);
<Option<i64>>::sse_encode(self.created_unix_seconds, serializer);
<Option<i64>>::sse_encode(self.last_connected_unix_seconds, serializer);
<Option<u64>>::sse_encode(self.connections_total, serializer);
<Option<i64>>::sse_encode(self.online_seconds, serializer);
<Option<i64>>::sse_encode(self.idle_milliseconds, serializer);
<Option<i64>>::sse_encode(self.ping_milliseconds, serializer);
<String>::sse_encode(self.client_address, serializer);
<Vec<String>>::sse_encode(self.server_groups, serializer);
<String>::sse_encode(self.channel_group, serializer);
<String>::sse_encode(self.avatar_path, serializer);
<Option<u64>>::sse_encode(self.bytes_downloaded_month, serializer);
<Option<u64>>::sse_encode(self.bytes_uploaded_month, serializer);
<Option<u64>>::sse_encode(self.bytes_downloaded_total, serializer);
<Option<u64>>::sse_encode(self.bytes_uploaded_total, serializer);
<Option<f32>>::sse_encode(self.packet_loss_client_to_server_total, serializer);
<Option<f32>>::sse_encode(self.packet_loss_server_to_client_total, serializer);
}
}
impl SseEncode for crate::api::BridgeEffectOwner {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@@ -3828,6 +4065,16 @@ impl SseEncode for i64 {
}
}
impl SseEncode for Vec<String> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<i32>::sse_encode(self.len() as _, serializer);
for item in self {
<String>::sse_encode(item, serializer);
}
}
}
impl SseEncode for Vec<crate::api::BridgeAudioDevice> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@@ -3898,6 +4145,16 @@ impl SseEncode for Option<crate::api::BridgeVoiceJoinErrorCode> {
}
}
impl SseEncode for Option<f32> {
// 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 {
<f32>::sse_encode(value, serializer);
}
}
}
impl SseEncode for Option<i32> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@@ -3908,6 +4165,16 @@ impl SseEncode for Option<i32> {
}
}
impl SseEncode for Option<i64> {
// 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 {
<i64>::sse_encode(value, serializer);
}
}
}
impl SseEncode for Option<u64> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
+18
View File
@@ -774,6 +774,11 @@ impl ProtocolEventRecorder {
pub fn drain(&mut self) -> Vec<String> {
std::mem::take(&mut self.events)
}
/// Snapshot all recorded events without clearing the buffer.
pub fn snapshot(&self) -> Vec<String> {
self.events.clone()
}
}
impl Default for ProtocolEventRecorder {
@@ -1080,4 +1085,17 @@ mod tests {
"explicit with_android_audio(None) must also omit the section"
);
}
#[test]
fn protocol_event_snapshot_does_not_drain_buffer() {
let mut recorder = ProtocolEventRecorder::new(4);
recorder.record_connected("Server");
let first = recorder.snapshot();
let second = recorder.snapshot();
let drained = recorder.drain();
assert_eq!(first, second);
assert_eq!(drained, first);
}
}
+8 -9
View File
@@ -13,8 +13,9 @@ publish.workspace = true
thiserror.workspace = true
tracing.workspace = true
serde.workspace = true
base64 = "0.22"
chanora_resolver = { path = "../chanora_resolver" }
base64 = "0.22"
time = "0.3"
# tsclientlib is git-only and not on crates.io. The "audio" feature
# pulls in `audiopus` only — `sdl2` is a dev-dep used by upstream
@@ -32,14 +33,12 @@ tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"
futures = "0.3"
async-trait = "0.1"
[target.'cfg(any(target_os = "ios", target_os = "macos", target_os = "windows"))'.dependencies]
# Apple + Windows keep the platform TLS backend (Security.framework /
# SChannel). iOS in particular avoids the rustls/aws-lc path here.
[target.'cfg(not(target_os = "android"))'.dependencies]
# Desktop/iOS: native TLS maps to the platform TLS backend (Security.framework
# on Apple, SChannel on Windows, system OpenSSL on Linux/BSD).
reqwest = { version = "0.13", default-features = false, features = ["charset", "http2", "native-tls"] }
[target.'cfg(any(target_os = "android", target_os = "linux"))'.dependencies]
# Android + Linux avoid a system OpenSSL dependency and use rustls instead.
# This keeps Linux CI / dev hosts buildable without `openssl-devel`, while
# preserving the native-tls path for Apple targets where rustls/aws-lc has
# been problematic.
[target.'cfg(target_os = "android")'.dependencies]
# 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.
reqwest = { version = "0.13", default-features = false, features = ["charset", "http2", "rustls"] }
+439 -98
View File
@@ -28,21 +28,44 @@ use tokio::sync::{mpsc, oneshot};
use tracing::{info, warn};
use tsclientlib::data::{self, Channel, Client};
use tsclientlib::messages::s2c::{InClientDbInfoPart, InMessage};
use tsclientlib::prelude::*;
use tsclientlib::{
ChannelId as TsChannelId, Connection, DisconnectOptions, Identity, MessageHandle,
OutCommandExt, StreamItem, Version,
ChannelId as TsChannelId, ClientId as TsClientId, Connection, DisconnectOptions, Identity,
MessageHandle, OutCommandExt, StreamItem, Version,
};
use tsproto_packets::packets::{InAudioBuf, OutPacket};
use tsproto_packets::packets::{Direction, Flags, InAudioBuf, OutCommand, OutPacket, PacketType};
use tsproto_types::ClientType;
use crate::dto::{
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, MessageTarget, ServerActivity,
ServerSnapshot,
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, ClientProfile, MessageTarget,
ServerActivity, ServerSnapshot,
};
use crate::ProtocolError;
const SPEAKING_ACTIVITY_WINDOW: Duration = Duration::from_millis(750);
const INBOUND_VOICE_SEND_TIMEOUT: Duration = Duration::from_millis(40);
#[derive(Debug, PartialEq, Eq)]
enum SendTimeoutError<T> {
Timeout(T),
Closed(T),
}
async fn send_with_timeout<T: Send>(
tx: &mpsc::Sender<T>,
value: T,
timeout_duration: Duration,
) -> Result<(), SendTimeoutError<T>> {
match tokio::time::timeout(timeout_duration, tx.reserve()).await {
Ok(Ok(permit)) => {
permit.send(value);
Ok(())
}
Ok(Err(_)) => Err(SendTimeoutError::Closed(value)),
Err(_) => Err(SendTimeoutError::Timeout(value)),
}
}
/// Pick the TeamSpeak `client_version`/platform/signature triple
/// (sourced from `ReSpeak/tsdeclarations/Versions.csv`, baked into
@@ -116,6 +139,11 @@ pub struct ConnectConfig {
/// How long to wait for the initial state snapshot before
/// returning `ProtocolError::Timeout`.
pub ready_timeout: Duration,
/// Optional already-resolved socket address from core's invisible
/// prefetch cache. When present, the protocol layer skips address
/// resolution but still opens a normal TS3 connection only after
/// the user requested Connect.
pub resolved_address: Option<std::net::SocketAddr>,
}
impl Default for ConnectConfig {
@@ -126,6 +154,7 @@ impl Default for ConnectConfig {
password: None,
identity: None,
ready_timeout: Duration::from_secs(10),
resolved_address: None,
}
}
}
@@ -159,6 +188,11 @@ enum Request {
/// Reply channel for outcome.
reply: oneshot::Sender<Result<(), ProtocolError>>,
},
/// Fetch richer profile/connection details for an online client.
FetchClientProfile {
client_id: u64,
reply: oneshot::Sender<Result<ClientProfile, ProtocolError>>,
},
}
/// Why a [`ProtocolClient`] task ended. Distinguishes a user-driven
@@ -297,6 +331,20 @@ impl ProtocolClient {
.map_err(|_| ProtocolError::Lost("snapshot reply dropped".to_string()))?
}
/// Fetch richer profile and live connection details for one online client.
pub async fn client_profile(&self, client_id: u64) -> Result<ClientProfile, ProtocolError> {
let (tx, rx) = oneshot::channel();
self.tx
.send(Request::FetchClientProfile {
client_id,
reply: tx,
})
.await
.map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))?;
rx.await
.map_err(|_| ProtocolError::Lost("client_profile reply dropped".to_string()))?
}
/// Disconnect cleanly. Blocks until the task exits.
pub async fn disconnect(self) {
let (tx, rx) = oneshot::channel();
@@ -493,16 +541,30 @@ async fn connection_task(
}};
}
let resolved = match resolve_server_socket(&cfg.address).await {
Ok(addr) => addr,
Err(err) => fail_ready!(err),
let resolved = match server_socket_from_config(&cfg) {
Some(addr) => {
info!(
target: "chanora_protocol",
input = %cfg.address,
resolved = %addr,
"using prefetched server address"
);
addr
}
None => {
let addr = match resolve_server_socket(&cfg.address).await {
Ok(addr) => addr,
Err(err) => fail_ready!(err),
};
info!(
target: "chanora_protocol",
input = %cfg.address,
resolved = %addr,
"server address resolved"
);
addr
}
};
info!(
target: "chanora_protocol",
input = %cfg.address,
resolved = %resolved,
"server address resolved"
);
// Pass the resolved SocketAddr directly to tsclientlib so it
// skips its own resolver entirely (tsclientlib accepts
@@ -619,14 +681,6 @@ async fn connection_task(
std::time::Instant,
),
> = HashMap::new();
let mut pending_text_messages: HashMap<
MessageHandle,
(
MessageTarget,
oneshot::Sender<Result<(), ProtocolError>>,
std::time::Instant,
),
> = HashMap::new();
let mut voice_activity: HashMap<u64, Instant> = HashMap::new();
// Main loop: pump events, service requests, forward voice.
@@ -650,14 +704,33 @@ async fn connection_task(
let from = packet_sender_id(&buf);
if let Some(from) = from {
voice_activity.insert(from, Instant::now());
if voice_in_tx
.try_send(InboundVoice {
from_client: from,
packet: buf,
})
.is_err()
let inbound = InboundVoice {
from_client: from,
packet: buf,
};
match send_with_timeout(
&voice_in_tx,
inbound,
INBOUND_VOICE_SEND_TIMEOUT,
)
.await
{
// Subscriber is too slow or absent; drop.
Ok(()) => {}
Err(SendTimeoutError::Timeout(_)) => {
warn!(
target: "chanora_protocol",
from_client = from,
timeout_ms = INBOUND_VOICE_SEND_TIMEOUT.as_millis() as u64,
"inbound voice queue stayed full; dropping packet"
);
}
Err(SendTimeoutError::Closed(_)) => {
warn!(
target: "chanora_protocol",
from_client = from,
"inbound voice consumer closed; dropping packet"
);
}
}
}
}
@@ -735,7 +808,28 @@ async fn connection_task(
if let Some((_target_channel, reply, _deadline)) =
pending_moves.remove(&handle)
{
let mapped = map_command_result(result, "client_move");
let mapped = match result {
Ok(()) => Ok(()),
Err(cmd_err) => {
// tsclientlib's CommandError carries a
// typed `TsError` (the canonical TS3
// error code) plus an optional missing
// permission. We convert to our typed
// ProtocolError::ServerRejected so the
// upper layers can render a localised
// explanation by code instead of a
// generic backend string.
let code = cmd_err.error as u32;
let message = cmd_err.error.to_string();
info!(
target: "chanora_protocol",
code,
message = %message,
"server rejected client_move"
);
Err(ProtocolError::ServerRejected { code, message })
}
};
if let Some(reply) = reply {
let _ = reply.send(mapped);
} else if let Err(err) = mapped {
@@ -745,11 +839,6 @@ async fn connection_task(
"client_move completed in background with error"
);
}
} else if let Some((_target, reply, _deadline)) =
pending_text_messages.remove(&handle)
{
let mapped = map_command_result(result, "text_message");
let _ = reply.send(mapped);
}
}
_ => { /* book / message / other events: ignore */ }
@@ -794,24 +883,6 @@ async fn connection_task(
}
}
}
if !pending_text_messages.is_empty() {
let now = std::time::Instant::now();
let expired: Vec<MessageHandle> = pending_text_messages
.iter()
.filter_map(|(handle, (_, _, deadline))| {
if now >= *deadline {
Some(*handle)
} else {
None
}
})
.collect();
for handle in expired {
if let Some((_target, reply, _)) = pending_text_messages.remove(&handle) {
let _ = reply.send(Err(ProtocolError::Timeout));
}
}
}
// 3. Service at most one control request (non-blocking).
match rx.try_recv() {
@@ -861,15 +932,14 @@ async fn connection_task(
message,
target,
reply,
}) => match send_text_message(&mut con, &message, target) {
Ok(handle) => {
let deadline = std::time::Instant::now() + Duration::from_secs(3);
pending_text_messages.insert(handle, (target, reply, deadline));
}
Err(e) => {
let _ = reply.send(Err(e));
}
},
}) => {
let r = send_text_message(&mut con, &message, target);
let _ = reply.send(r);
}
Ok(Request::FetchClientProfile { client_id, reply }) => {
let r = fetch_client_profile(&mut con, client_id).await;
let _ = reply.send(r);
}
Ok(Request::Disconnect(reply)) => {
let _ = con.disconnect(DisconnectOptions::new());
con.events().for_each(|_| future::ready(())).await;
@@ -908,6 +978,10 @@ async fn resolve_server_socket(address: &str) -> Result<SocketAddr, ProtocolErro
})
}
fn server_socket_from_config(cfg: &ConnectConfig) -> Option<SocketAddr> {
cfg.resolved_address
}
/// Move our own client into `channel_id` with an optional password.
/// Looks up our `own_client` in the current state and dispatches the
/// generated `client_move` command via `send_with_result`. The
@@ -973,11 +1047,11 @@ fn send_text_message(
con: &mut Connection,
message: &str,
target: MessageTarget,
) -> Result<MessageHandle, ProtocolError> {
) -> Result<(), ProtocolError> {
use tsproto_types::TextMessageTargetMode;
match target {
MessageTarget::Server => {
send_text_to_mode(con, message, TextMessageTargetMode::Server, "server")
send_text_to_mode(con, message, TextMessageTargetMode::Server, "server")?;
}
MessageTarget::Channel => {
// Fix: previously channel messages were sent via
@@ -985,33 +1059,31 @@ fn send_text_message(
// TextMessageTargetMode::Server. Now correctly uses
// TextMessageTargetMode::Channel so the message is
// scoped to the current channel, not server-wide.
send_text_to_mode(con, message, TextMessageTargetMode::Channel, "channel")
send_text_to_mode(con, message, TextMessageTargetMode::Channel, "channel")?;
}
MessageTarget::Client(client_id) => {
let state = con
.get_state()
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
let client = find_client_by_id(state.clients.values(), client_id)?;
let handle = client
client
.send_textmessage(message)
.send_with_result(con)
.send(con)
.map_err(|e| ProtocolError::Backend(format!("send_textmessage(client): {e}")))?;
info!(target: "chanora_protocol", len = message.len(), ?target, "text message queued");
Ok(handle)
}
MessageTarget::Poke(client_id) => {
let state = con
.get_state()
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
let client = find_client_by_id(state.clients.values(), client_id)?;
let handle = client
client
.poke(message)
.send_with_result(con)
.send(con)
.map_err(|e| ProtocolError::Backend(format!("poke: {e}")))?;
info!(target: "chanora_protocol", len = message.len(), ?target, "text message queued");
Ok(handle)
}
}
info!(target: "chanora_protocol", len = message.len(), ?target, "text message sent");
Ok(())
}
fn send_text_to_mode(
@@ -1019,7 +1091,7 @@ fn send_text_to_mode(
message: &str,
target: tsproto_types::TextMessageTargetMode,
label: &str,
) -> Result<MessageHandle, ProtocolError> {
) -> Result<(), ProtocolError> {
use ts_bookkeeping::messages::c2s;
c2s::OutSendTextMessageMessage::new(&mut std::iter::once(c2s::OutSendTextMessagePart {
@@ -1027,31 +1099,242 @@ fn send_text_to_mode(
target_client_id: None,
message: message.into(),
}))
.send_with_result(con)
.send(con)
.map_err(|e| ProtocolError::Backend(format!("send_textmessage({label}): {e}")))
}
fn map_command_result(
result: Result<(), tsclientlib::CommandError>,
action: &str,
) -> Result<(), ProtocolError> {
match result {
Ok(()) => Ok(()),
Err(cmd_err) => {
let code = cmd_err.error as u32;
let message = cmd_err.error.to_string();
info!(
target: "chanora_protocol",
action,
code,
message = %message,
"server rejected command"
);
Err(ProtocolError::ServerRejected { code, message })
async fn fetch_client_profile(
con: &mut Connection,
client_id: u64,
) -> Result<ClientProfile, ProtocolError> {
let target_id = TsClientId(client_id as u16);
let (database_id, uid_b64) = {
let state = con
.get_state()
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
let client = find_client_by_id(state.clients.values(), client_id)?;
(
client.database_id,
client.uid.as_ref().map(|uid| uid_to_b64(uid.as_ref())),
)
};
let _ = request_messages(con, build_command("servergrouplist", &[], &[])).await;
let _ = request_messages(con, build_command("channelgrouplist", &[], &[])).await;
let _ = request_messages(
con,
build_command(
"clientgetvariables",
&[("clid", client_id.to_string())],
&[],
),
)
.await;
let _ = request_messages(
con,
build_command("getconnectioninfo", &[("clid", client_id.to_string())], &[]),
)
.await;
let db_info = request_client_db_info(con, database_id).await.ok();
let state = con
.get_state()
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
let client = state
.clients
.get(&target_id)
.ok_or_else(|| ProtocolError::Backend(format!("client {client_id} not found")))?;
let optional = client.optional_data.as_ref();
let connection = client.connection_data.as_ref();
let server_group_names: HashMap<_, _> = state
.server_groups
.iter()
.map(|(id, group)| (*id, group.name.clone()))
.collect();
let channel_group_names: HashMap<_, _> = state
.channel_groups
.iter()
.map(|(id, group)| (*id, group.name.clone()))
.collect();
let unique_id = db_info
.as_ref()
.map(|info| uid_to_b64(info.uid.as_ref()))
.or(uid_b64)
.unwrap_or_default();
let avatar_path = if client.avatar_hash.is_empty() || unique_id.is_empty() {
String::new()
} else {
format!("/avatar_{}", uid_to_avatar_path(&unique_id))
};
Ok(ClientProfile {
id: ClientId(client.id.0 as u64),
channel: ChannelId(client.channel.0),
name: db_info
.as_ref()
.map(|info| sanitize(&info.name))
.unwrap_or_else(|| sanitize(&client.name)),
unique_id,
database_id: Some(client.database_id.0),
country_code: client.country_code.clone(),
description: db_info
.as_ref()
.map(|info| sanitize(&info.description))
.unwrap_or_else(|| sanitize(&client.description)),
version: optional
.map(|info| info.version.clone())
.unwrap_or_default(),
platform: optional
.map(|info| info.platform.clone())
.unwrap_or_default(),
created_unix_seconds: optional
.map(|info| info.created.unix_timestamp())
.or_else(|| db_info.as_ref().map(|info| info.created.unix_timestamp())),
last_connected_unix_seconds: optional
.map(|info| info.last_connected.unix_timestamp())
.or_else(|| {
db_info
.as_ref()
.map(|info| info.last_connected.unix_timestamp())
}),
connections_total: optional
.map(|info| u64::from(info.connections_total))
.or_else(|| {
db_info
.as_ref()
.map(|info| u64::from(info.connections_total))
}),
online_seconds: connection
.and_then(|info| info.connected_time.map(|duration| duration.whole_seconds())),
idle_milliseconds: connection.map(|info| duration_millis(info.idle_time)),
ping_milliseconds: connection.and_then(|info| info.ping.map(duration_millis)),
client_address: connection
.and_then(|info| info.client_address.map(|address| address.to_string()))
.unwrap_or_default(),
server_groups: format_server_groups(client.server_groups.iter(), &server_group_names),
channel_group: channel_group_names
.get(&client.channel_group)
.cloned()
.unwrap_or_else(|| format!("Unknown ({})", client.channel_group.0)),
avatar_path,
bytes_downloaded_month: optional
.map(|info| info.bytes_downloaded_month)
.or_else(|| db_info.as_ref().map(|info| info.bytes_downloaded_month)),
bytes_uploaded_month: optional
.map(|info| info.bytes_uploaded_month)
.or_else(|| db_info.as_ref().map(|info| info.bytes_uploaded_month)),
bytes_downloaded_total: optional
.map(|info| info.bytes_downloaded_total)
.or_else(|| db_info.as_ref().map(|info| info.bytes_downloaded_total)),
bytes_uploaded_total: optional
.map(|info| info.bytes_uploaded_total)
.or_else(|| db_info.as_ref().map(|info| info.bytes_uploaded_total)),
packet_loss_client_to_server_total: connection
.map(|info| info.client_to_server_packetloss_total),
packet_loss_server_to_client_total: connection
.and_then(|info| info.server_to_client_packetloss_total),
})
}
fn build_command(name: &str, args: &[(&str, String)], flags: &[&str]) -> OutCommand {
let mut command = OutCommand::new(Direction::C2S, Flags::empty(), PacketType::Command, name);
for flag in flags {
command.write_arg(flag, &"");
}
for (key, value) in args {
command.write_arg(key, value);
}
command
}
async fn request_messages(
con: &mut Connection,
command: OutCommand,
) -> Result<Vec<InMessage>, ProtocolError> {
let handle = command
.send_with_result(con)
.map_err(|e| ProtocolError::Backend(format!("send command: {e}")))?;
let mut messages = Vec::new();
loop {
let item = con
.events()
.next()
.await
.ok_or_else(|| ProtocolError::Lost("event stream ended".to_string()))?
.map_err(|e| ProtocolError::Lost(e.to_string()))?;
match item {
StreamItem::MessageEvent(message) => messages.push(message),
StreamItem::MessageResult(reply, status) if reply == handle => {
status.map_err(|error| ProtocolError::ServerRejected {
code: error.error as u32,
message: error.error.to_string(),
})?;
return Ok(messages);
}
_ => {}
}
}
}
async fn request_client_db_info(
con: &mut Connection,
dbid: tsclientlib::ClientDbId,
) -> Result<InClientDbInfoPart, ProtocolError> {
let messages = request_messages(
con,
build_command("clientdbinfo", &[("cldbid", dbid.0.to_string())], &[]),
)
.await?;
for message in messages {
if let InMessage::ClientDbInfo(info) = message {
if let Some(row) = info.iter().next() {
return Ok(row.clone());
}
}
}
Err(ProtocolError::Backend(
"clientdbinfo returned no row".to_string(),
))
}
fn format_server_groups<'a>(
groups: impl IntoIterator<Item = &'a tsclientlib::ServerGroupId>,
names: &HashMap<tsclientlib::ServerGroupId, String>,
) -> Vec<String> {
let mut rendered = groups
.into_iter()
.map(|group| {
names
.get(group)
.cloned()
.unwrap_or_else(|| format!("Unknown ({})", group.0))
})
.collect::<Vec<_>>();
rendered.sort();
rendered
}
fn duration_millis(duration: time::Duration) -> i64 {
duration
.whole_milliseconds()
.clamp(i64::MIN as i128, i64::MAX as i128) as i64
}
fn uid_to_b64(uid: &tsclientlib::Uid) -> String {
BASE64_STANDARD.encode(&uid.0)
}
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
}
fn find_client_by_id<'a>(
clients: impl IntoIterator<Item = &'a Client>,
client_id: u64,
@@ -1219,11 +1502,6 @@ fn build_snapshot(
.iter()
.map(|c| ClientInfo {
id: ClientId(c.id.0 as u64),
uid: c
.uid
.as_ref()
.map(|uid| BASE64_STANDARD.encode(&uid.0))
.unwrap_or_default(),
channel: ChannelId(c.channel.0),
name: sanitize(&c.name),
input_muted: c.input_muted,
@@ -1441,7 +1719,12 @@ const _: () = {
#[cfg(test)]
mod tests {
use super::{is_server_query_client_type, sort_channels_tree_by};
use super::{
is_server_query_client_type, send_with_timeout, server_socket_from_config,
sort_channels_tree_by, ConnectConfig, SendTimeoutError,
};
use std::time::Duration;
use tokio::sync::mpsc;
use tsproto_types::ClientType;
/// Lightweight fixture mirroring just the (id, parent, order)
@@ -1470,6 +1753,36 @@ mod tests {
}));
}
#[test]
fn connect_config_can_carry_prefetched_socket_address() {
let addr: std::net::SocketAddr = "127.0.0.1:9987".parse().unwrap();
let cfg = ConnectConfig {
address: "example.com".to_string(),
nickname: "Tester".to_string(),
password: None,
identity: None,
ready_timeout: Duration::from_secs(1),
resolved_address: Some(addr),
};
assert_eq!(cfg.resolved_address, Some(addr));
}
#[test]
fn server_socket_from_config_prefers_prefetched_address() {
let addr: std::net::SocketAddr = "127.0.0.1:9987".parse().unwrap();
let cfg = ConnectConfig {
address: "example.com".to_string(),
resolved_address: Some(addr),
nickname: "Tester".to_string(),
password: None,
identity: None,
ready_timeout: Duration::from_secs(1),
};
assert_eq!(server_socket_from_config(&cfg), Some(addr));
}
#[test]
fn channel_sort_linked_list_under_one_parent() {
// Server emits four root-level channels in arbitrary HashMap
@@ -1589,4 +1902,32 @@ mod tests {
assert!(ids.contains(&1));
assert!(ids.contains(&2));
}
#[tokio::test]
async fn send_with_timeout_waits_for_capacity_instead_of_dropping() {
let (tx, mut rx) = mpsc::channel(1);
tx.send(1_u8).await.expect("seed first item");
let drain = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(10)).await;
assert_eq!(rx.recv().await, Some(1));
rx.recv().await
});
send_with_timeout(&tx, 2_u8, Duration::from_millis(100))
.await
.expect("second item should enqueue once capacity frees");
assert_eq!(drain.await.expect("drain task should succeed"), Some(2));
}
#[tokio::test]
async fn send_with_timeout_times_out_when_capacity_stays_full() {
let (tx, _rx) = mpsc::channel(1);
tx.send(1_u8).await.expect("seed first item");
let result = send_with_timeout(&tx, 2_u8, Duration::from_millis(10)).await;
assert_eq!(result, Err(SendTimeoutError::Timeout(2)));
}
}
+61 -7
View File
@@ -13,7 +13,7 @@ pub struct ChannelId(pub u64);
pub struct ClientId(pub u64);
/// One channel in the server's tree.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChannelInfo {
/// Stable channel id.
pub id: ChannelId,
@@ -44,7 +44,7 @@ pub enum MessageTarget {
}
/// An in-channel text message from a specific client.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChatMessage {
/// The client id of the sender.
pub sender_id: ClientId,
@@ -57,19 +57,17 @@ pub struct ChatMessage {
}
/// A server-activity notification derived from TeamSpeak bookkeeping events.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerActivity {
/// Human-readable activity line.
pub message: String,
}
/// One connected client on the server.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClientInfo {
/// Stable client id.
pub id: ClientId,
/// Stable TeamSpeak unique identifier for cross-session preference keys.
pub uid: String,
/// Channel the client is currently in.
pub channel: ChannelId,
/// Nickname, preserved verbatim per ADR-008.
@@ -88,8 +86,64 @@ pub struct ClientInfo {
pub talk_power_granted: bool,
}
/// Best-effort profile and live connection details for one online
/// client, fetched through the normal TeamSpeak client protocol.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ClientProfile {
/// Stable online client id.
pub id: ClientId,
/// Channel the client is currently in.
pub channel: ChannelId,
/// Nickname, preserved verbatim per ADR-008.
pub name: String,
/// TeamSpeak unique id encoded in the canonical base64 form.
pub unique_id: String,
/// Stable TeamSpeak database id, when visible.
pub database_id: Option<u64>,
/// ISO country code, when the server exposes one.
pub country_code: String,
/// User description, when visible.
pub description: String,
/// Client version string, populated by `clientgetvariables`.
pub version: String,
/// Client platform string, populated by `clientgetvariables`.
pub platform: String,
/// Account creation time as Unix seconds.
pub created_unix_seconds: Option<i64>,
/// Last connection time as Unix seconds.
pub last_connected_unix_seconds: Option<i64>,
/// Total historical connections, when visible.
pub connections_total: Option<u64>,
/// Current online duration in seconds, when visible.
pub online_seconds: Option<i64>,
/// Current idle time in milliseconds, when visible.
pub idle_milliseconds: Option<i64>,
/// Current ping in milliseconds, when visible.
pub ping_milliseconds: Option<i64>,
/// Client address. Empty when permission-gated.
pub client_address: String,
/// Resolved server group names for the online client.
pub server_groups: Vec<String>,
/// Resolved channel group name for the online client.
pub channel_group: String,
/// TeamSpeak avatar file path suffix, when an avatar hash is present.
pub avatar_path: String,
/// Downloaded bytes this month.
pub bytes_downloaded_month: Option<u64>,
/// Uploaded bytes this month.
pub bytes_uploaded_month: Option<u64>,
/// Downloaded bytes across all time.
pub bytes_downloaded_total: Option<u64>,
/// Uploaded bytes across all time.
pub bytes_uploaded_total: Option<u64>,
/// Client-to-server total packet loss ratio.
pub packet_loss_client_to_server_total: Option<f32>,
/// Server-to-client total packet loss ratio.
pub packet_loss_server_to_client_total: Option<f32>,
}
/// Snapshot of the server's published state at a moment in time.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerSnapshot {
/// Server name.
pub server_name: String,
+4 -3
View File
@@ -8,7 +8,8 @@
//!
//! * [`ConnectConfig`] — typed connection parameters.
//! * [`ProtocolClient`] — async handle owning the connection task.
//! * [`ServerSnapshot`], [`ChannelInfo`], [`ClientInfo`] — opaque
//! * [`ServerSnapshot`], [`ChannelInfo`], [`ClientInfo`],
//! [`ClientProfile`] — opaque
//! DTOs containing only `String`s and primitives.
//! * [`ProtocolError`] — typed error catalogue.
//!
@@ -37,8 +38,8 @@ mod dto;
pub use adapter::{ConnectConfig, DisconnectReason, InboundVoice, ProtocolClient, SnapshotProbe};
pub use dto::{
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, MessageTarget, ServerActivity,
ServerSnapshot,
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, ClientProfile, MessageTarget,
ServerActivity, ServerSnapshot,
};
// Re-export the upstream voice types so chanora_audio can build outbound
+2
View File
@@ -2,6 +2,8 @@
name = "chanora_resolver"
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
publish = false
build = "build.rs"
[dependencies]
+6 -5
View File
@@ -9,10 +9,11 @@ Use `ChanoraResolver::resolve_client_request` or `resolve_client_address` for ap
1. Normalize raw client input, including `ts3server://host?port=...`.
2. For dotless names such as `6666` or `wwb`, query the myTeamSpeak server-name endpoint first. If it returns a hostname, restart normal resolution with that hostname. If it returns `ip:port`, use that final address.
3. Start plain A/AAAA DNS as the final fallback address.
4. Prefer `_ts3._udp.<host>` SRV. Its target host and port override the user input port, then the target is resolved to a final IP.
5. Try `_tsdns._tcp.<candidate>` SRV on candidate parent/full hosts and query the returned TSDNS server over TCP.
6. Try direct TSDNS TCP on candidate parent/full hosts at port `41144`.
7. Fall back to the A/AAAA result with the user-supplied port, or default TeamSpeak port `9987`.
4. If the user supplied an explicit port and DNS resolved, return that direct target immediately.
5. Prefer `_ts3._udp.<host>` SRV. Its target host and port override the default port, then the target is resolved to a final IP.
6. Try `_tsdns._tcp.<candidate>` SRV on candidate parent/full hosts and query the returned TSDNS server over TCP.
7. Try direct TSDNS TCP on candidate parent/full hosts at port `41144`.
8. Fall back to the A/AAAA result with the default TeamSpeak port `9987`. When DNS fallback is already available, TSDNS discovery is capped so missing or filtered TSDNS cannot add multi-second join delays.
The older explicit API (`Args { host, service, protocol }`) is still available for diagnostic example use.
@@ -101,4 +102,4 @@ cargo run --example cli -- -host voice.teamspeak.com -service ts3
cargo fmt --all -- --check
cargo test
cargo clippy --all-targets --all-features -- -D warnings
```
```
+168 -50
View File
@@ -18,6 +18,8 @@ const NICK_RESOLVE_URL: &str = "https://named.myteamspeak.com/lookup";
const TSDNS_PORT: u16 = 41144;
const TSDNS_TERMINATOR: &[u8] = b"\n\r\r\r\n";
const TSDNS_TIMEOUT: Duration = Duration::from_secs(3);
const TS3_SRV_FALLBACK_DISCOVERY_BUDGET: Duration = Duration::from_millis(900);
const TSDNS_FALLBACK_DISCOVERY_BUDGET: Duration = Duration::from_millis(900);
pub const DEFAULT_TEAMSPEAK_PORT: u16 = 9987;
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -262,7 +264,7 @@ impl ChanoraResolver {
));
}
let direct = if is_bare_numeric_name(&host) {
let mut direct = if is_bare_numeric_name(&host) {
errors.push("dns: skipped bare numeric server name".to_string());
None
} else {
@@ -275,53 +277,80 @@ impl ChanoraResolver {
}
};
match self.resolve_ts3(&host, "").await {
Ok(resolution) => {
let selected = match &resolution {
Resolution::Srv { selected, .. } => selected.clone(),
_ => unreachable!("ts3 resolution must be an srv result"),
};
return self
.client_srv_resolution(
input,
ClientResolutionMethod::Ts3Srv,
&selected,
resolution,
)
.await;
if should_return_direct_dns_before_discovery(port) {
if let Some(resolution) = direct.take() {
return Ok(direct_client_resolution(
input,
from_server_name,
fallback_port,
resolution,
));
}
Err(err) => errors.push(format!("ts3 srv: {err}")),
}
match self
.resolve_tsdns_srv_candidates(input, &host, fallback_port)
.await
{
Ok(resolution) => return Ok(resolution),
Err(err) => errors.push(format!("tsdns srv: {err}")),
let ts3 = self.resolve_ts3(&host, "");
match ts3_srv_discovery_budget(direct.is_some()) {
Some(budget) => match timeout(budget, ts3).await {
Ok(Ok(resolution)) => {
let selected = match &resolution {
Resolution::Srv { selected, .. } => selected.clone(),
_ => unreachable!("ts3 resolution must be an srv result"),
};
return self
.client_srv_resolution(
input,
ClientResolutionMethod::Ts3Srv,
&selected,
resolution,
)
.await;
}
Ok(Err(err)) => errors.push(format!("ts3 srv: {err}")),
Err(_) => errors.push(format!(
"ts3 srv: discovery timed out after {}ms",
budget.as_millis()
)),
},
None => match ts3.await {
Ok(resolution) => {
let selected = match &resolution {
Resolution::Srv { selected, .. } => selected.clone(),
_ => unreachable!("ts3 resolution must be an srv result"),
};
return self
.client_srv_resolution(
input,
ClientResolutionMethod::Ts3Srv,
&selected,
resolution,
)
.await;
}
Err(err) => errors.push(format!("ts3 srv: {err}")),
},
}
match self
.resolve_tsdns_tcp_candidates(input, &host, fallback_port)
.await
{
Ok(resolution) => return Ok(resolution),
Err(err) => errors.push(format!("tsdns tcp: {err}")),
let tsdns = self.resolve_tsdns_candidates(input, &host, fallback_port);
match tsdns_discovery_budget(direct.is_some()) {
Some(budget) => match timeout(budget, tsdns).await {
Ok(Ok(resolution)) => return Ok(resolution),
Ok(Err(err)) => errors.push(format!("tsdns: {err}")),
Err(_) => errors.push(format!(
"tsdns: discovery timed out after {}ms",
budget.as_millis()
)),
},
None => match tsdns.await {
Ok(resolution) => return Ok(resolution),
Err(err) => errors.push(format!("tsdns: {err}")),
},
}
if let Some(resolution) = direct {
let selected = match &resolution {
Resolution::Dns { selected, .. } => *selected,
_ => unreachable!("direct fallback must be a dns resolution"),
};
return Ok(client_resolution_with_address(
return Ok(direct_client_resolution(
input,
if from_server_name {
ClientResolutionMethod::Nick
} else {
ClientResolutionMethod::Dns
},
format_host_port(&selected.to_string(), fallback_port),
from_server_name,
fallback_port,
resolution,
));
}
@@ -332,6 +361,32 @@ impl ChanoraResolver {
)
}
async fn resolve_tsdns_candidates(
&self,
input: &str,
query_host: &str,
fallback_port: u16,
) -> Result<ClientResolution> {
let mut errors = Vec::new();
match self
.resolve_tsdns_srv_candidates(input, query_host, fallback_port)
.await
{
Ok(resolution) => return Ok(resolution),
Err(err) => errors.push(format!("tsdns srv: {err}")),
}
match self
.resolve_tsdns_tcp_candidates(input, query_host, fallback_port)
.await
{
Ok(resolution) => return Ok(resolution),
Err(err) => errors.push(format!("tsdns tcp: {err}")),
}
bail!("{}", errors.join("; "))
}
async fn client_srv_resolution(
&self,
input: &str,
@@ -716,17 +771,6 @@ impl ChanoraResolver {
}
}
#[cfg(target_os = "android")]
fn srv_resolver_builder() -> Result<hickory_resolver::ResolverBuilder<TokioRuntimeProvider>> {
use hickory_resolver::config::{ResolverConfig, CLOUDFLARE};
Ok(TokioResolver::builder_with_config(
ResolverConfig::udp_and_tcp(&CLOUDFLARE),
TokioRuntimeProvider::default(),
))
}
#[cfg(not(target_os = "android"))]
fn srv_resolver_builder() -> Result<hickory_resolver::ResolverBuilder<TokioRuntimeProvider>> {
TokioResolver::builder_tokio().context("failed to initialize DNS resolver")
}
@@ -801,6 +845,40 @@ fn format_host_port(host: &str, port: u16) -> String {
}
}
fn should_return_direct_dns_before_discovery(port: Option<u16>) -> bool {
port.is_some()
}
fn tsdns_discovery_budget(has_dns_fallback: bool) -> Option<Duration> {
has_dns_fallback.then_some(TSDNS_FALLBACK_DISCOVERY_BUDGET)
}
fn ts3_srv_discovery_budget(has_dns_fallback: bool) -> Option<Duration> {
has_dns_fallback.then_some(TS3_SRV_FALLBACK_DISCOVERY_BUDGET)
}
fn direct_client_resolution(
input: &str,
from_server_name: bool,
fallback_port: u16,
resolution: Resolution,
) -> ClientResolution {
let selected = match &resolution {
Resolution::Dns { selected, .. } => *selected,
_ => unreachable!("direct fallback must be a dns resolution"),
};
client_resolution_with_address(
input,
if from_server_name {
ClientResolutionMethod::Nick
} else {
ClientResolutionMethod::Dns
},
format_host_port(&selected.to_string(), fallback_port),
resolution,
)
}
fn client_resolution_with_address(
input: &str,
method: ClientResolutionMethod,
@@ -1313,4 +1391,44 @@ mod tests {
vec!["teamspeak.app"]
);
}
#[test]
fn explicit_ports_use_direct_dns_fast_path() {
assert!(should_return_direct_dns_before_discovery(Some(9987)));
assert!(!should_return_direct_dns_before_discovery(None));
}
#[tokio::test]
async fn explicit_port_client_request_uses_dns_result() {
let resolver = ChanoraResolver {
resolver: None,
http: Client::new(),
};
let resolved = resolver
.resolve_client_request("localhost:10075")
.await
.unwrap();
assert_eq!(resolved.method, ClientResolutionMethod::Dns);
assert!(resolved.address.ends_with(":10075"));
}
#[test]
fn tsdns_discovery_is_bounded_when_dns_fallback_exists() {
assert_eq!(
tsdns_discovery_budget(true),
Some(TSDNS_FALLBACK_DISCOVERY_BUDGET)
);
assert_eq!(tsdns_discovery_budget(false), None);
}
#[test]
fn ts3_srv_discovery_is_bounded_when_dns_fallback_exists() {
assert_eq!(
ts3_srv_discovery_budget(true),
Some(TS3_SRV_FALLBACK_DISCOVERY_BUDGET)
);
assert_eq!(ts3_srv_discovery_budget(false), None);
}
}
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "chanora_server_prefetch"
description = "Chanora — server-address prefetch cache and policy built on chanora_resolver."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
publish.workspace = true
[dependencies]
chanora_resolver = { path = "../chanora_resolver" }
thiserror.workspace = true
tracing.workspace = true
tokio = { version = "1", features = ["sync", "rt", "macros"] }
[features]
test-support = []
+312
View File
@@ -0,0 +1,312 @@
//! Server-address prefetch cache and policy for Chanora.
//!
//! This crate owns speculative server-resolution warming. It does not
//! decide whether a connection should use a prefetched address; callers
//! must still apply their own trust boundary before dialing.
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::{Duration, Instant};
use thiserror::Error;
use tokio::sync::Mutex;
use tracing::{debug, info, warn};
const SERVER_PREFETCH_TTL: Duration = Duration::from_secs(120);
#[derive(Debug, Error)]
pub enum ServerPrefetchError {
#[error("resolver initialization failed: {0}")]
ResolverInit(String),
#[error("resolution failed: {0}")]
Resolution(String),
#[error("resolver returned invalid socket address '{resolved}': {reason}")]
InvalidSocketAddress { resolved: String, reason: String },
}
#[derive(Debug, Clone)]
struct ServerPrefetchEntry {
normalized_host: String,
resolved_address: SocketAddr,
completed_at: Instant,
}
#[derive(Debug, Default)]
struct ServerPrefetchCache {
latest_generation: u64,
entry: Option<ServerPrefetchEntry>,
}
impl ServerPrefetchCache {
fn begin(&mut self, host: &str) -> u64 {
if normalize_host(host).is_empty() {
return self.latest_generation;
}
self.latest_generation = self.latest_generation.saturating_add(1);
self.latest_generation
}
fn store_success(
&mut self,
generation: u64,
host: &str,
resolved_address: SocketAddr,
completed_at: Instant,
) {
if generation != self.latest_generation {
return;
}
self.entry = Some(ServerPrefetchEntry {
normalized_host: normalize_host(host),
resolved_address,
completed_at,
});
}
fn fresh_match(&self, host: &str, now: Instant) -> Option<SocketAddr> {
let normalized = normalize_host(host);
let entry = self.entry.as_ref()?;
if entry.normalized_host != normalized {
return None;
}
if now.duration_since(entry.completed_at) > SERVER_PREFETCH_TTL {
return None;
}
// Generation is not checked here: a fresh entry remains usable while
// a newer prefetch is in flight. Stale async completions are still
// rejected in store_success via the generation guard.
Some(entry.resolved_address)
}
}
#[derive(Debug, Clone, Default)]
pub struct ServerPrefetcher {
cache: Arc<Mutex<ServerPrefetchCache>>,
#[cfg(any(test, feature = "test-support"))]
setup_error_for_test: Arc<Mutex<Option<String>>>,
}
impl ServerPrefetcher {
pub fn new() -> Self {
Self::default()
}
/// Schedules a fire-and-forget prefetch for `host`.
///
/// The result only reports synchronous setup failures before scheduling,
/// such as resolver initialization. DNS/resolution failures after the task
/// is spawned are logged and do not complete this returned `Result`.
pub async fn prefetch(&self, host: String) -> Result<(), ServerPrefetchError> {
let normalized = normalize_host(&host);
if normalized.is_empty() {
return Ok(());
}
#[cfg(any(test, feature = "test-support"))]
if let Some(err) = self.setup_error_for_test.lock().await.take() {
return Err(ServerPrefetchError::ResolverInit(err));
}
let resolver = chanora_resolver::ChanoraResolver::new()
.map_err(|err| ServerPrefetchError::ResolverInit(err.to_string()))?;
let generation = {
let mut cache = self.cache.lock().await;
cache.begin(&normalized)
};
let cache = self.cache.clone();
tokio::spawn(async move {
info!(target: "chanora_server_prefetch", host = %normalized, "resolution prefetch started");
let result = resolve_socket(resolver, &normalized).await;
match result {
Ok(addr) => {
info!(
target: "chanora_server_prefetch",
host = %normalized,
resolved = %addr,
"resolution prefetch result"
);
let mut guard = cache.lock().await;
guard.store_success(generation, &normalized, addr, Instant::now());
}
Err(err) => {
warn!(
target: "chanora_server_prefetch",
host = %normalized,
error = %err,
"resolution prefetch failed"
);
}
}
});
Ok(())
}
pub async fn fresh_match(&self, host: &str) -> Option<SocketAddr> {
let resolved = {
let cache = self.cache.lock().await;
cache.fresh_match(host, Instant::now())
};
match resolved {
Some(addr) => {
info!(
target: "chanora_server_prefetch",
host = %host,
resolved = %addr,
"connect using prefetched resolution"
);
Some(addr)
}
None => {
debug!(target: "chanora_server_prefetch", host = %host, "connect prefetch miss or stale");
None
}
}
}
#[cfg(any(test, feature = "test-support"))]
pub async fn begin_for_test(&self, host: &str) -> u64 {
let mut cache = self.cache.lock().await;
cache.begin(host)
}
#[cfg(any(test, feature = "test-support"))]
pub async fn store_success_for_test(
&self,
generation: u64,
host: &str,
resolved_address: SocketAddr,
completed_at: Instant,
) {
let mut cache = self.cache.lock().await;
cache.store_success(generation, host, resolved_address, completed_at);
}
#[cfg(any(test, feature = "test-support"))]
pub async fn latest_generation_for_test(&self) -> u64 {
let cache = self.cache.lock().await;
cache.latest_generation
}
#[cfg(any(test, feature = "test-support"))]
pub async fn fail_next_prefetch_setup_for_test(&self, error: impl Into<String>) {
*self.setup_error_for_test.lock().await = Some(error.into());
}
}
fn normalize_host(host: &str) -> String {
host.trim().to_lowercase()
}
async fn resolve_socket(
resolver: chanora_resolver::ChanoraResolver,
host: &str,
) -> Result<SocketAddr, ServerPrefetchError> {
let resolved = resolver
.resolve_client_address(host)
.await
.map_err(|err| ServerPrefetchError::Resolution(err.to_string()))?;
resolved
.parse::<SocketAddr>()
.map_err(|err| ServerPrefetchError::InvalidSocketAddress {
resolved,
reason: err.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn fresh_exact_match_returns_socket_address() {
let prefetcher = ServerPrefetcher::new();
let generation = prefetcher.begin_for_test(" Example.COM ").await;
let addr = "127.0.0.1:9987".parse().unwrap();
prefetcher
.store_success_for_test(generation, "example.com", addr, Instant::now())
.await;
assert_eq!(prefetcher.fresh_match("example.com").await, Some(addr));
assert_eq!(prefetcher.fresh_match(" EXAMPLE.com ").await, Some(addr));
}
#[tokio::test]
async fn stale_entries_are_ignored() {
let prefetcher = ServerPrefetcher::new();
let generation = prefetcher.begin_for_test("example.com").await;
let addr = "127.0.0.1:9987".parse().unwrap();
prefetcher
.store_success_for_test(
generation,
"example.com",
addr,
Instant::now() - SERVER_PREFETCH_TTL - Duration::from_secs(1),
)
.await;
assert_eq!(prefetcher.fresh_match("example.com").await, None);
}
#[tokio::test]
async fn different_hosts_are_ignored() {
let prefetcher = ServerPrefetcher::new();
let generation = prefetcher.begin_for_test("example.com").await;
let addr = "127.0.0.1:9987".parse().unwrap();
prefetcher
.store_success_for_test(generation, "example.com", addr, Instant::now())
.await;
assert_eq!(prefetcher.fresh_match("other.example.com").await, None);
}
#[tokio::test]
async fn stale_generation_completions_are_ignored() {
let prefetcher = ServerPrefetcher::new();
let old_generation = prefetcher.begin_for_test("old.example.com").await;
let _new_generation = prefetcher.begin_for_test("new.example.com").await;
let old_addr = "127.0.0.1:9987".parse().unwrap();
prefetcher
.store_success_for_test(old_generation, "old.example.com", old_addr, Instant::now())
.await;
assert_eq!(prefetcher.fresh_match("old.example.com").await, None);
}
#[tokio::test]
async fn fresh_entry_usable_during_warming() {
let prefetcher = ServerPrefetcher::new();
let first_generation = prefetcher.begin_for_test("example.com").await;
let cached_addr = "127.0.0.1:9987".parse().unwrap();
prefetcher
.store_success_for_test(first_generation, "example.com", cached_addr, Instant::now())
.await;
let _pending_generation = prefetcher.begin_for_test("example.com").await;
assert_eq!(
prefetcher.fresh_match("example.com").await,
Some(cached_addr)
);
let stale_addr = "127.0.0.2:9987".parse().unwrap();
prefetcher
.store_success_for_test(first_generation, "example.com", stale_addr, Instant::now())
.await;
assert_eq!(
prefetcher.fresh_match("example.com").await,
Some(cached_addr)
);
}
#[tokio::test]
async fn blank_host_skips_prefetch() {
let prefetcher = ServerPrefetcher::new();
let before = prefetcher.latest_generation_for_test().await;
prefetcher.prefetch(" ".to_string()).await.unwrap();
assert_eq!(prefetcher.latest_generation_for_test().await, before);
}
}
+1
View File
@@ -11,3 +11,4 @@ publish.workspace = true
[dependencies]
thiserror.workspace = true
chanora_protocol = { path = "../chanora_protocol" }
+772 -7
View File
@@ -4,17 +4,26 @@
//! channel tree, client list, our own connection state. Owns the
//! reducers that fold protocol events into a snapshot, and the
//! deltas that the bridge publishes to Flutter view-models
//! (per SAD §7.2 and SDD §5).
//! (per SAD 7.2 and SDD 5).
//!
//! ## Status
//! ## Reducer contract
//!
//! Scaffold only.
//! Reducers mutate the caller-owned state passed by `&mut` and return a
//! `Reduction` containing only the emitted `Delta` values. The caller
//! (typically `chanora_core`) owns state storage, publishes deltas to the
//! bridge, and executes any side effects.
//!
//! This design satisfies SRS-056 (deterministic deltas), SRS-057
//! (per-connection ordering), and SRS-058 (reducer functions).
#![forbid(unsafe_code)]
#![warn(missing_docs)]
pub mod channel_join;
use std::collections::HashMap;
use chanora_protocol::{ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, ServerSnapshot};
use thiserror::Error;
/// Errors raised while reducing protocol events into state or
@@ -24,8 +33,7 @@ pub enum StateError {
/// Reducer received an event referencing an unknown entity.
#[error("unknown entity: {0}")]
Unknown(&'static str),
/// A reducer invariant was violated (e.g. two clients claiming
/// the same id).
/// A reducer invariant was violated (e.g. two clients claiming the same id).
#[error("state invariant: {0}")]
Invariant(&'static str),
}
@@ -39,16 +47,773 @@ pub enum ConnectionState {
Connecting,
/// Initial state snapshot has been received; ready for use.
Ready,
/// Connection lost; reconnecting.
Reconnecting,
/// Connection lost; will not auto-reconnect at this stage.
Lost,
}
/// Authoritative mirror of a connected server's published state.
///
/// Constructed from a [`ServerSnapshot`] and updated through delta
/// events. Owns the channel tree, client list, and server metadata.
/// Satisfies SRS-054.
#[derive(Debug, Clone)]
pub struct ServerState {
/// Retained connection lifecycle state.
pub connection_state: ConnectionState,
/// Server name from the most recent snapshot.
pub server_name: String,
/// Welcome message / banner (server-provided markup).
pub welcome_message: String,
/// Server platform string.
pub platform: String,
/// Server version string.
pub version: String,
channels: HashMap<u64, ChannelInfo>,
clients: HashMap<u64, ClientInfo>,
channel_order: Vec<u64>,
client_order: Vec<u64>,
/// Our own client id, as the server reported it.
pub own_client_id: u64,
}
impl ServerState {
/// Build state from the initial full snapshot. Satisfies SRS-055.
pub fn from_snapshot(snapshot: ServerSnapshot) -> Self {
let snapshot = normalize_snapshot(snapshot);
let channels = snapshot
.channels
.iter()
.map(|c| (c.id.0, c.clone()))
.collect();
let clients = snapshot
.clients
.iter()
.map(|c| (c.id.0, c.clone()))
.collect();
let channel_order: Vec<u64> = snapshot.channels.iter().map(|c| c.id.0).collect();
let mut client_order: Vec<u64> = snapshot.clients.iter().map(|c| c.id.0).collect();
client_order.sort_unstable();
client_order.dedup();
Self {
connection_state: ConnectionState::Ready,
server_name: snapshot.server_name,
welcome_message: snapshot.welcome_message,
platform: snapshot.platform,
version: snapshot.version,
channels,
clients,
channel_order,
client_order,
own_client_id: snapshot.own_client_id,
}
}
/// 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.
pub fn channel(&self, id: ChannelId) -> Option<&ChannelInfo> {
self.channels.get(&id.0)
}
/// Look up a client by id.
pub fn client(&self, id: ClientId) -> Option<&ClientInfo> {
self.clients.get(&id.0)
}
/// All channels in protocol snapshot order. Live inserts are appended
/// until the next full snapshot re-normalizes order.
pub fn channels(&self) -> impl Iterator<Item = &ChannelInfo> {
self.channel_order
.iter()
.filter_map(|id| self.channels.get(id))
}
/// All clients in stable reducer order.
///
/// Full snapshots normalize clients by id to avoid inheriting upstream
/// HashMap iteration order. Live inserts are appended in arrival order
/// until the next full snapshot re-normalizes the list.
pub fn clients(&self) -> impl Iterator<Item = &ClientInfo> {
self.client_order
.iter()
.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.
pub fn own_channel(&self) -> Option<&ChannelInfo> {
self.client(ClientId(self.own_client_id))
.and_then(|c| self.channel(c.channel))
}
/// Clients in a given channel.
pub fn clients_in_channel(&self, channel_id: ChannelId) -> impl Iterator<Item = &ClientInfo> {
self.client_order
.iter()
.filter_map(|id| self.clients.get(id))
.filter(move |c| c.channel == channel_id)
}
}
fn normalize_snapshot(snapshot: ServerSnapshot) -> ServerSnapshot {
let mut channels = HashMap::new();
let mut channel_order = Vec::new();
for channel in snapshot.channels {
if !channels.contains_key(&channel.id.0) {
channel_order.push(channel.id.0);
}
channels.insert(channel.id.0, channel);
}
let mut clients = HashMap::new();
for client in snapshot.clients {
clients.insert(client.id.0, client);
}
let mut client_order: Vec<u64> = clients.keys().copied().collect();
client_order.sort_unstable();
ServerSnapshot {
server_name: snapshot.server_name,
welcome_message: snapshot.welcome_message,
platform: snapshot.platform,
version: snapshot.version,
channels: channel_order
.into_iter()
.filter_map(|id| channels.remove(&id))
.collect(),
clients: client_order
.into_iter()
.filter_map(|id| clients.remove(&id))
.collect(),
own_client_id: snapshot.own_client_id,
}
}
/// A change to the server state that the bridge should publish to
/// Flutter. Deltas are cheap to construct and carry only the
/// information that changed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Delta {
/// A full snapshot was applied; the entire UI should refresh.
SnapshotApplied(ServerSnapshot),
/// A channel was added or updated.
ChannelUpserted(ChannelInfo),
/// A channel was removed.
ChannelRemoved(ChannelId),
/// A client joined or was updated (includes moves).
ClientUpserted(ClientInfo),
/// A client left.
ClientRemoved(ClientId),
/// A chat message arrived.
ChatMessage(ChatMessage),
/// A client's speaking state changed.
VoiceActivityChanged {
/// Client whose voice activity changed.
client_id: ClientId,
/// Whether voice activity is currently observed.
is_speaking: bool,
},
/// The connection state changed.
ConnectionStateChanged(ConnectionState),
}
/// Events that flow from the protocol layer into the state reducer.
/// Each variant carries all data the reducer needs; no external
/// lookups are required.
#[derive(Debug, Clone)]
pub enum StateEvent {
/// Initial or post-reconnect snapshot arrived.
Snapshot(ServerSnapshot),
/// A new channel appeared or an existing channel was updated.
ChannelChanged(ChannelInfo),
/// A channel was deleted.
ChannelDeleted(ChannelId),
/// A client joined or was updated (including channel moves and
/// mute/speaking state changes).
ClientChanged(ClientInfo),
/// A client left the server.
ClientLeft(ClientId),
/// A text message was received.
ChatReceived(ChatMessage),
/// A client's speaking state changed.
VoiceActivityChanged {
/// Client whose voice activity changed.
client_id: ClientId,
/// Whether voice activity is currently observed.
is_speaking: bool,
},
/// Connection lifecycle transition.
ConnectionChanged(ConnectionState),
/// Reconnect started; stale state must be discarded.
ReconnectStarted,
}
/// Result of applying a single event through the reducer.
#[derive(Debug, Clone)]
pub struct Reduction {
/// Deltas describing what changed, in application order.
pub deltas: Vec<Delta>,
}
/// Apply a [`StateEvent`] to optional [`ServerState`], returning
/// the resulting [`Reduction`].
///
/// The state is taken by `&mut` so the caller retains ownership.
/// When `state` is `None` (no active connection), most events are
/// ignored except `ConnectionChanged` and `Snapshot` (which creates
/// the state).
///
/// Deterministic: the same `(state, event)` pair always produces the
/// same `Reduction`. Satisfies SRS-056.
pub fn reduce(state: &mut Option<ServerState>, event: StateEvent) -> Reduction {
match event {
StateEvent::ConnectionChanged(cs) => {
if let Some(s) = state {
s.connection_state = cs;
}
Reduction {
deltas: vec![Delta::ConnectionStateChanged(cs)],
}
}
StateEvent::ReconnectStarted => {
*state = None;
Reduction {
deltas: vec![Delta::ConnectionStateChanged(ConnectionState::Reconnecting)],
}
}
StateEvent::Snapshot(snap) => {
let normalized = normalize_snapshot(snap);
*state = Some(ServerState::from_snapshot(normalized.clone()));
Reduction {
deltas: vec![
Delta::ConnectionStateChanged(ConnectionState::Ready),
Delta::SnapshotApplied(normalized),
],
}
}
StateEvent::ChannelChanged(info) => match state {
Some(s) if s.connection_state == ConnectionState::Ready => {
if !s.channels.contains_key(&info.id.0) {
s.channel_order.push(info.id.0);
}
s.channels.insert(info.id.0, info.clone());
Reduction {
deltas: vec![Delta::ChannelUpserted(info)],
}
}
_ => Reduction { deltas: vec![] },
},
StateEvent::ChannelDeleted(id) => match state {
Some(s) if s.connection_state == ConnectionState::Ready => {
let removed_clients: Vec<u64> = s
.client_order
.iter()
.copied()
.filter(|client_id| {
s.clients
.get(client_id)
.is_some_and(|client| client.channel == id)
})
.collect();
for client_id in &removed_clients {
s.clients.remove(client_id);
}
s.client_order
.retain(|existing| !removed_clients.contains(existing));
let removed_channel = s.channels.remove(&id.0).is_some();
s.channel_order.retain(|existing| *existing != id.0);
let mut deltas: Vec<Delta> = removed_clients
.into_iter()
.map(|client_id| Delta::ClientRemoved(ClientId(client_id)))
.collect();
if removed_channel {
deltas.push(Delta::ChannelRemoved(id));
}
Reduction {
deltas,
}
}
_ => Reduction { deltas: vec![] },
},
StateEvent::ClientChanged(info) => match state {
Some(s) if s.connection_state == ConnectionState::Ready => {
if !s.clients.contains_key(&info.id.0) {
s.client_order.push(info.id.0);
}
s.clients.insert(info.id.0, info.clone());
Reduction {
deltas: vec![Delta::ClientUpserted(info)],
}
}
_ => Reduction { deltas: vec![] },
},
StateEvent::ClientLeft(id) => match state {
Some(s) if s.connection_state == ConnectionState::Ready => {
s.clients.remove(&id.0);
s.client_order.retain(|existing| *existing != id.0);
Reduction {
deltas: vec![Delta::ClientRemoved(id)],
}
}
_ => Reduction { deltas: vec![] },
},
StateEvent::ChatReceived(msg) => match state {
Some(s) if s.connection_state == ConnectionState::Ready => Reduction {
deltas: vec![Delta::ChatMessage(msg)],
},
_ => Reduction { deltas: vec![] },
},
StateEvent::VoiceActivityChanged {
client_id,
is_speaking,
} => match state {
Some(s) if s.connection_state == ConnectionState::Ready => {
if let Some(client) = s.clients.get_mut(&client_id.0) {
client.is_speaking = is_speaking;
Reduction {
deltas: vec![Delta::VoiceActivityChanged {
client_id,
is_speaking,
}],
}
} else {
Reduction { deltas: vec![] }
}
}
_ => Reduction { deltas: vec![] },
},
}
}
/// Replace server state with a fresh snapshot after a reconnect.
/// Discards all previous state unconditionally (SRS-059).
pub fn reduce_reconnect_snapshot(
state: &mut Option<ServerState>,
snap: ServerSnapshot,
) -> Reduction {
let normalized = normalize_snapshot(snap);
*state = Some(ServerState::from_snapshot(normalized.clone()));
Reduction {
deltas: vec![
Delta::ConnectionStateChanged(ConnectionState::Ready),
Delta::SnapshotApplied(normalized),
],
}
}
#[cfg(test)]
mod tests {
use super::*;
use chanora_protocol::MessageTarget;
fn sample_channel(id: u64) -> ChannelInfo {
ChannelInfo {
id: ChannelId(id),
parent: ChannelId(0),
name: format!("channel-{id}"),
order: 0,
has_password: false,
needed_talk_power: None,
}
}
fn sample_client(id: u64, channel: u64) -> ClientInfo {
ClientInfo {
id: ClientId(id),
channel: ChannelId(channel),
name: format!("client-{id}"),
input_muted: false,
output_muted: false,
is_speaking: false,
is_server_query: false,
talk_power: 0,
talk_power_granted: false,
}
}
fn sample_snapshot() -> ServerSnapshot {
ServerSnapshot {
server_name: "Test Server".into(),
welcome_message: String::new(),
platform: "Linux".into(),
version: "3.13".into(),
channels: vec![sample_channel(1), sample_channel(2)],
clients: vec![sample_client(10, 1)],
own_client_id: 10,
}
}
#[test]
fn state_transitions_compile() {
let _ = ConnectionState::Idle;
fn snapshot_creates_state() {
let mut state = None;
let snapshot = sample_snapshot();
let reduction = reduce(&mut state, StateEvent::Snapshot(snapshot.clone()));
assert!(state.is_some());
let s = state.as_ref().unwrap();
assert_eq!(s.connection_state, ConnectionState::Ready);
assert_eq!(s.channel_count(), 2);
assert_eq!(s.client_count(), 1);
assert_eq!(s.own_client_id, 10);
assert_eq!(
reduction.deltas,
vec![
Delta::ConnectionStateChanged(ConnectionState::Ready),
Delta::SnapshotApplied(snapshot),
]
);
}
#[test]
fn channel_upsert_adds_and_updates() {
let mut state = None;
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
let ch = ChannelInfo {
id: ChannelId(3),
parent: ChannelId(1),
name: "new-channel".into(),
order: 1,
has_password: true,
needed_talk_power: Some(50),
};
let reduction = reduce(&mut state, StateEvent::ChannelChanged(ch.clone()));
let s = state.as_ref().unwrap();
assert_eq!(s.channel_count(), 3);
assert!(s.channel(ChannelId(3)).is_some());
assert!(matches!(&reduction.deltas[..], [Delta::ChannelUpserted(_)]));
let updated = ChannelInfo {
name: "renamed".into(),
..ch
};
reduce(&mut state, StateEvent::ChannelChanged(updated));
assert_eq!(
state.as_ref().unwrap().channel(ChannelId(3)).unwrap().name,
"renamed"
);
assert_eq!(state.as_ref().unwrap().channel_count(), 3);
}
#[test]
fn channel_delete_removes() {
let mut state = None;
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
let reduction = reduce(&mut state, StateEvent::ChannelDeleted(ChannelId(2)));
let s = state.as_ref().unwrap();
assert_eq!(s.channel_count(), 1);
assert!(s.channel(ChannelId(2)).is_none());
assert!(matches!(&reduction.deltas[..], [Delta::ChannelRemoved(_)]));
}
#[test]
fn channel_delete_removes_clients_in_deleted_channel() {
let mut state = None;
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
reduce(&mut state, StateEvent::ClientChanged(sample_client(20, 2)));
reduce(&mut state, StateEvent::ClientChanged(sample_client(30, 2)));
let reduction = reduce(&mut state, StateEvent::ChannelDeleted(ChannelId(2)));
let s = state.as_ref().unwrap();
assert!(s.channel(ChannelId(2)).is_none());
assert!(s.client(ClientId(20)).is_none());
assert!(s.client(ClientId(30)).is_none());
assert_eq!(s.client_count(), 1);
assert_eq!(s.clients_in_channel(ChannelId(2)).count(), 0);
assert_eq!(
reduction.deltas,
vec![
Delta::ClientRemoved(ClientId(20)),
Delta::ClientRemoved(ClientId(30)),
Delta::ChannelRemoved(ChannelId(2)),
]
);
}
#[test]
fn client_upsert_adds_and_moves() {
let mut state = None;
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
let new_client = sample_client(20, 2);
let reduction = reduce(&mut state, StateEvent::ClientChanged(new_client));
let s = state.as_ref().unwrap();
assert_eq!(s.client_count(), 2);
assert!(matches!(&reduction.deltas[..], [Delta::ClientUpserted(_)]));
let moved = ClientInfo {
channel: ChannelId(2),
..sample_client(10, 2)
};
reduce(&mut state, StateEvent::ClientChanged(moved));
assert_eq!(
state
.as_ref()
.unwrap()
.client(ClientId(10))
.unwrap()
.channel,
ChannelId(2)
);
}
#[test]
fn client_leave_removes() {
let mut state = None;
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
let reduction = reduce(&mut state, StateEvent::ClientLeft(ClientId(10)));
assert_eq!(state.as_ref().unwrap().client_count(), 0);
assert!(matches!(&reduction.deltas[..], [Delta::ClientRemoved(_)]));
}
#[test]
fn reconnect_discards_stale_state() {
let mut state = None;
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
assert_eq!(state.as_ref().unwrap().channel_count(), 2);
let reduction = reduce(&mut state, StateEvent::ReconnectStarted);
assert!(state.is_none());
assert!(matches!(
&reduction.deltas[..],
[Delta::ConnectionStateChanged(ConnectionState::Reconnecting)]
));
let snap2 = ServerSnapshot {
server_name: "New Server".into(),
channels: vec![sample_channel(100)],
clients: vec![sample_client(200, 100)],
..sample_snapshot()
};
reduce_reconnect_snapshot(&mut state, snap2);
let s = state.as_ref().unwrap();
assert_eq!(s.server_name, "New Server");
assert_eq!(s.channel_count(), 1);
assert_eq!(s.client_count(), 1);
}
#[test]
fn deltas_ignored_when_disconnected() {
let mut state: Option<ServerState> = None;
let r1 = reduce(&mut state, StateEvent::ChannelChanged(sample_channel(1)));
assert!(r1.deltas.is_empty());
let r2 = reduce(&mut state, StateEvent::ClientChanged(sample_client(1, 1)));
assert!(r2.deltas.is_empty());
}
#[test]
fn chat_message_requires_connected_state() {
let mut state: Option<ServerState> = None;
let msg = ChatMessage {
sender_id: ClientId(10),
sender_name: "Alice".into(),
message: "hello".into(),
target: MessageTarget::Channel,
};
let disconnected = reduce(&mut state, StateEvent::ChatReceived(msg.clone()));
assert!(disconnected.deltas.is_empty());
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
reduce(
&mut state,
StateEvent::ConnectionChanged(ConnectionState::Lost),
);
let lost = reduce(&mut state, StateEvent::ChatReceived(msg.clone()));
assert!(lost.deltas.is_empty());
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
let reduction = reduce(&mut state, StateEvent::ChatReceived(msg));
assert!(matches!(&reduction.deltas[..], [Delta::ChatMessage(_)]));
}
#[test]
fn connection_state_change_produces_delta() {
let mut state: Option<ServerState> = None;
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
let reduction = reduce(
&mut state,
StateEvent::ConnectionChanged(ConnectionState::Connecting),
);
assert_eq!(
state.as_ref().unwrap().connection_state,
ConnectionState::Connecting
);
assert!(matches!(
&reduction.deltas[..],
[Delta::ConnectionStateChanged(ConnectionState::Connecting)]
));
}
#[test]
fn snapshot_order_is_deterministic_and_live_inserts_append() {
let mut state = None;
let snapshot = ServerSnapshot {
channels: vec![sample_channel(20), sample_channel(10)],
clients: vec![sample_client(30, 20), sample_client(10, 10)],
..sample_snapshot()
};
reduce(&mut state, StateEvent::Snapshot(snapshot));
reduce(&mut state, StateEvent::ChannelChanged(sample_channel(15)));
reduce(&mut state, StateEvent::ClientChanged(sample_client(20, 2)));
let s = state.as_ref().unwrap();
let channel_ids: Vec<u64> = s.channels().map(|c| c.id.0).collect();
let client_ids: Vec<u64> = s.clients().map(|c| c.id.0).collect();
assert_eq!(channel_ids, vec![20, 10, 15]);
assert_eq!(client_ids, vec![10, 30, 20]);
}
#[test]
fn malformed_snapshot_duplicate_ids_are_deduplicated_in_order_vectors() {
let mut snapshot = sample_snapshot();
snapshot.channels.push(ChannelInfo {
name: "duplicate".into(),
..sample_channel(1)
});
snapshot.clients.push(ClientInfo {
name: "duplicate".into(),
..sample_client(10, 2)
});
let mut state = None;
let reduction = reduce(&mut state, StateEvent::Snapshot(snapshot));
let s = state.as_ref().unwrap();
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.channel(ChannelId(1)).unwrap().name, "duplicate");
assert_eq!(s.client(ClientId(10)).unwrap().channel, ChannelId(2));
let Delta::SnapshotApplied(emitted) = &reduction.deltas[1] else {
panic!("expected snapshot delta");
};
assert_eq!(emitted.channels.len(), 2);
assert_eq!(emitted.clients.len(), 1);
}
#[test]
fn voice_activity_updates_existing_client_and_emits_delta() {
let mut state = None;
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
let reduction = reduce(
&mut state,
StateEvent::VoiceActivityChanged {
client_id: ClientId(10),
is_speaking: true,
},
);
assert!(
state
.as_ref()
.unwrap()
.client(ClientId(10))
.unwrap()
.is_speaking
);
assert_eq!(
reduction.deltas,
vec![Delta::VoiceActivityChanged {
client_id: ClientId(10),
is_speaking: true,
}]
);
}
#[test]
fn voice_activity_for_unknown_client_is_ignored() {
let mut state = None;
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
let reduction = reduce(
&mut state,
StateEvent::VoiceActivityChanged {
client_id: ClientId(999),
is_speaking: true,
},
);
assert!(reduction.deltas.is_empty());
}
#[test]
fn own_channel_returns_correct_channel() {
let mut state = None;
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
let own = state.as_ref().unwrap().own_channel();
assert!(own.is_some());
assert_eq!(own.unwrap().id, ChannelId(1));
}
#[test]
fn clients_in_channel_filters_correctly() {
let mut state = None;
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
reduce(&mut state, StateEvent::ClientChanged(sample_client(20, 1)));
reduce(&mut state, StateEvent::ClientChanged(sample_client(30, 2)));
let s = state.as_ref().unwrap();
assert_eq!(s.clients_in_channel(ChannelId(1)).count(), 2);
assert_eq!(s.clients_in_channel(ChannelId(2)).count(), 1);
}
#[test]
fn malformed_delete_is_tolerated() {
let mut state = None;
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
let reduction = reduce(&mut state, StateEvent::ChannelDeleted(ChannelId(999)));
assert!(reduction.deltas.is_empty());
assert_eq!(state.as_ref().unwrap().channel_count(), 2);
}
#[test]
fn same_event_sequence_produces_same_state() {
let mut a: Option<ServerState> = None;
let mut b: Option<ServerState> = None;
let events: Vec<StateEvent> = vec![
StateEvent::Snapshot(sample_snapshot()),
StateEvent::ClientChanged(sample_client(20, 2)),
StateEvent::ChannelChanged(ChannelInfo {
id: ChannelId(3),
parent: ChannelId(1),
name: "x".into(),
order: 0,
has_password: false,
needed_talk_power: None,
}),
StateEvent::ClientLeft(ClientId(20)),
];
let mut deltas_a = Vec::new();
let mut deltas_b = Vec::new();
for e in &events {
deltas_a.extend(reduce(&mut a, e.clone()).deltas);
}
for e in &events {
deltas_b.extend(reduce(&mut b, e.clone()).deltas);
}
assert_eq!(
a.as_ref().unwrap().channel_count(),
b.as_ref().unwrap().channel_count()
);
assert_eq!(
a.as_ref().unwrap().client_count(),
b.as_ref().unwrap().client_count()
);
assert_eq!(
a.as_ref().unwrap().own_client_id,
b.as_ref().unwrap().own_client_id
);
assert_eq!(deltas_a, deltas_b);
}
}
+9 -1
View File
@@ -37,6 +37,7 @@ allow = [
"BSL-1.0",
"Unicode-3.0",
"CC0-1.0",
"CDLA-Permissive-2.0",
]
confidence-threshold = 0.93
@@ -61,7 +62,13 @@ yanked = "warn"
# Empty for v1.0.0-rc.1; if a future advisory blocks the release
# without a workspace fix, add it here with a tracking issue link
# and a date.
ignore = []
ignore = [
# RUSTSEC-2026-0150: audiopus_sys is unmaintained and references
# old CMake behavior. Chanora currently patches the `cmake` crate
# at workspace level to keep builds working while Opus dependency
# replacement is tracked as a release/supply-chain limitation.
"RUSTSEC-2026-0150",
]
# ---------- Bans (versions / packages) ----------
@@ -83,6 +90,7 @@ unknown-git = "warn"
# `Cargo.toml`. Allow the upstream and its workspace members.
allow-git = [
"https://github.com/ReSpeak/tsclientlib.git",
"https://github.com/EdisonJwa/oboe-rs",
]
[sources.allow-org]
-31
View File
@@ -1,31 +0,0 @@
# AI Agent Router — Chanora Minimal Spec
Purpose: keep AI context small. Do not load the old full bundle unless explicitly asked for audit reconstruction.
## Load Order
1. Always load this file first.
2. Load exactly one phase file: `P0_MVP_AGENT_SPEC.md`, `P1_BETA_AGENT_SPEC.md`, or `P2_PRODUCTION_AGENT_SPEC.md`.
3. Load `SOP_AGENT_OPERATIONS.md` only when changing architecture, release, security, privacy, diagnostics, build, or test gates.
4. Use `phase_index.json` for machine lookup. Do not paste full source docs into context.
## Phase Meaning
- P0 = MVP buildable product. Default implementation target.
- P1 = beta hardening and extended quality. Do not implement unless asked.
- P2 = production/public-release readiness. Do not implement until P0 and P1 gates pass.
- SOP = standing operating rules for agents, reviews, testing, release, traceability, and refusal conditions.
## Non-Negotiable Product Contract
- Product: Chanora, a cross-platform TeamSpeak 3-compatible client application.
- Stack: Flutter UI + Rust core + typed Flutter/Rust bridge + `tsclientlib` adapter.
- Platforms: Windows, macOS, Linux, Android, iOS.
- MVP server model: external TS3-compatible servers only; no Chanora-operated backend.
- MVP connection model: one active server connection per client instance.
- Privacy: no telemetry, no automatic diagnostic upload, no crash reporting unless later approved.
- Diagnostics: local, user-initiated export only.
- Storage: secrets in platform secure storage; non-secrets in bundled SQLite via `rusqlite`.
- Localization: English + Chinese Simplified at MVP; preserve server content verbatim.
- Design: Material 3 + Chanora Design System.
- License: Apache-2.0 OR MIT.
## Hard Stop Rules
Stop and ask for explicit approval before adding: telemetry, crash reporting, automatic upload, multiple active server connections, a Chanora backend, unsupported platform targets, unapproved hotkey/audio packages, or legal/trademark wording changes.
-136
View File
@@ -1,136 +0,0 @@
# P0 MVP Agent Spec — Build This First
## Objective
Create the smallest complete Chanora MVP that can connect to one TS3-compatible server, render channel/user state, support basic voice workflow, persist local non-secret settings, protect secrets, and pass platform P0 acceptance gates.
## Scope
Implement only P0/MVP. Treat P1/P2 items as backlog unless the user explicitly requests them.
## Accepted MVP Decisions
- DEC-001: Release type = Internal Alpha first, then External Beta, then MVP Public / Store Release
- DEC-002: MVP platform scope = MVP target remains Windows, macOS, Linux, Android, and iOS; first release may be staged by channel/platform
- DEC-003: Minimum iOS version = iOS 13 minimum for Flutter support baseline; test latest iOS release separately
- DEC-004: Minimum Android version = **Android API 28 (Android 9.0)** minimum, raised from the original recommendation of API 24 by explicit owner ruling on 2026-05-14. Rationale: simplifies the audio path (AAudio is unconditionally available from API 26+ and stable from API 28), narrows the TLS / privacy / scoped-storage compatibility surface, and matches typical 2026 Android baselines. The original cpal-on-Oboe Android spike was built with `minSdk = 24` and `cargo-ndk -P 26`; product code in `apps/chanora_flutter` uses `minSdk = 28` and the Android product audio backend is now direct Oboe, so Chanora no longer carries the API 24/25 OpenSL ES fallback branch.
- DEC-005: Android target SDK = Target the Google Play-required API level on the upload date; current release gate uses API 35+ unless newer Google policy applies
- DEC-006: Multiple server connections in MVP = Not in MVP; support one active server connection per client instance
- DEC-007: AEC default state = Enabled by default on platforms/audio backends where supported and stable
- DEC-008: AGC default state = Enabled by default, with user setting to disable
- DEC-009: Noise suppression default state = Enabled by default, with user setting to disable
- DEC-010: High-pass filter default state = Enabled by default
- DEC-011: Audio processing implementation path = Use platform-native audio processing first where available; use Rust/WebRTC-style processing as controlled fallback or later architecture option
- DEC-011.1: Audio crate choice = `cpal` for desktop (empirically verified on Linux/PipeWire by `poc/audio-capture-playback-spike` on 2026-05-13) and direct `oboe-rs` for Android product code. The earlier cpal-on-Oboe spike remains evidence that Rust audio reaches Oboe/AAudio on a Motorola Moto G Stylus 5G (2023) running Android 14 arm64-v8a, but product code chooses direct Oboe because P0 Android needs explicit `VoiceCommunication` input preset, `VoiceCommunication`/`Speech` output attributes, low-latency/exclusive stream requests with shared fallback, audio-session ids for hardware AEC/NS/AGC binding, and diagnostics of requested/achieved stream values. iOS crate TBD pending an iOS spike that requires macOS + Xcode hardware
- DEC-012: Official SDK / trademark / licensing review = Public/store release is blocked until legal confirms TeamSpeak non-affiliation wording, trademark usage, OSS licenses, and `tsclientlib` license posture
- DEC-013: Local database choice = Use SQLite or equivalent embedded local database for non-secret local state; secrets remain in platform secure storage
- DEC-013.1: SQLite crate = `rusqlite` with the `bundled` feature (SQLite statically linked into the binary; no system libsqlite3 dependency); verified by `poc/sqlite-storage-spike` on 2026-05-13
- DEC-013.2: Linux secure-storage backend policy = Prefer Secret Service (libsecret / gnome-keyring / kwallet / KeePassXC) on Linux; if the default collection is locked or D-Bus is unavailable, fall back to kernel keyutils with a clear user notice. Both backends are "equivalent" per SysRS-053 / SysRS-162; verified by `poc/secure-storage-spike` on 2026-05-13
- DEC-014: Bridge choice = Use a stable typed Flutter/Rust bridge with generated or schema-controlled DTOs; **`flutter_rust_bridge` 2.x pinned** (empirically verified at 2.12.0 by `poc/flutter_rust_bridge_hello` on 2026-05-13)
- DEC-015: Product language for MVP = **English + Chinese (Simplified) for MVP**, raised from the original recommendation of English-only by explicit owner ruling on 2026-05-14. Rationale: the demonstrated test-server population (verified live against `cn.teamspeak.app`) and broader TS3 audience include substantial Chinese-speaking users; shipping zh-Hans alongside en at MVP avoids a launch-window UX gap. Architecture remains i18n-ready so additional languages can be added later mechanically. Server-provided content is preserved verbatim and never translated (ADR-008 UTF-8 boundary, DEC-015 server-content rule retained).
- DEC-016: Diagnostics upload policy = No automatic upload for MVP; user-initiated local diagnostic export only
- DEC-017: Crash reporting = Disabled for MVP unless explicit opt-in provider and privacy policy are approved
- DEC-018: Public product name = Chanora
- DEC-021: Apple App Store submission SDK = Use Xcode 26 or later and the iOS 26 / iPadOS 26 SDK or later for App Store submission on or after 2026-04-28, unless Apple publishes a newer applicable requirement before upload
- DEC-020: License model = **Dual-licensed under Apache-2.0 OR MIT (recipient's choice)**, the standard Rust-ecosystem permissive license model. Accepted on 2026-05-14. Compatible with every direct dependency in the PoC tree (`tsclientlib` MIT-OR-Apache-2.0, `flutter_rust_bridge` MIT, `cpal` Apache-2.0, `rusqlite` MIT, `keyring` MIT-OR-Apache-2.0, etc.) and with the Flutter framework's BSD-3-Clause. The license texts ship as `LICENSE-APACHE` and `LICENSE-MIT` at the repository root; an aggregator `LICENSE` points to both. `NOTICE` enumerates current direct-dependency attributions. The full OSS legal review (transitive deps, license obligations, OSS notices) remains under DEC-012 and is still required before public release.
- DEC-022: Canonical implementation directory layout = Accept the README's sketch as canonical: `apps/chanora_flutter/`, `core/chanora_core/`, `crates/chanora_protocol/`, `crates/chanora_audio/`, `crates/chanora_state/`, `crates/chanora_storage/`, `crates/chanora_diagnostics/`, `crates/chanora_bridge/`. Matches SAD §7.2 module decomposition
- DEC-023: Windows desktop Global PTT priority = **P0 in MVP.** Resolves PTT-OPEN-001 from `gen2/chanora-desktop-ptt-review-summary-v0.9.2.md`. The Windows backend ladder (Raw Input → low-level keyboard hook → Focused fallback) is mandatory for the first public release; release notes shall not claim Global PTT support on Windows until live measurement confirms a Global level from a non-fallback rung.
- DEC-024: macOS desktop Global PTT priority = **P0 in MVP, with explicit permission UX flow.** Resolves PTT-OPEN-002. The macOS backend shall request the Input Monitoring / Accessibility permission, accept user denial gracefully (continue at `L0Focused` without functional regression), and upgrade to Global asynchronously when the user grants the permission.
- DEC-025: Officially-tested Linux environment for first public release = **GNOME on Wayland (only).** Resolves PTT-OPEN-003. The Linux backend shall use the freedesktop `org.freedesktop.portal.GlobalShortcuts` interface on GNOME-on-Wayland and fall back to Focused PTT on every other Linux environment (X11, sway, KDE, untested compositors). Release notes shall not claim Global PTT support outside the tested compositor.
- DEC-026: Mouse side-button support in first desktop PTT release = **Supported on Windows and macOS; Linux follows whatever the GlobalShortcuts portal exposes.** Resolves PTT-OPEN-004. The Raw Input backend (Windows) and the Event Tap backend (macOS) shall accept Mouse4 / Mouse5 bindings; the Linux portal binding accepts whatever the session exposes and the release notes shall not over-claim.
- DEC-027: PTT diagnostics privacy posture = **Capability and availability state only — no raw key codes ever leave the device.** Resolves PTT-OPEN-005. The diagnostic export shall name only `PttCapabilityLevel`, `backend_id`, `bound_input_class`, and `fallback_exercised`; the user's actual key value (scan code, virtual key, keysym) shall never be logged, persisted, or exported. `PttSanitizer` enforces this at write time.
- DEC-028: Missed-key-up watchdog requirement = **P0.** Resolves PTT-OPEN-006. The audio engine shall include a missed-key-up watchdog that clears `transmit_active` after a configured ceiling (default 30 s) when no key-up event arrives. The watchdog is a P0 release-gate item rather than an implementation-level concern because the failure mode (stuck transmission after the user has released the binding) is user-visible and reputation-relevant.
- DEC-029: Flutter global-hotkey packages rejected for PTT = **Use the native Rust `DesktopPttBackend` trait + per-OS implementations (already specified in SDD-083 / SDD-084 / SDD-085); do not adopt `hotkey_manager`, `super_hot_key`, or any equivalent pub.dev package for PTT.** Rationale: those packages wrap the OS `RegisterHotKey` / `RegisterEventHotKey` semantics — they consume the key (suppressing it from other applications), they do not deliver a key-up event, and they do not support mouse side-buttons. PTT requires the opposite primitive (observe, do not consume, deliver both up and down).
- DEC-030: Voice activity detection deferred to P1 = **`TransmitMode::VoiceActivity` ships as a reserved enum variant with no v1 implementation.** The settings UI shows it as a disabled "coming soon" option. The actual implementation choice is deferred to a future baseline. Rationale: three viable backends were compared (RMS energy threshold — trivial but quality-poor; WebRTC VAD via the `webrtc-vad` crate — frozen-but-stable C++ BSD-3 dep, ~200 KB binary, industry baseline; Silero VAD via ONNX Runtime — best quality but ~816 MB binary uplift per platform plus an ONNX-runtime dependency surface). The v1 dependency-surface budget does not have room for the trade-off review required to pick correctly. Choosing too early risks either user-visible quality regression (RMS) or a forced ONNX adoption (Silero) before there is a comparable need for ML inference elsewhere (noise suppression, AEC).
- DEC-001: Product Owner = Release type sequence
- DEC-002: Product Owner / Engineering = MVP platform strategy
- DEC-003: Product Owner / iOS Owner = Minimum iOS version
- DEC-004: Product Owner / Android Owner = Minimum Android version
- DEC-005: Android Owner / Release Manager = Android target SDK
- DEC-006: Product Owner / Software Architect = Multiple server connections in MVP
- DEC-007: Audio Owner / Product Owner = AEC default
- DEC-008: Audio Owner / Product Owner = AGC default
- DEC-009: Audio Owner / Product Owner = Noise suppression default
- DEC-010: Audio Owner = High-pass filter default
- DEC-011: Software Architect / Audio Owner = Audio processing path
- DEC-011.1: Software Architect / Audio Owner = Audio crate (desktop / mobile)
- DEC-012: Legal / Compliance = SDK/trademark/licensing review
- DEC-013: Software Architect / Storage Owner = Local database
- DEC-013.1: Software Architect / Storage Owner = SQLite crate
- DEC-013.2: Software Architect / Storage Owner / Security Reviewer = Linux secure-storage backend policy
- DEC-014: Software Architect = Bridge choice
- DEC-015: Product Owner = Product language for MVP
- DEC-016: Product Owner / Legal / Security = Diagnostics upload policy
- DEC-017: Product Owner / Legal / Security = Crash reporting
- DEC-018: Product Owner = Public product name
- DEC-020: Product Owner / Legal = License model
- DEC-021: iOS Owner / Release Manager = Apple App Store SDK gate
- DEC-022: Software Architect = Canonical implementation directory layout
- DEC-023: Product Owner / Windows Platform Owner = Windows desktop Global PTT priority
- DEC-024: Product Owner / macOS Platform Owner = macOS desktop Global PTT priority
- DEC-025: Product Owner / Linux Platform Owner = Officially-tested Linux environment
- DEC-026: Product Owner / UX Owner = Mouse side-button support
- DEC-027: Security / Privacy Reviewer = PTT diagnostics privacy posture
- DEC-028: Audio Owner / Software Architect = Missed-key-up watchdog
- DEC-029: Software Architect / Audio Owner = Flutter global-hotkey packages rejected for PTT
- DEC-030: Audio Owner / Product Owner = Voice activity detection deferred to P1
## Required P0 Capability Groups
1. App shell: routing, startup, theme, localization, adaptive layout, platform-service initialization.
2. UI/design: Material 3, Chanora design tokens, responsive desktop/tablet/mobile layouts, accessible controls, keyboard focus, safe areas/insets.
3. Protocol: TS3-compatible adapter through `tsclientlib`; no direct UI coupling to protocol internals.
4. Connection: one active server connection; visible connecting/connected/reconnecting/disconnected/error states.
5. Channel/user tree: preserve Unicode server content; do not translate server names/messages.
6. Voice basics: capture/playback path, push-to-talk where platform-supported, mute/deafen indicators, native processing defaults.
7. Audio defaults: AEC on where stable, AGC on with user toggle, noise suppression on with user toggle, high-pass filter on.
8. Storage: SQLite for non-secret state; platform secure storage for identities/tokens/password-equivalent secrets.
9. Diagnostics: local export only; redact secrets; machine fields remain language-neutral.
10. Builds: reproducible platform builds for Windows, macOS, Linux, Android, and iOS with staged release allowed.
11. Verification: unit, widget, integration, platform acceptance, and release go/no-go evidence.
## P0 Architecture Boundaries
- Flutter owns UI, navigation, Material 3 theming, accessibility, and adaptive presentation.
- Rust core owns domain state, protocol/session orchestration, storage, diagnostics, and platform-abstracted services.
- Bridge owns typed DTOs/events/errors only. Do not leak Rust internals into Flutter or Flutter objects into Rust core.
- Platform adapters own OS-specific audio, hotkey, secure storage, build, and permission behavior.
## P0 Definition of Done
- User can launch app, enter/connect to a TS3-compatible server, see server/channel/user state, and disconnect cleanly.
- App supports the selected P0 voice path without telemetry or automatic upload.
- Secrets are never stored in SQLite/plaintext.
- English and zh-Hans product strings are available; server-originated Unicode remains unchanged.
- All diagnostic exports are local/user-triggered and redacted.
- Platform P0 acceptance docs pass or contain explicit blocker records.
- Every code change cites at least one requirement or decision ID.
## P0 Requirement Index
- Count: 500
- ID groups: SAD-001..SAD-096 (71 ids); SDD-001..SDD-121 (98 ids); SRS-001..SRS-219 (178 ids); SysDes-001..SysDes-158 (153 ids)
## P0 Source Weighting
- `requirements/srs.md`: 178
- `architecture/sysdes.md`: 153
- `architecture/sdd.md`: 98
- `architecture/sad.md`: 71
## P0 Allocation Hotspots
- Unallocated: 98
- Flutter UI: 68
- Rust Core: 52
- Verification: 43
- Audio Subsystem: 42
- Software: Audio Subsystem: 39
- System Engineering: 33
- Protocol Adapter: 31
- Diagnostics: 28
- State Sync: 24
- Design System: 22
- Bridge: 21
- Storage: 21
- Software: Diagnostics: 21
- Platform: 20
- Software: Protocol Adapter: 20
- Platform Adapters: 19
- Software: Rust Core: 19
- Audio: 18
- Operations: 17
## Agent Compression Rule
Do not paste all P0 requirement statements into prompt context. Use this file as the execution contract and `phase_index.json` only when an exact ID/source lookup is required.
-58
View File
@@ -1,58 +0,0 @@
# P1 Beta Agent Spec — Harden After MVP
## Objective
Improve reliability, beta readiness, accessibility coverage, diagnostics usefulness, and platform quality after P0 works end-to-end.
## Entry Gate
Do not start P1 until P0 MVP acceptance is passing or blockers are formally recorded.
## P1 Scope
- Harden reconnection/error handling beyond MVP happy path.
- Expand integration/system tests and beta acceptance evidence.
- Improve accessibility, keyboard workflows, adaptive layout edge cases, and localization completeness.
- Improve diagnostics quality without changing the privacy model.
- Prepare external beta release notes, support workflows, and known-issue tracking.
## P1 Non-Goals
- No telemetry by default.
- No automatic diagnostic upload.
- No multiple active server connections unless a new accepted decision overrides DEC-006.
- No production/store launch gates unless explicitly promoted to P2.
## P1 Definition of Done
- P0 regressions remain passing.
- Beta blockers are triaged and traceable.
- Diagnostic exports are useful for support while still redacted and local.
- Platform-specific beta risks have owner/status records.
## P1 Requirement Index
- Count: 81
- ID groups: SAD-035..SAD-066 (18 ids); SDD-051..SDD-109 (20 ids); SRS-028..SRS-191 (38 ids); SysDes-122, SysDes-123, SysDes-124, SysDes-125, SysDes-138
## P1 Source Weighting
- `requirements/srs.md`: 38
- `architecture/sdd.md`: 20
- `architecture/sad.md`: 18
- `architecture/sysdes.md`: 5
## P1 Allocation Hotspots
- Unallocated: 20
- Diagnostics: 14
- Audio Subsystem: 13
- Flutter UI: 9
- Platform Adapters: 9
- Deployment Scripts: 7
- Software Architecture: 6
- Application Container: 6
- Storage: 5
- Protocol Adapter: 5
- Localization Service: 5
- Bridge: 4
- Flutter App Shell: 4
- Software Design: 4
- Platform Adapter: 4
- Platform Audio Adapter: 3
- State Sync: 3
- Rust Core: 3
- Verification Support: 2
- Flutter State: 2
-34
View File
@@ -1,34 +0,0 @@
# P2 Production Agent Spec — Public Release Readiness
## Objective
Prepare Chanora for public/store release only after P0 implementation and P1 beta hardening are complete.
## Entry Gate
Do not begin P2 until P0 and P1 evidence is available. Public release is blocked by legal/trademark/OSS review and store-policy readiness.
## P2 Scope
- Final release go/no-go records.
- Store metadata and platform upload compliance.
- Legal review: TeamSpeak non-affiliation wording, trademark usage, OSS license posture, `tsclientlib` license posture.
- Final privacy policy confirmation against actual app behavior.
- Production build signing, artifact policy, and staged rollout policy.
## P2 Non-Goals
- Do not add telemetry/crash reporting to satisfy production readiness unless privacy/legal/product explicitly approve it.
- Do not expand server/backend scope.
- Do not add new features that destabilize P0/P1 acceptance.
## P2 Definition of Done
- Release manager has signed go/no-go evidence.
- Legal/compliance blockers are closed.
- Platform upload SDK/target requirements are current on upload date.
- Release artifacts are reproducible, signed where required, and mapped to a version/tag.
## P2 Requirement Index
- Count: 11
- ID groups: SAD-045, SAD-046, SAD-047, SAD-048, SAD-049, SAD-050, SAD-055; SDD-063; SRS-117, SRS-154, SRS-175
## P2 Source Weighting
- `architecture/sad.md`: 7
- `requirements/srs.md`: 3
- `architecture/sdd.md`: 1
-11
View File
@@ -1,11 +0,0 @@
# Chanora AI-Agent Minimal Phased Requirements
This bundle intentionally removes the large rewritten archive and trace CSV. It is designed for AI agents with limited context.
Recommended loading:
1. `00_AGENT_ROUTER.md`
2. One phase file: P0, P1, or P2
3. `SOP_AGENT_OPERATIONS.md` only when process/release/security rules matter
4. `phase_index.json` only for exact ID lookup
Do not load the old full bundle unless performing audit reconstruction.
-46
View File
@@ -1,46 +0,0 @@
# SOP Agent Operations — Standing Rules
## Core Agent Rules
1. Default to P0. Do not implement P1/P2 unless explicitly requested.
2. Preserve traceability: commit messages, PR descriptions, tests, and review notes must cite decision/requirement IDs.
3. Make the smallest compliant change. Avoid architecture invention.
4. If requirements conflict, precedence is: Product Decision Register > SysRS > SysDes > SRS > SAD > SDD > Verification/Release/Security docs.
5. If a task requires exact historical detail, inspect `phase_index.json`; do not load the obsolete full bundle.
## Required Review Gates
- Architecture boundary review for Flutter/Rust/bridge/protocol/storage/audio changes.
- Privacy review for diagnostics, logging, crash handling, analytics, upload, identifiers, or support artifacts.
- Security review for secure storage, redaction, secrets, dependencies, and supply chain.
- Release review for platform build, signing, SDK, store, and artifact policy.
- Localization review for product strings and server-content preservation.
## Testing SOP
- Unit tests: Rust core, DTO transforms, storage, redaction, decision logic.
- Widget tests: Material 3 components, state tokens, localization, accessibility semantics, adaptive layouts.
- Integration tests: bridge events/errors, protocol adapter state, diagnostics export, storage migration.
- Platform tests: secure storage, audio capture/playback, permissions, hotkeys/PTT, packaging.
- Acceptance tests: use platform P0 acceptance docs before any release promotion.
## Build SOP
- Keep repo layout canonical: `apps/chanora_flutter/`, `core/chanora_core/`, `crates/chanora_*`.
- Pin `flutter_rust_bridge` 2.x.
- Use bundled `rusqlite` for embedded SQLite.
- Android minSdk = 28; target API must match current Google Play requirement on upload date.
- iOS minimum = 13; App Store upload SDK gate must be checked on actual upload date.
## Privacy/Security SOP
- No telemetry in MVP.
- No crash reporting unless approved.
- No automatic diagnostic upload.
- Diagnostics are local export only and must redact secrets.
- Product-owned strings may be localized; server-originated content must be preserved verbatim.
- Secrets never enter logs, diagnostics, SQLite, screenshots, or crash-like artifacts.
## Release SOP
- Release sequence: Internal Alpha -> External Beta -> MVP Public.
- Public/store release requires legal/trademark/OSS license confirmation.
- Staged platform release is allowed when documented.
- Every release must include version, commit/tag, artifact list, known issues, test evidence, and go/no-go result.
## Agent Refusal / Escalation Conditions
Stop implementation and ask for owner approval when asked to add or change: telemetry, crash reporting, diagnostic uploads, backend service, multi-server active sessions, platform scope, license, trademark/non-affiliation text, secret-storage policy, or target SDK/minimum OS baseline.
@@ -0,0 +1,43 @@
# Chanora Desktop Push-to-Talk Architecture
**Document status:** DV meeting baseline candidate
**Date:** 2026-05-29
**Related documents:** `docs/architecture/sad.md`, `docs/architecture/sdd.md`, `docs/release/dv-waiver-register.md`
## 1. Purpose
This document records the desktop push-to-talk architecture advertised by the README and connects it to the SWE.2/SWE.3 baselines.
## 2. Architecture Summary
Desktop PTT is implemented as a platform-capability feature. The application must detect the active backend, expose the resulting `PttCapabilityLevel`, and avoid claiming global PTT support when the runtime falls back to focused-input behavior.
## 3. Platform Backends
| Platform | Backend strategy | Release claim rule |
|---|---|---|
| Windows | Raw Input first, low-level keyboard hook fallback, focused fallback if unavailable | Claim only the detected runtime capability |
| macOS | Event Tap where permission and OS policy allow; focused fallback otherwise | Claim Global PTT only with permission/backend evidence |
| Linux | Freedesktop GlobalShortcuts portal where available; focused fallback otherwise | State portal/fallback behavior clearly |
## 4. Safety Rules
| Rule | Purpose |
|---|---|
| Missed-key-up watchdog clears transmit after timeout | Prevents stuck transmit when an OS suppresses key-up |
| Capability is surfaced to UI and release record | Prevents over-claiming platform support |
| Mouse side-button support is platform-dependent | Avoids blocking release on Linux portal limitations |
| Focused fallback remains available | Preserves usable PTT when global backends are unavailable |
## 5. Verification Handoff
| Evidence | Required result |
|---|---|
| Per-platform smoke | Active backend and fallback behavior recorded |
| UI inspection | PTT capability badge matches runtime backend |
| Release readiness | Release notes mirror actual capability per platform |
| Safety test | Watchdog prevents stuck transmit after missed key-up |
## 6. DV Conclusion
The desktop PTT architecture is documented for DV navigation. Public release claims still require per-platform PTT evidence attached to the release-readiness record.
+186
View File
@@ -0,0 +1,186 @@
# Chanora Software Architecture Description
**Lifecycle:** SWE.2 Software Architectural Design
**Document status:** DV meeting baseline candidate
**Date:** 2026-05-29
**Direct upstream source:** `docs/srs.md`
**Related system allocation:** `docs/sysdes.md`
## 1. Purpose
This Software Architecture Description defines Chanora's software architecture for DV review. It bridges SRS software requirements to SWE.3 detailed design and to SWE.5/SWE.6 verification planning.
This baseline captures the architecture visible in the current repository. It is sufficient for DV traceability review, while deeper per-module algorithms remain in `docs/architecture/sdd.md` and source-level design.
## 2. Architectural Scope
Chanora is a Flutter application with a Rust core. Flutter owns the user-facing shell, Material 3 widgets, localization, permission UX, and platform service presentation. Rust owns connection orchestration, protocol isolation, audio processing, storage coordination, diagnostics, server resolution, prefetch policy, and bridge DTOs.
## 3. Upstream SRS Allocation
| SRS group | Architectural allocation |
|---|---|
| SRS-003, SRS-008 through SRS-016 | Cross-platform app shell, Flutter UI, Rust core, platform adapters |
| SRS-017 through SRS-030 | Flutter UI, state presentation, connection and voice controls |
| SRS-031 through SRS-035 | Bridge layer and typed DTO boundary |
| SRS-036 through SRS-043 | Rust core connection lifecycle and state behavior |
| SRS-044 through SRS-053 | Protocol adapter and TeamSpeak-compatible server boundary |
| SRS-054 through SRS-061 | State synchronization and replay/reducer verification hooks |
| SRS-062 through SRS-083 | Audio subsystem, DSP, codec, PTT, mute/deaf, metering |
| SRS-084 through SRS-095 | Storage, secure storage, identity, diagnostics-sensitive data |
| SRS-096 through SRS-102 | Diagnostics, export, redaction, troubleshooting hooks |
| SRS-103 through SRS-123 | Platform adapters, packaging, release behavior |
| SRS-124 through SRS-143 | Verification support, analysis requirements, traceability rules |
| SRS-144 through SRS-184 | Material 3, adaptive UI, accessibility, localization, Unicode, app initialization |
| SRS-185 through SRS-218 | Platform baselines, PTT capability, transmit mode, no automatic telemetry, benchmark advisory |
## 4. Component Architecture
| Component | Repository location | Responsibility | Direct architectural dependencies |
|---|---|---|---|
| Flutter app shell | `apps/chanora_flutter/lib/main.dart`, services, widgets | App startup, screen composition, user actions, localization, Material 3 UI | Generated Rust bridge, platform plugins, Flutter services |
| Flutter service layer | `apps/chanora_flutter/lib/services/` | Permission flows, lifecycle policy, host prefetch debounce, link trust, state mapping, platform back intent | Flutter app shell, generated bridge APIs, platform plugins |
| Flutter widget layer | `apps/chanora_flutter/lib/widgets/` | Connect UI, channel tree, chat, voice controls, settings, diagnostics surfaces | Flutter services, generated DTOs, design tokens |
| Bridge layer | `crates/chanora_bridge`, `apps/chanora_flutter/lib/src/rust/` | Typed Flutter/Rust boundary and generated bindings | Rust core, Flutter generated code |
| Rust core | `core/chanora_core` | Connection lifecycle, orchestration, reconnect behavior, storage coordination, voice state | Protocol, audio, storage, diagnostics, state, resolver/prefetch |
| Protocol adapter | `crates/chanora_protocol` | Isolate `tsclientlib`, expose typed protocol DTOs/errors | Rust core, external compatible server |
| State sync | `crates/chanora_state` | Snapshot/delta model, channel join helpers, reducer behavior | Rust core, protocol DTOs |
| Audio subsystem | `crates/chanora_audio` | Capture/playback, Opus, DSP, PTT, voice activity reservation, platform units | Rust core, platform APIs, protocol audio path |
| Storage | `crates/chanora_storage` | Bookmarks, identities, encrypted local data, platform keyring integration | Rust core, platform secure storage |
| 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 prefetch | `crates/chanora_server_prefetch`, Flutter `prefetch_debouncer.dart` | Invisible host-field resolution warming, TTL cache, generation safety | Resolver, Flutter connect UI, Rust core |
## 5. Static Architecture View
```text
Flutter UI/widgets/services
-> generated Dart bridge API
-> chanora_bridge
-> chanora_core
-> chanora_protocol -> tsclientlib -> external compatible server
-> chanora_state
-> chanora_audio -> platform audio APIs / Opus / DSP
-> chanora_storage -> platform secure storage / SQLite
-> chanora_diagnostics
-> chanora_server_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.
## 6. Runtime Flow Architecture
### 6.1 Connect Flow
```text
User enters host/bookmark
-> Flutter connect widgets
-> optional prefetch debounce
-> bridge connect command
-> Rust core supervisor
-> resolver / prefetch cache
-> protocol adapter
-> external compatible server
-> state snapshot/events
-> bridge event stream
-> Flutter state mapper and widgets
```
### 6.2 Voice Flow
```text
Microphone / platform input
-> audio capture unit
-> DSP chain: HPF, NS, AEC, AGC where active
-> PTT/mute/transmit gate
-> Opus encode
-> protocol adapter
-> external compatible server
External server voice
-> protocol adapter
-> jitter/decode path
-> mixer / per-user controls
-> platform output
```
### 6.3 Diagnostics Flow
```text
Runtime event or error
-> diagnostic log sink / known-secret registry
-> redactor
-> user-initiated export bundle
-> Flutter share/export surface
```
## 7. Interface Catalogue
| Interface | Producer | Consumer | Architectural rule |
|---|---|---|---|
| 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 |
| 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 |
| 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 |
| Platform capability records | Platform adapters/audio/PTT backends | UI and release record | UI/release wording must not over-claim capability |
## 8. Dependency Rules
| Rule | Rationale |
|---|---|
| Flutter UI depends on generated bridge APIs, not Rust internals | Keeps UI stable across Rust implementation changes |
| Rust core orchestrates crates but protocol/audio/storage crates remain separately testable | Supports SWE.4 unit verification and bounded responsibilities |
| Protocol adapter is the only component that owns `tsclientlib` coupling | Protects the app from protocol-library leakage |
| Diagnostics redaction must be reusable by runtime logging and export | Prevents split redaction behavior |
| Platform-specific behavior stays in platform adapters or audio platform units | Keeps cross-platform logic testable and reduces conditional sprawl |
| Release claims consume capability records and release evidence | Prevents over-claiming PTT, signing, packaging, or secure-storage behavior |
## 9. Non-Functional Allocation
| Concern | Architectural mechanism | Verification owner |
|---|---|---|
| Real-time audio responsiveness | Rust audio subsystem, benchmark advisory, bounded callback behavior | Audio / Platform QA |
| Privacy and no automatic telemetry | User-initiated diagnostics, no automatic upload policy | Security / Privacy QA |
| Secure secret handling | Platform secure-storage abstraction and encrypted local storage | Security / QA |
| Cross-platform UI | Flutter Material 3, design tokens, responsive widgets | Software QA / UX |
| Protocol compatibility | `tsclientlib` adapter isolation and compatible-server matrix | Protocol / Integration QA |
| Release reproducibility | CI, build scripts, artifact hashes, release-readiness record | Release / Operations QA |
## 10. Architectural Decisions Captured by This Baseline
| Decision | Architectural outcome |
|---|---|
| Flutter + Rust split | Flutter owns presentation; Rust owns protocol/audio/storage/diagnostics core behavior |
| `tsclientlib` isolation | Protocol compatibility is behind `chanora_protocol` |
| 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 |
| 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 |
| No automatic diagnostic upload in MVP | Diagnostics are local and user-initiated unless future approved requirements change policy |
## 11. Verification Handoff
| Verification plan | SAD handoff |
|---|---|
| SWE.4 | Component boundaries define unit-test ownership for Flutter services/widgets and Rust crates |
| SWE.5 | Interface catalogue and runtime flows define integration paths |
| SWE.6 | SRS allocation and acceptance flows define software acceptance evidence |
| SYS.4 | Platform capability and external-server boundaries define system integration evidence |
## 12. Traceability to SRS
This SAD derives only from `docs/srs.md`. The broad SRS group-to-component allocation in section 3 is the controlling SWE.2 trace for DV. Detailed item-level trace is represented by the SRS coverage matrix and `docs/governance/traceability-matrix.md`.
## 13. Open Architecture Risks
| Risk | Impact | Control |
|---|---|---|
| SAD item numbering from historical status references is not reconstructed in this baseline | Existing references such as `SAD-043` and `SAD-046` are not itemized here | Treat this as a DV baseline SAD; add itemized SAD IDs in a follow-up if process requires strict ID-level review |
| Some architecture views are textual rather than C4 diagrams | Reviewers may request visual C4 views | Record as documentation hardening, not a blocker for DV baseline if textual views are accepted |
| Release/platform architecture evidence is incomplete | Public release remains blocked | Controlled by release-readiness and waiver records |
## 14. DV Conclusion
This SWE.2 baseline is sufficient to remove the missing-SAD traceability gap for DV review. It does not replace candidate test evidence or final release approval.
+154
View File
@@ -0,0 +1,154 @@
# Chanora Software Detailed Design
**Lifecycle:** SWE.3 Software Detailed Design and Unit Construction Handoff
**Document status:** DV meeting baseline candidate
**Date:** 2026-05-29
**Direct upstream source:** `docs/architecture/sad.md`
**Related software requirements:** `docs/srs.md`
## 1. Purpose
This Software Detailed Design defines the module-level design details needed for SWE.4 unit verification and SWE.5 integration verification. It is based on the current repository layout and the SWE.2 architecture baseline.
## 2. Module Catalogue
| SDD module | Source location | Primary responsibility | Upstream SAD component |
|---|---|---|---|
| SDD-MOD-001 Flutter app bootstrap | `apps/chanora_flutter/lib/services/app_bootstrap.dart`, `main.dart` | Initialize Rust bridge, localization, app services, theme/design baseline | Flutter app shell |
| SDD-MOD-002 Connect UI | `apps/chanora_flutter/lib/widgets/connect_widgets.dart` | Host/bookmark inputs, connect actions, pre-request UX | Flutter widget layer |
| SDD-MOD-003 Snapshot and channel UI | `snapshot_view.dart`, `snapshot_state_mapper.dart`, `channel_spacer.dart` | Present channel tree, clients, and mapped state | Flutter widget/service layer |
| SDD-MOD-004 Chat UI | `chat_views.dart`, `bbcode_text.dart` | Channel text rendering and BBCode-safe display | 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-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`, `ptt.rs` | Connection orchestration, reconnect, PTT state, storage coordination | Rust core |
| 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-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-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_server_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 |
## 3. Bridge Boundary Design
The bridge boundary is the only supported Flutter-to-Rust command path. Dart code uses generated APIs under `apps/chanora_flutter/lib/src/rust/`; Rust exposes bridge functions through `crates/chanora_bridge/src/api.rs`.
Design rules:
| Rule | Detail |
|---|---|
| DTO stability | DTO fields must be explicit and serializable through Flutter Rust Bridge generation |
| Error safety | Rust errors exposed to Flutter must be user-safe or mapped before display |
| Secret handling | Secrets may cross only as command inputs or protected DTO fields and must be registered for diagnostic redaction where relevant |
| Capability reporting | Platform and PTT capability fields must reflect actual active backend state |
| Regeneration control | Generated bridge files are implementation artifacts and must be regenerated when bridge API signatures change |
## 4. Connection and State Design
| Detail | Design |
|---|---|
| Connection lifecycle | Rust core owns connect/disconnect/reconnect decisions and suppresses reconnect after user disconnect |
| Backoff | Reconnect uses exponential backoff as described in implementation status, capped at 60 seconds |
| Server resolution | Resolver performs SRV/TSDNS/DNS fallback; prefetch cache may warm but must not be required for connect success |
| Snapshot mapping | Rust state and bridge DTOs are mapped into Flutter view models by `snapshot_state_mapper.dart` |
| Channel join | Channel join logic and errors are represented through Rust state/protocol handling and Flutter error mapper service |
| Reducers | `chanora_state` owns snapshot/delta reducer design with unit coverage for snapshot, delta, reconnect, duplicate normalization, disconnected/lost suppression, unknown-client voice activity, deterministic ordering, and channel-delete/client cleanup. Current runtime UI refresh still flows through `chanora_core` snapshot/probe paths; full live-event folding through `chanora_state::reduce` is an integration follow-up. |
## 5. Audio Detailed Design
| Audio element | Design detail |
|---|---|
| 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 |
| 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 |
| 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 |
| Benchmarks | Realtime capture, Opus, and resampler benchmarks provide advisory baseline evidence |
## 6. Storage and Secret Design
| Storage item | Design detail |
|---|---|
| Bookmarks | Stored locally through the storage crate and surfaced in Flutter connect UI |
| Identity references | Stored through `IdentityFileStore` and platform secure storage where available |
| Passwords/secrets | Encrypted at rest using the current storage design; Android Keystore-backed DEK is deferred and must be disclosed |
| CI keyring behavior | CI disables real keyring access with `CHANORA_DISABLE_KEYRING=1` to avoid headless blocking |
| Fallback behavior | Platform fallback modes must be represented as limitations in release/security evidence |
## 7. Diagnostics Detailed Design
| Diagnostic element | Design detail |
|---|---|
| Log sink | Runtime logs can be captured by diagnostic sinks for export |
| Known-secret registry | Runtime secrets are registered for redaction where applicable |
| Redactor | Redacts configured sensitive patterns before export |
| Export bundle | Diagnostic export is JSON-based and user-initiated |
| Upload policy | MVP has no automatic diagnostic, telemetry, or crash upload |
## 8. Flutter UI Detailed Design
| UI area | Design detail |
|---|---|
| Design tokens | `chanora_tokens.dart` centralizes product styling over Material 3 |
| Platform capability display | `platform_capabilities.dart` and PTT capability widgets expose platform-specific support honestly |
| Localization | Generated localization files provide English and Simplified Chinese resources |
| Responsive behavior | Current widgets support compact/mobile-oriented layouts; expanded side-pane hardening remains P1/P2 as recorded |
| Accessibility | Critical status should use text/icons/semantics and not color alone; verification remains through UI tests/audit |
| UI settings persistence | `UiPreferencesService` persists host, nickname, permission explanation state, and theme mode through `shared_preferences`; invalid stored theme values fall back to system theme |
## 9. Build and Release Detailed Design
| Build/release item | Design detail |
|---|---|
| Rust CI | `.github/workflows/ci.yml` runs cargo check/test and advisory clippy |
| Flutter CI | `.github/workflows/ci.yml` runs Flutter pub get, analyze, and tests |
| Supply chain | CI runs cargo-deny and license inventory checks |
| iOS unsigned build | CI runs `flutter build ios --release --no-codesign` |
| Audio benchmarks | `bench-advisory.yml` runs audio benchmarks and posts advisory evidence |
| Platform packages | Public binary packaging/signing/notarization remains release-gated |
## 10. Verification Hook Design
| Module | SWE.4 unit hooks | SWE.5/SWE.6 integration hooks |
|---|---|---|
| Flutter services/widgets | Dart unit/widget tests under `apps/chanora_flutter/test/` | Widget/system demos and candidate device smoke |
| Bridge | API compile/generation checks | Flutter-to-Rust command/event smoke |
| Rust core | Cargo tests | Compatible-server lifecycle demo |
| Protocol | DTO/error mapping tests | Protocol compatibility matrix and server demo |
| State sync | Reducer tests | Snapshot/delta/reconnect integration evidence |
| Audio | DSP/codec/PTT tests and benchmarks | Platform audio loopback/device demo |
| Storage | Repository/encryption/keyring-disabled tests | Platform secure-storage audit |
| Diagnostics | Redaction/export tests | User-initiated export inspection |
| Release hooks | CI workflow validation | Release readiness record and artifact evidence |
## 11. Traceability to SAD
| SAD component | SDD modules |
|---|---|
| Flutter app shell | SDD-MOD-001 |
| Flutter service layer | SDD-MOD-006, SDD-MOD-014 |
| Flutter widget layer | SDD-MOD-002 through SDD-MOD-005 |
| Bridge layer | SDD-MOD-007 |
| Rust core | SDD-MOD-008 |
| Protocol adapter | SDD-MOD-009 |
| State sync | SDD-MOD-010 |
| Audio subsystem | SDD-MOD-011 |
| Storage | SDD-MOD-012 |
| Diagnostics | SDD-MOD-013 |
| Server resolver/prefetch | SDD-MOD-014 |
| Release/platform architecture | SDD-MOD-015 |
## 12. Open Detailed-Design Risks
| Risk | Impact | Control |
|---|---|---|
| Detailed item IDs from historical SDD references are not reconstructed | Existing references such as `SDD-109` are not itemized in this baseline | Treat this as a DV baseline SDD and add strict item numbering later if required |
| Some module designs are summarized rather than API-by-API | May be insufficient for final process audit | Use this as DV baseline; deepen high-risk modules before final release gate |
| Android Keystore-backed DEK is not implemented | Limits storage/security design claims | Controlled by waiver and release-readiness records |
| Full event replay tooling and live reducer integration evidence are absent | Limits state verification design beyond reducer unit behavior | Controlled as P1 gap and runtime-integration follow-up |
## 13. DV Conclusion
This SWE.3 baseline is sufficient to remove the missing-SDD traceability gap for DV review and to feed SWE.4/SWE.5 verification plans. It does not close release evidence gaps or replace source-level tests.
+21
View File
@@ -0,0 +1,21 @@
# System Design Specification
**Document status:** DV entry-point record
**Canonical document:** `../sysdes.md`
The canonical Chanora System Design Specification currently lives at `docs/sysdes.md`. This file preserves the README-advertised path `docs/architecture/sysdes.md` for DV navigation.
Reviewers shall use `docs/sysdes.md` as the authoritative SysDes baseline until the repository migration moves the canonical file into this directory.
## DV Review Summary
| Topic | Canonical source |
|---|---|
| System element allocation | `docs/sysdes.md` sections 4 through 8 |
| Verification handoff | `docs/sysdes.md` section 12, SysDes-102 through SysDes-107 |
| SysRS-to-SysDes allocation matrix | `docs/sysdes.md` Appendix A |
| Change-control and traceability rules | `docs/sysdes.md` SysDes-108 through SysDes-110 |
## DV Position
The SysDes baseline is reviewable for DV. SRS, SAD, SDD, and verification documents derive from or consume this allocation layer.
-767
View File
@@ -1,767 +0,0 @@
{
"document_id": "CHANORA_P1_VOICE_AGENT_SPEC",
"version": "1.1.0",
"language": "en",
"audience": "ai_agents",
"scope": "P1_voice_only",
"mode": "modify_existing_scaffold_do_not_rewrite",
"based_on": {
"archive": "chanora-product-scaffold-v0.zip",
"previous_spec": "chanora_p1_voice_design_from_current_scaffold.json"
},
"project_context": {
"ui": "Flutter",
"core": "Rust",
"network": "tsclientlib",
"audio_crate": "crates/chanora_audio",
"bridge_crate": "crates/chanora_bridge",
"flutter_app": "apps/chanora_flutter",
"phase_1_platform": "iOS",
"future_platforms": [
"Android",
"Windows",
"macOS",
"Linux"
]
},
"current_scaffold_facts": {
"ios_audio_backend_exists": true,
"ios_current_backend": "VoiceProcessingIO AudioUnit",
"android_audio_backend_exists": true,
"android_current_backend": "Oboe",
"desktop_audio_backend_exists": true,
"desktop_current_backend": "cpal / SDL depending on platform",
"current_network_frame": {
"sample_rate_hz": 48000,
"channels": 1,
"frame_ms": 20,
"samples": 960,
"codec": "Opus VoIP"
},
"current_effects_api_exists": true,
"current_effects_are_real_dsp": false,
"current_voice_activity_is_real_vad": false,
"current_voice_activity_behavior": "VoiceActivity behaves like Continuous",
"current_ios_has_platform_voice_processing": true,
"current_ios_platform_processing_likely_includes": [
"AEC",
"NS",
"AGC"
],
"current_rust_software_ns_exists": false,
"current_rust_software_aec_exists": false,
"current_rust_software_agc_exists": false
},
"p1_primary_goals": [
"Add real VoiceActivity mode using ML VAD.",
"Keep iOS VoiceProcessingIO as default shipping voice-processing path.",
"Add optional iOS Sonora mode for experimental raw Rust AEC3/NS/AGC2/HPF.",
"Make AEC/NS/AGC route-aware and backend-aware.",
"Expose voice processing configuration and stats to Flutter.",
"Preserve current 20ms Opus/tsclientlib transport behavior.",
"Add internal 10ms processing frames without changing network framing."
],
"non_goals": [
"Do not rewrite P0.",
"Do not move PCM processing to Flutter.",
"Do not replace tsclientlib.",
"Do not require Android/Desktop implementation in Phase 1.",
"Do not require CoreML conversion for P1.",
"Do not require GPU/NPU execution providers for correctness.",
"Do not ship AI noise suppression as P1 default.",
"Do not use energy VAD as production transmit gate."
],
"hard_invariants": {
"INV_001": "Flutter MUST NOT process realtime PCM.",
"INV_002": "Rust AudioEngine MUST own voice state, VAD state, processing config, route policy, stats, and diagnostics.",
"INV_003": "Platform audio layer MUST only provide audio I/O, device/session config, and route detection.",
"INV_004": "tsclientlib MUST remain the voice network/session layer.",
"INV_005": "Keep existing 20ms Opus frame/network contract in P1.",
"INV_006": "Add internal 10ms processing frames by splitting 20ms frames into two 10ms frames.",
"INV_007": "Realtime callbacks MUST NOT call Flutter, block on network, perform file I/O, allocate unbounded memory, or run ML inference.",
"INV_008": "Rust panics MUST NOT cross FFI/bridge boundaries.",
"INV_009": "Never enable platform AEC and Rust AEC simultaneously on the same route.",
"INV_010": "Never enable VoiceProcessingIO and Sonora AEC3 simultaneously.",
"INV_011": "Software AEC-capable backends MUST receive both capture and render-reference streams.",
"INV_012": "Render reference MUST be copied from decoded/mixed remote PCM before playout.",
"INV_013": "VAD MUST NOT stop capture or playback; VAD may only gate transmit and update speaking/debug state."
},
"canonical_audio_model": {
"network_frame": {
"sample_rate_hz": 48000,
"channels": 1,
"frame_ms": 20,
"samples": 960,
"sample_format": "i16_or_f32_internal_conversion",
"status": "keep_existing"
},
"processing_frame": {
"sample_rate_hz": 48000,
"channels": 1,
"frame_ms": 10,
"samples": 480,
"sample_format": "f32",
"status": "new_internal_contract"
},
"vad_model_input": {
"sample_rate_hz": 16000,
"channels": 1,
"sample_format": "f32",
"source": "processed_capture",
"resample_from": "48kHz"
}
},
"target_architecture": {
"layers": [
"Flutter UI",
"Typed Bridge / FFI",
"Rust Application Core",
"tsclientlib Adapter",
"Rust AudioEngine",
"AudioDevice backend",
"AudioProcessor backend",
"VoiceActivityDetector backend",
"VoiceTransmitStateMachine",
"AudioStats",
"WavDebugRecorder"
],
"capture_path": [
"Platform mic input",
"Platform audio callback",
"Minimal realtime-safe push into Rust",
"Canonical conversion to 48kHz mono f32",
"20ms network frame split into 2x10ms processing frames",
"Raw mic debug tap",
"AudioProcessor.process_capture",
"Processed mic debug tap",
"ML VAD worker consumes processed capture",
"VoiceActivityStateMachine updates transmit state",
"If transmitting, collect 10ms frames into 20ms Opus frame",
"tsclientlib / existing send path"
],
"render_path": [
"tsclientlib receive/decode/jitter/mix",
"Remote mixer output",
"Copy render_reference before playout",
"If software AEC backend active, call AudioProcessor.process_render on 10ms slices",
"Platform output callback",
"Speaker / receiver / headset"
]
},
"modules_to_add": {
"crates/chanora_audio/src/frame.rs": "AudioFrame10ms, AudioFrame20ms, split/merge helpers, i16/f32 conversion, dBFS helpers.",
"crates/chanora_audio/src/processor.rs": "AudioProcessor trait, AudioBackend enum, backend selector.",
"crates/chanora_audio/src/processor/platform.rs": "PlatformVoiceProcessing backend marker/policy.",
"crates/chanora_audio/src/processor/sonora.rs": "Optional Sonora backend: HPF, AEC3, NS, AGC2, limiter hook.",
"crates/chanora_audio/src/processor/noop.rs": "No-op backend for tests/headset/debug.",
"crates/chanora_audio/src/vad/mod.rs": "VoiceActivityDetector trait, VadOutput, VadBackend enum.",
"crates/chanora_audio/src/vad/silero_onnx.rs": "Silero ONNX VAD runtime wrapper.",
"crates/chanora_audio/src/vad/webrtc.rs": "WebRTC VAD fallback.",
"crates/chanora_audio/src/vad/energy_debug.rs": "Debug-only energy VAD.",
"crates/chanora_audio/src/voice_activity.rs": "VoiceActivity state machine.",
"crates/chanora_audio/src/stats.rs": "Expanded AudioStats schema.",
"crates/chanora_audio/src/debug_wav.rs": "Async WAV dump writer.",
"crates/chanora_audio/src/route_policy.rs": "AudioRoute enum and route-to-processing policy.",
"crates/chanora_audio/src/ios_raw_unit.rs": "Optional raw iOS RemoteIO path for Sonora mode."
},
"modules_to_modify": {
"crates/chanora_audio/src/lib.rs": [
"Export AudioProcessingConfig, AudioStats, AudioRoute, AudioBackend, VadBackend.",
"Replace no-op effects semantics with backend-aware processing config."
],
"crates/chanora_audio/src/engine.rs": [
"Own AudioProcessingConfig.",
"Own AudioProcessor backend.",
"Own VoiceActivityDetector backend.",
"Own VoiceActivityStateMachine.",
"Expose expanded stats.",
"Preserve existing 20ms Opus send/receive behavior."
],
"crates/chanora_audio/src/transmit_selector.rs": [
"Remove VoiceActivity == Continuous behavior.",
"Accept VoiceActivityStateMachine result for VoiceActivity transmit mode."
],
"crates/chanora_audio/src/ios_voice_unit.rs": [
"Keep as platform VoiceProcessingIO backend.",
"Ensure Rust AEC/NS/AGC disabled when this backend is active.",
"Feed captured audio into Rust VAD/state pipeline.",
"Do not run ML inference in callback.",
"Expose render_reference for debug/future software AEC validation."
],
"crates/chanora_audio/src/android_voice_unit.rs": [
"Keep current Oboe backend.",
"Future: adapt to same AudioProcessor/VAD config and stats."
],
"crates/chanora_bridge/src/api.rs": [
"Add set_audio_processing_config.",
"Add audio_processing_stats.",
"Add set_vad_model_path or bundled model discovery.",
"Add enable_audio_debug_wav_dump."
],
"apps/chanora_flutter/ios/Runner/AppDelegate.swift": [
"Keep AVAudioSession ownership.",
"Add/verify mediaServicesWereReset handling.",
"Send detailed route class to Rust.",
"Do not send PCM to Flutter."
]
},
"voice_processing_backends": {
"platform_voice_processing": {
"description": "OS/platform voice processing.",
"ios_implementation": "VoiceProcessingIO AudioUnit",
"ios_default": true,
"owns": [
"AEC",
"NS",
"AGC"
],
"rust_aec": "disabled",
"rust_ns": "disabled_by_default",
"rust_agc": "disabled_by_default"
},
"sonora": {
"description": "Optional Rust-native WebRTC-style voice processor.",
"ios_status": "experimental_optional",
"future_cross_platform_status": "candidate",
"owns": [
"HPF",
"AEC3",
"NS",
"AGC2",
"limiter_hook"
],
"requires": [
"raw_or_near_raw_capture",
"render_reference",
"48kHz mono 10ms frames"
],
"must_not_run_with": [
"VoiceProcessingIO",
"platform AEC"
],
"shipping_default": false
},
"webrtc_apm": {
"description": "Fallback/candidate WebRTC APM backend.",
"status": "candidate_or_fallback",
"requires": [
"render_reference_for_AEC"
]
},
"noop": {
"description": "No processing.",
"status": "debug_or_headset_policy"
}
},
"ios_processing_modes": {
"platform_default": {
"mode_id": "ios_platform_voice_processing",
"shipping_default": true,
"audio_io": "VoiceProcessingIO AudioUnit",
"av_audio_session": {
"category": "playAndRecord",
"mode": "voiceChat",
"preferred_sample_rate_hz": 48000,
"preferred_io_buffer_duration_ms": [
5,
10
],
"options": [
"defaultToSpeaker_when_speaker_route",
"allowBluetoothHFP",
"allowBluetoothA2DP_only_for_output_policy"
]
},
"processing": {
"aec": "platform",
"ns": "platform",
"agc": "platform",
"hpf": "platform_or_safe_rust_optional",
"limiter": "safe_rust_optional",
"sonora": "disabled"
},
"vad": {
"backend": "silero_vad_onnx",
"input_source": "platform_processed_capture"
}
},
"sonora_experimental": {
"mode_id": "ios_raw_sonora",
"shipping_default": false,
"audio_io": "RemoteIO_or_raw_input_output_path",
"av_audio_session": {
"category": "playAndRecord",
"mode": "default_or_measurement_candidate",
"avoid": [
"voiceChat",
"videoChat",
"VoiceProcessingIO",
"setVoiceProcessingEnabled(true)",
"setPrefersEchoCancelledInput(true)"
]
},
"processing": {
"backend": "sonora",
"order": [
"hpf",
"aec3",
"ns",
"agc2",
"limiter",
"vad_input_tap"
],
"requires_render_reference": true,
"requires_raw_or_near_raw_capture": true
},
"vad": {
"backend": "silero_vad_onnx",
"input_source": "sonora_processed_capture"
},
"fallback": {
"on_backend_failure": "switch_to_ios_platform_voice_processing",
"on_render_reference_missing": "reject_sonora_aec_activation"
}
}
},
"route_policy": {
"ios": {
"speaker": {
"default": "platform_voice_processing",
"optional": "sonora_experimental",
"aec": "platform_by_default",
"ns": "platform_by_default",
"agc": "platform_by_default",
"vad": "silero_vad_onnx",
"sonora_allowed_if": [
"raw_io_available",
"render_reference_available"
]
},
"earpiece": {
"default": "platform_voice_processing",
"optional": "sonora_experimental",
"vad": "silero_vad_onnx"
},
"wired_headset": {
"default": "noop_or_conservative",
"aec": "off",
"ns": "conservative_optional",
"agc": "conservative_optional",
"optional": "sonora_ns_agc_without_aec",
"vad": "silero_vad_onnx"
},
"bluetooth_hfp": {
"default": "route_managed",
"aec": "app_side_off",
"ns": "route_managed_or_conservative",
"agc": "route_managed_or_conservative",
"sonora": "disabled_by_default",
"vad": "silero_vad_onnx"
},
"bluetooth_a2dp": {
"default": "invalid_for_duplex_voice",
"transmit_allowed": false,
"vad": "disabled"
},
"unknown": {
"default": "safe_fallback",
"aec": "off_until_classified",
"vad": "silero_vad_onnx"
}
},
"future_platforms": {
"android": {
"default": "platform_effects_if_available_else_rust_backend",
"audio_io": "Oboe",
"vad": "silero_vad_onnx"
},
"windows": {
"default": "rust_backend_candidate",
"audio_io": "WASAPI_or_CPAL",
"vad": "silero_vad_onnx"
},
"macos": {
"default": "platform_or_rust_policy",
"audio_io": "CoreAudio_or_CPAL",
"vad": "silero_vad_onnx"
},
"linux": {
"default": "rust_backend_candidate",
"audio_io": "CPAL_with_ALSA_PulseAudio_PipeWire",
"vad": "silero_vad_onnx"
}
}
},
"vad_runtime_strategy": {
"owner": "Rust AudioEngine",
"default_backend": "silero_vad_onnx",
"model_artifact": "silero_vad.onnx",
"primary_runtime": "onnxruntime_c_api",
"rust_binding": "ort_or_onnxruntime_wrapper",
"fallback_backend": "webrtc_vad",
"debug_backend": "energy_vad",
"flutter_onnx_plugins": {
"allowed_in_realtime_voice_path": false,
"blocked_packages_for_core_path": [
"onnxruntime_v2",
"fonnx"
],
"reason": "Flutter must not process realtime PCM or own VAD inference."
},
"coreml": {
"p1_primary": false,
"p1_conversion_required": false,
"p2_candidate": true,
"allowed_p1_use": "CoreML Execution Provider benchmark only, not correctness dependency"
},
"execution_provider_policy": {
"correctness_provider": "CPU",
"acceleration_providers_optional": true,
"ios_optional": [
"CoreML_EP_after_benchmark"
],
"android_optional": [
"NNAPI_after_benchmark"
],
"windows_optional": [
"DirectML_after_benchmark",
"CUDA_after_benchmark"
],
"macos_optional": [
"CoreML_EP_after_benchmark"
],
"linux_optional": [
"OpenVINO_after_benchmark",
"CUDA_after_benchmark",
"ROCm_after_benchmark"
]
},
"runtime_rules": {
"create_session_once": true,
"reuse_session": true,
"run_outside_realtime_callback": true,
"do_not_create_session_per_frame": true,
"fallback_if_model_missing": true,
"fallback_if_inference_lag_exceeds_budget": true,
"fallback_if_memory_budget_exceeded": true
},
"model_input": {
"sample_rate_hz": 16000,
"channels": 1,
"source": "processed_capture",
"resample_from_hz": 48000
},
"defaults": {
"open_threshold": 0.55,
"close_threshold": 0.35,
"open_after_ms": 40,
"hangover_ms": 500,
"pre_roll_ms": 160,
"min_tx_ms": 200,
"max_inference_lag_ms": 60
},
"ios_memory_budget": {
"target_extra_rss_mb": 30,
"max_acceptable_extra_rss_mb": 50,
"fail_threshold_extra_rss_mb": 80,
"measurement_required": true,
"measurement_tool": "Xcode Instruments"
}
},
"noise_suppression_strategy": {
"p1_default": "platform_or_webRTC_style_NS_not_AI_NS",
"ios": {
"default": "VoiceProcessingIO_NS",
"rust_ns": "disabled_by_default",
"reason": "Avoid double noise suppression and voice degradation."
},
"ios_sonora_optional": {
"default": "Sonora_NS",
"requires": [
"raw_io",
"sonora_backend_enabled"
],
"status": "experimental"
},
"android": {
"default": "Android NoiseSuppressor if available",
"fallback_candidate": "Sonora_NS_or_WebRTC_APM_NS"
},
"desktop": {
"default_candidate": "Sonora_NS_or_WebRTC_APM_NS"
},
"not_p1_default": [
"RNNoise",
"DeepFilterNet",
"AI_noise_suppression_default"
]
},
"audio_processing_config_schema": {
"schema_version": "p1.audio_processing.v1",
"sample_rate_hz": 48000,
"network_frame_ms": 20,
"processing_frame_ms": 10,
"route": "speaker|earpiece|wired_headset|bluetooth_hfp|bluetooth_a2dp|unknown",
"ios_mode": "platform_voice_processing|sonora_experimental",
"processing_backend": "platform_voice_processing|sonora|webrtc_apm|noop",
"vad_backend": "silero_vad_onnx|webrtc_vad|energy_debug|disabled",
"aec": "platform|sonora|webrtc_apm|off",
"ns": "platform|sonora|webrtc_apm|off|conservative",
"agc": "platform|sonora|webrtc_apm|off|conservative",
"hpf_enabled": true,
"limiter_enabled": true,
"vad": {
"model": "silero_vad.onnx",
"runtime": "onnxruntime_c_api",
"input_source": "processed_capture",
"model_sample_rate_hz": 16000,
"open_threshold": 0.55,
"close_threshold": 0.35,
"open_after_ms": 40,
"hangover_ms": 500,
"pre_roll_ms": 160,
"min_tx_ms": 200,
"max_inference_lag_ms": 60,
"fallback_backend": "webrtc_vad"
},
"debug": {
"stats_enabled": true,
"wav_dump_enabled": false
},
"invalid_combinations": [
"ios_mode=platform_voice_processing AND processing_backend=sonora",
"aec=platform AND aec=sonora",
"VoiceProcessingIO enabled AND Sonora AEC3 enabled",
"vad_backend=energy_debug in production",
"route=bluetooth_a2dp AND transmit_allowed=true"
]
},
"ffi_bridge_api_additions": {
"set_audio_processing_config": {
"crate": "crates/chanora_bridge/src/api.rs",
"input": "BridgeAudioProcessingConfig",
"output": "Result<(), BridgeError>"
},
"audio_processing_stats": {
"crate": "crates/chanora_bridge/src/api.rs",
"input": "none",
"output": "BridgeAudioProcessingStats"
},
"set_vad_model_path": {
"crate": "crates/chanora_bridge/src/api.rs",
"input": "String",
"output": "Result<(), BridgeError>",
"note": "May be replaced by bundled model discovery."
},
"enable_audio_debug_wav_dump": {
"crate": "crates/chanora_bridge/src/api.rs",
"input": "bool",
"output": "Result<(), BridgeError>"
},
"set_ios_voice_processing_mode": {
"crate": "crates/chanora_bridge/src/api.rs",
"input": "platform_voice_processing|sonora_experimental",
"output": "Result<(), BridgeError>"
}
},
"runtime_stats_schema": {
"existing_keep": [
"frames_sent",
"frames_received"
],
"add": {
"input_dbfs": "float",
"render_dbfs": "float",
"processed_dbfs": "float",
"vad_probability": "float",
"vad_active": "bool",
"speaking": "bool",
"transmitting": "bool",
"vad_backend": "enum",
"vad_fallback_active": "bool",
"vad_inference_lag_ms": "float",
"vad_runtime_memory_estimate_mb": "float_optional",
"processing_backend": "enum",
"ios_voice_processing_mode": "enum_optional",
"audio_route": "enum",
"actual_sample_rate_hz": "uint32",
"actual_io_buffer_frames": "uint32",
"capture_queue_ms": "float",
"render_queue_ms": "float",
"estimated_echo_delay_ms": "float_optional",
"input_overruns": "uint64",
"output_underruns": "uint64",
"callback_xruns": "uint64",
"clipped_samples": "uint64",
"sonora_enabled": "bool",
"platform_voice_processing_enabled": "bool"
}
},
"debug_wav": {
"enabled_by_default": false,
"streams": {
"raw_mic": "before_audio_processor",
"render_reference": "remote_mixer_output_before_playout",
"processed_mic": "after_audio_processor"
},
"rules": [
"Never write WAV from realtime callback.",
"Use bounded queues to async writer.",
"Include route/backend/timestamp in filenames or sidecar metadata."
]
},
"failure_modes": {
"silero_model_missing": [
"fallback_to_webrtc_vad",
"emit_structured_error",
"stats.vad_fallback_active=true"
],
"onnxruntime_unavailable": [
"fallback_to_webrtc_vad",
"do_not_crash_audio_engine"
],
"vad_inference_late": [
"do_not_block_audio",
"use_latest_valid_probability_if_not_stale",
"fallback_to_webrtc_vad_if_stale"
],
"onnx_memory_budget_exceeded": [
"fallback_to_webrtc_vad",
"mark_ml_vad_unavailable",
"emit_structured_error"
],
"sonora_requested_with_vpio": [
"reject_config",
"return_invalid_config",
"do_not_start_double_processing"
],
"sonora_aec_without_render_reference": [
"reject_sonora_aec_activation",
"allow_sonora_ns_agc_only_if_configured",
"emit_structured_error"
],
"route_change": [
"update AudioRoute",
"recompute AudioProcessingConfig",
"reset processor if needed",
"reset AEC delay state if software AEC active"
],
"ios_interruption": [
"stop_or_pause_audio_io",
"resume_after_interruption_end",
"update_stats"
],
"ios_media_services_reset": [
"rebuild_AVAudioSession",
"rebuild_AudioUnit",
"reapply_config",
"update_stats"
]
},
"implementation_phases": {
"P1A_foundation": [
"Add frame.rs 10ms/20ms types.",
"Add AudioProcessingConfig.",
"Add expanded AudioStats.",
"Add AudioRoute/AudioBackend/VadBackend enums.",
"Keep existing 20ms Opus path passing."
],
"P1B_voice_activity": [
"Implement VoiceActivityDetector trait.",
"Implement VoiceActivityStateMachine.",
"Make TransmitMode::VoiceActivity real VAD-gated mode.",
"Preserve PTT and Continuous behavior."
],
"P1C_silero_onnx_vad": [
"Choose Rust ONNX Runtime binding.",
"Bundle or locate silero_vad.onnx.",
"Create ONNX session once and reuse it.",
"Implement 48kHz-to-16kHz VAD adapter.",
"Run inference off callback thread.",
"Implement WebRTC VAD fallback."
],
"P1D_ios_platform_default": [
"Keep VoiceProcessingIO default.",
"Route VPIO processed capture to Rust VAD pipeline.",
"Expose route/backend/VAD stats.",
"Add mediaServicesWereReset handling if missing."
],
"P1E_ios_sonora_optional": [
"Add ios_raw_unit.rs RemoteIO/raw path.",
"Add processor/sonora.rs backend.",
"Wire render_reference to Sonora process_render.",
"Wire raw capture to Sonora process_capture.",
"Reject Sonora when VPIO active.",
"Mark Sonora experimental and disabled by default."
],
"P1F_diagnostics": [
"Add raw_mic/render_reference/processed_mic WAV dump.",
"Expose audio_processing_stats to Flutter debug UI.",
"No Flutter PCM."
],
"P1G_acceptance": [
"Speaker echo test VPIO default.",
"Speaker echo test Sonora optional.",
"Double-talk test VPIO vs Sonora.",
"VoiceActivity first-syllable test.",
"Quiet-room false-open test.",
"Keyboard-noise false-open test.",
"Route switching test.",
"Interruption recovery test.",
"Media services reset recovery test.",
"30-minute memory/thermal test with ONNX Runtime."
]
},
"acceptance_criteria": {
"vad": {
"VAD_001": "VoiceActivity no longer behaves as Continuous.",
"VAD_002": "Silero ONNX VAD active by default in P1 builds where runtime/model are available.",
"VAD_003": "WebRTC VAD fallback works if Silero/ONNX Runtime is unavailable, late, or over memory budget.",
"VAD_004": "First syllable preserved using pre-roll.",
"VAD_005": "Hangover prevents choppy transmit close.",
"VAD_006": "ML inference never runs inside realtime callback."
},
"ios_platform": {
"IOSP_001": "VoiceProcessingIO remains default iOS shipping backend.",
"IOSP_002": "Platform AEC/NS/AGC active on speaker/earpiece policy.",
"IOSP_003": "Rust/Sonora AEC/NS/AGC disabled by default when VPIO active.",
"IOSP_004": "Route/interruption/media-services-reset handled without app restart."
},
"ios_sonora": {
"IOSS_001": "Sonora backend exists as optional experimental mode.",
"IOSS_002": "Sonora mode uses raw or near-raw iOS I/O, not VoiceProcessingIO.",
"IOSS_003": "Sonora AEC3 receives render_reference before playout.",
"IOSS_004": "Config rejects VoiceProcessingIO + Sonora AEC3 double-processing.",
"IOSS_005": "Sonora can be compared against VPIO using same stats and WAV dumps."
},
"noise_suppression": {
"NS_001": "iOS default uses platform NS through VoiceProcessingIO.",
"NS_002": "Rust NS is not double-enabled on iOS platform path.",
"NS_003": "Sonora NS is available only in Sonora experimental mode.",
"NS_004": "AI noise suppression is not P1 default."
},
"diagnostics": {
"DIAG_001": "Stats expose route, backend, iOS mode, VAD probability, VAD backend, fallback state, sample rate, buffer size, xruns.",
"DIAG_002": "Debug WAV dump supports raw_mic, render_reference, processed_mic.",
"DIAG_003": "WAV writer never runs on realtime callback."
},
"regression": {
"REG_001": "PTT mode remains functionally unchanged except diagnostics.",
"REG_002": "Continuous mode remains functionally unchanged except diagnostics.",
"REG_003": "Existing voice_join/voice_leave lifecycle remains public lifecycle.",
"REG_004": "Existing tsclientlib receive/decode/mix path remains active."
}
},
"agent_execution_rules": {
"AGENT_001": "Modify existing scaffold; do not introduce a parallel audio engine.",
"AGENT_002": "Start in crates/chanora_audio; bridge and Flutter changes expose config/stats only.",
"AGENT_003": "Preserve 20ms Opus network framing.",
"AGENT_004": "Introduce 10ms processing frames internally only.",
"AGENT_005": "Do not enable Sonora on iOS default path.",
"AGENT_006": "Do not enable Rust AEC when platform AEC is active.",
"AGENT_007": "Do not run ML inference in realtime callback.",
"AGENT_008": "Do not use Flutter ONNX plugins in the core realtime path.",
"AGENT_009": "Every implementation PR should reference requirement or acceptance IDs.",
"AGENT_010": "If uncertain, preserve P0 lifecycle, tsclientlib transport, and cross-platform AudioEngine boundary."
}
}
@@ -0,0 +1,32 @@
# Chanora Baseline Approval Record
**Document status:** DV meeting baseline candidate
**Date:** 2026-05-29
## 1. Approval Scope
This record tracks approval for documentation baselines. It does not approve public/store release.
| Baseline | Status | DV disposition |
|---|---|---|
| SysRS | Baseline available | Reviewable |
| SysDes | Baseline available | Reviewable |
| SRS | Baseline available | Reviewable |
| SAD / SWE.2 | Baseline candidate | Reviewable with depth limitation |
| SDD / SWE.3 | Baseline candidate | Reviewable with depth limitation |
| Verification plans | Baseline candidate | Reviewable with evidence limitations |
| Release readiness | Baseline candidate | No-Go for public/store release |
## 2. Required Approvers
| Area | Approver role |
|---|---|
| Requirements/design | System Engineering / Software Engineering |
| Verification | Software QA / System QA |
| Release | Product / Release Operations |
| Security/privacy | Security / Privacy owner |
| Legal/trademark/OSS | Legal / Product owner |
## 3. DV Recommendation
Approve the document baseline for DV discussion. Do not approve public/store release until release gates close.
@@ -0,0 +1,38 @@
# Chanora Baseline Candidate Validation Report
**Document status:** DV meeting baseline candidate
**Date:** 2026-05-29
## 1. Validation Summary
Validation was performed against the README-advertised document tree and the DV lifecycle chain.
| Check | Result |
|---|---|
| README document tree represented | Baseline candidate documents exist or path records point to canonical baselines |
| SysRS/SysDes/SRS chain | Available |
| SAD/SDD chain | Baseline candidates added |
| Verification plans | Available |
| Release readiness | Available; public/store release No-Go |
| Waivers | Available |
## 2. Validation Method
| Validation step | Evidence |
|---|---|
| README path coverage | Shell `test -f` command over every advertised document path |
| Sentinel-language scan | Review scan over the DV document tree returned no incomplete-marker matches |
| Lifecycle trace | Traceability matrix covers SysRS -> SysDes -> SRS -> SAD -> SDD -> Verification |
| Release posture | Release-readiness record and legal/security docs consistently keep public/store release at No-Go |
## 3. Known Validation Limits
| Limit | Control |
|---|---|
| Candidate run IDs not embedded | Release-readiness record requires run IDs before release approval |
| Legal DEC-012 open | Public/store release remains blocked |
| Some SAD/SDD details summarized | Deepen before final process audit if required |
## 4. Conclusion
The baseline candidate is suitable for DV meeting review with recorded limitations.
@@ -0,0 +1,18 @@
# Chanora Decision Impact Assessment
**Document status:** DV meeting baseline candidate
**Date:** 2026-05-29
## 1. Impact Matrix
| Decision or gap | Requirements impact | Design impact | Verification impact | Release impact |
|---|---|---|---|---|
| DEC-012 open | Public wording and OSS gates remain constrained | Legal surfaces must avoid over-claiming | Audit evidence required | Public/store release No-Go |
| Android Keystore-backed DEK deferred | Secure-storage claim limited | Storage design carries fallback limitation | Platform audit required | Waiver required |
| Full reducer tests incomplete | State verification incomplete | State design remains valid but evidence partial | SWE.4/SWE.5 partial | Blocks full state-sync claim |
| Desktop/iOS artifacts not release-ready | Platform packaging requirements partial | Release design remains source-build/unsigned | SYS.4 evidence partial | Public binary release No-Go |
| VAD deferred | VoiceActivity not active | UI must show disabled/coming-soon | No VAD pass claim | No VAD marketing claim |
## 2. Conclusion
Current impacts are controlled for DV by waivers and release No-Go status.
+35
View File
@@ -0,0 +1,35 @@
# Chanora Document Index
**Document status:** DV meeting baseline candidate
**Date:** 2026-05-29
## 1. Purpose
This index lists the documents required for DV review and identifies their current status.
## 2. Baseline Documents
| Area | Document | Status |
|---|---|---|
| Requirements | `docs/sysrs.md` | Canonical SysRS baseline |
| Requirements | `docs/srs.md` | Canonical SRS baseline |
| Requirements path | `docs/requirements/sysrs.md`, `docs/requirements/srs.md` | Path records pointing to canonical root files |
| System design | `docs/sysdes.md` | Canonical SysDes baseline |
| System design path | `docs/architecture/sysdes.md` | Path record pointing to canonical root file |
| Software architecture | `docs/architecture/sad.md` | SWE.2 baseline candidate |
| Software detailed design | `docs/architecture/sdd.md` | SWE.3 baseline candidate |
| Verification | `docs/verification/verification-master-plan.md` | Verification baseline candidate |
| Verification | `docs/verification/swe4-unit-verification-plan.md` | SWE.4 baseline candidate |
| Verification | `docs/verification/swe5-software-integration-verification-plan.md` | SWE.5 baseline candidate |
| Verification | `docs/verification/swe6-software-verification-plan.md` | SWE.6 baseline candidate |
| Verification | `docs/verification/sys4-system-integration-verification-plan.md` | SYS.4 baseline candidate |
| Release | `docs/release/release-readiness-go-nogo-record.md` | No-Go for public/store release |
| Release | `docs/release/platform-release-policy.md` | Baseline candidate |
| Governance | `docs/governance/traceability-matrix.md` | DV baseline candidate |
| Security/privacy/legal | `docs/security/security-privacy-legal-guideline.md` | Baseline candidate |
| Privacy | `docs/privacy/privacy-policy.md` | Engineering baseline candidate |
| Legal | `docs/legal/trademark-and-attribution-review.md` | DEC-012 open |
## 3. DV Use
Use this index as the first navigation document in the DV meeting. Release approval remains controlled by the release-readiness record.
@@ -0,0 +1,33 @@
# Chanora Document Naming Convention
**Document status:** DV meeting baseline candidate
**Date:** 2026-05-29
## 1. Rule
Repository documentation uses lowercase kebab-case file names under topic directories.
Examples:
| Document type | Path pattern |
|---|---|
| Requirements | `docs/requirements/<name>.md` or canonical root file during migration |
| Architecture | `docs/architecture/<artifact>.md` |
| Verification | `docs/verification/<lifecycle>-verification-plan.md` |
| Release | `docs/release/<record-name>.md` |
| Governance | `docs/governance/<record-name>.md` |
## 2. Identifier Rules
| Identifier | Meaning |
|---|---|
| `SysRS-XXX` | System requirement |
| `SysDes-XXX` | System design item |
| `SRS-XXX` | Software requirement |
| `SAD` | Software Architecture Description / SWE.2 |
| `SDD` | Software Detailed Design / SWE.3 |
| `DV-WVR-XXX` | DV waiver |
## 3. Migration Rule
When canonical files move, path records must be replaced by the canonical content or by redirects that clearly identify the authoritative source.
+37
View File
@@ -0,0 +1,37 @@
# Chanora Document Review Report
**Document status:** DV meeting baseline candidate
**Date:** 2026-05-29
## 1. Review Method
Documents were reviewed for DV navigation, lifecycle coverage, explicit release gating, and absence of unsupported completion claims.
| Review attribute | Value |
|---|---|
| Review date | 2026-05-29 |
| Review scope | `docs/requirements/`, `docs/architecture/`, `docs/verification/`, `docs/release/`, `docs/security/`, `docs/privacy/`, `docs/legal/`, `docs/ui-ux/`, `docs/i18n/`, `docs/governance/`, `docs/references/` |
| Review criteria | README path coverage, lifecycle traceability, release-status consistency, waiver visibility, security/privacy/legal gate visibility, sentinel-language scan |
| Evidence commands | README path `test -f` check; sentinel-language scan over the DV document tree |
## 2. Findings
| Finding | Status | Action |
|---|---|---|
| Previous gap: verification document set was missing | Addressed | Added verification master/SWE.4/SWE.5/SWE.6/SYS.4 plans |
| Previous gap: SAD/SDD were missing from current tree | Addressed | Added SWE.2/SAD and SWE.3/SDD baselines |
| Release blockers needed explicit record | Addressed | Added release-readiness record and waiver register |
| README tree had missing documents | Addressed for DV | Added baseline candidate records and path records |
## 3. Residual Issues
| Residual issue | DV handling |
|---|---|
| Candidate commit SHA, tag, artifact hashes, and run IDs are not recorded | Blocks release approval; acceptable for document baseline review |
| SAD/SDD are baseline candidates without historical item-number reconstruction | Accept for DV baseline; deepen before strict item-level audit |
| DEC-012 legal/trademark/OSS review remains open | Public/store release remains No-Go |
| Some implementation status items require revalidation after newer commits | Controlled by waiver register and release-readiness record |
## 4. Review Conclusion
The document set is reviewable for DV. Public/store release remains blocked by the release-readiness record.

Some files were not shown because too many files have changed in this diff Show More