chore: restore product scaffold to rollback baseline
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 => '清除';
|
||||
|
||||
|
||||
+467
-371
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,13 +56,15 @@ 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?'),
|
||||
builder: (ctx, setDialogState) {
|
||||
final l10n = AppL10n.of(ctx);
|
||||
return AlertDialog(
|
||||
title: Text(l10n.linkTrustTitle),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('You are about to open a link to:\n\n$domain'),
|
||||
Text(l10n.linkTrustBody(domain)),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
@@ -74,7 +78,7 @@ Future<bool?> showLinkTrustDialog(BuildContext context, String domain) async {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Flexible(child: Text('Trust all links from this domain')),
|
||||
Flexible(child: Text(l10n.linkTrustRememberDomain)),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -82,14 +86,15 @@ Future<bool?> showLinkTrustDialog(BuildContext context, String domain) async {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(null),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(l10n.cancelAction),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(remember),
|
||||
child: const Text('Open'),
|
||||
child: Text(l10n.openAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>{};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,8 +313,11 @@ 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(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: maxHeight),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -356,6 +359,7 @@ class _AndroidAudioOutputPickerSheet extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,33 +615,8 @@ class _ChatPageState extends State<ChatPage> {
|
||||
final messages = _messages;
|
||||
final currentChannelId = _currentChannelId;
|
||||
final channelName = snapshotChannelName(snapshot, currentChannelId);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Chat — ${snapshot.serverName}'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Close chat',
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: _selectedPrivateClientId == null
|
||||
? null
|
||||
: _closeSelectedPrivateChat,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Row(
|
||||
children: [
|
||||
_ChatSidebar(
|
||||
selectedTarget: _selectedTarget,
|
||||
privateChats: _privateChats,
|
||||
onSelect: _selectTarget,
|
||||
onNewPrivateChat: () => _pickClient((id, name) {
|
||||
_closedPrivateChats.remove(id);
|
||||
_selectTarget(rust.BridgeMessageTarget.client(id), name: name);
|
||||
}),
|
||||
),
|
||||
const VerticalDivider(width: 1),
|
||||
Expanded(
|
||||
child: _ChatDetailView(
|
||||
final l10n = AppL10n.of(context);
|
||||
final detail = _ChatDetailView(
|
||||
target: _selectedTarget,
|
||||
clientName: _selectedClientName,
|
||||
snapshot: snapshot,
|
||||
@@ -650,10 +624,50 @@ class _ChatPageState extends State<ChatPage> {
|
||||
currentChannelId: currentChannelId,
|
||||
channelName: channelName,
|
||||
onTs3ServerLink: widget.onTs3ServerLink,
|
||||
),
|
||||
);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('${l10n.chatAction} - ${snapshot.serverName}'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: l10n.chatCloseAction,
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: _selectedPrivateClientId == null
|
||||
? null
|
||||
: _closeSelectedPrivateChat,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final sidebar = _ChatSidebar(
|
||||
compact: constraints.maxWidth < _chatMobileBreakpoint,
|
||||
selectedTarget: _selectedTarget,
|
||||
privateChats: _privateChats,
|
||||
onSelect: _selectTarget,
|
||||
onNewPrivateChat: () => _pickClient((id, name) {
|
||||
_closedPrivateChats.remove(id);
|
||||
_selectTarget(rust.BridgeMessageTarget.client(id), name: name);
|
||||
}),
|
||||
);
|
||||
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,
|
||||
);
|
||||
return SizedBox(
|
||||
width: _chatSidebarTileExtent,
|
||||
child: Material(
|
||||
color: theme.colorScheme.surfaceContainerLow,
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
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()),
|
||||
),
|
||||
Divider(height: 1, indent: 12, endIndent: 12, color: dividerColor),
|
||||
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(
|
||||
];
|
||||
final privateItems = [
|
||||
for (final chat in privateChats)
|
||||
_ChatSidebarItem(
|
||||
compact: compact,
|
||||
icon: Icons.person_outline,
|
||||
label: chat.name.isNotEmpty ? chat.name : 'Direct',
|
||||
selected: selected,
|
||||
selected: switch (selectedTarget) {
|
||||
rust.BridgeMessageTarget_Client(:final field0) => field0 == chat.id,
|
||||
_ => false,
|
||||
},
|
||||
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),
|
||||
];
|
||||
final addButton = Padding(
|
||||
padding: EdgeInsets.all(compact ? 4 : 8),
|
||||
child: IconButton.filledTonal(
|
||||
tooltip: 'New private chat',
|
||||
tooltip: l10n.chatNewPrivateAction,
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: onNewPrivateChat,
|
||||
),
|
||||
);
|
||||
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: [
|
||||
...fixedItems,
|
||||
compactDivider,
|
||||
Expanded(
|
||||
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,59 +871,54 @@ 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(
|
||||
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: _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),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Ink(
|
||||
width: tileExtent,
|
||||
height: compact ? 72 : tileExtent,
|
||||
decoration: BoxDecoration(
|
||||
color: indicatorColor,
|
||||
borderRadius: _chatSidebarIndicatorRadius,
|
||||
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: _chatSidebarIconExtent,
|
||||
height: _chatSidebarIconExtent,
|
||||
child: Icon(
|
||||
icon,
|
||||
size: _chatSidebarIconSize,
|
||||
color: contentColor,
|
||||
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(
|
||||
Text(
|
||||
label,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: labelStyle,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: fg,
|
||||
height: 1.1,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -892,7 +926,6 @@ class _ChatSidebarItem extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,14 +1107,11 @@ 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;
|
||||
unawaited(rust.sendChatMessage(message: text, target: widget.target));
|
||||
final ownId = widget.snapshot.ownClientId;
|
||||
setState(() {
|
||||
widget.messages.add(
|
||||
@@ -1101,24 +1131,6 @@ class _ChatDetailViewState extends State<_ChatDetailView> {
|
||||
}
|
||||
});
|
||||
_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,
|
||||
),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _sending = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
const stackedActionsMaxWidth = 400.0;
|
||||
final connectButton = FilledButton.icon(
|
||||
icon: const Icon(Icons.login),
|
||||
label: Text(l10n.connectAction),
|
||||
onPressed: widget.onConnect,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
);
|
||||
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,8 +203,9 @@ class _PttBindingCaptureDialogState extends State<PttBindingCaptureDialog> {
|
||||
final theme = Theme.of(context);
|
||||
return AlertDialog(
|
||||
title: Text(l10n.pttConfigureTitle),
|
||||
content: SizedBox(
|
||||
width: 360,
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 360),
|
||||
child: SingleChildScrollView(
|
||||
child: Focus(
|
||||
focusNode: _focusNode,
|
||||
onKeyEvent: _onKeyEvent,
|
||||
@@ -246,11 +213,10 @@ class _PttBindingCaptureDialogState extends State<PttBindingCaptureDialog> {
|
||||
child: Listener(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPointerDown: (e) {
|
||||
final platformKey = pttMouseSideButtonPlatformKeyForButtons(
|
||||
e.buttons,
|
||||
);
|
||||
if (platformKey != null) {
|
||||
_captureMouseSideButton(platformKey);
|
||||
const int back = 0x08;
|
||||
const int forward = 0x10;
|
||||
if (e.buttons == back || e.buttons == forward) {
|
||||
_captureMouseSideButton(e.buttons);
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
@@ -263,6 +229,7 @@ class _PttBindingCaptureDialogState extends State<PttBindingCaptureDialog> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 12,
|
||||
horizontal: 16,
|
||||
@@ -292,6 +259,7 @@ class _PttBindingCaptureDialogState extends State<PttBindingCaptureDialog> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
|
||||
@@ -54,10 +54,14 @@ 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(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: maxHeight),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -93,6 +97,7 @@ class PttCapabilityBadge extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,27 +315,12 @@ 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,
|
||||
@@ -343,8 +332,73 @@ class _SnapshotViewState extends State<SnapshotView> {
|
||||
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(
|
||||
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,
|
||||
),
|
||||
secondChild: const SizedBox(width: double.infinity),
|
||||
crossFadeState: expanded
|
||||
? CrossFadeState.showFirst
|
||||
: CrossFadeState.showSecond,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,32 +178,12 @@ 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));
|
||||
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) {
|
||||
@@ -224,9 +194,6 @@ pub fn list_audio_devices() -> AudioDeviceList {
|
||||
}
|
||||
}
|
||||
}
|
||||
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) {
|
||||
@@ -237,7 +204,6 @@ pub fn list_audio_devices() -> AudioDeviceList {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
list
|
||||
}
|
||||
|
||||
@@ -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,8 +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",
|
||||
@@ -846,45 +782,6 @@ impl AudioEngine {
|
||||
"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",
|
||||
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"
|
||||
),
|
||||
}
|
||||
#[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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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!(
|
||||
// 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",
|
||||
"linux ptt: portal session ready; waiting for explicit BindShortcuts request"
|
||||
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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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;
|
||||
transform_result_sse::<_, ()>((move || {
|
||||
let output_ok = Result::<_, ()>::Ok(crate::api::export_diagnostics())?;
|
||||
Ok(output_ok)
|
||||
})()
|
||||
.await,
|
||||
)
|
||||
}
|
||||
})())
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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 {
|
||||
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 = %resolved,
|
||||
resolved = %addr,
|
||||
"server address resolved"
|
||||
);
|
||||
addr
|
||||
}
|
||||
};
|
||||
|
||||
// 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 {
|
||||
let inbound = InboundVoice {
|
||||
from_client: from,
|
||||
packet: buf,
|
||||
})
|
||||
.is_err()
|
||||
};
|
||||
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));
|
||||
}) => {
|
||||
let r = send_text_message(&mut con, &message, target);
|
||||
let _ = reply.send(r);
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = reply.send(Err(e));
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,6 +2,8 @@
|
||||
name = "chanora_resolver"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
publish = false
|
||||
build = "build.rs"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,7 +277,41 @@ impl ChanoraResolver {
|
||||
}
|
||||
};
|
||||
|
||||
match self.resolve_ts3(&host, "").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,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
@@ -291,37 +327,30 @@ impl ChanoraResolver {
|
||||
.await;
|
||||
}
|
||||
Err(err) => errors.push(format!("ts3 srv: {err}")),
|
||||
},
|
||||
}
|
||||
|
||||
match self
|
||||
.resolve_tsdns_srv_candidates(input, &host, fallback_port)
|
||||
.await
|
||||
{
|
||||
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 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}")),
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = []
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -11,3 +11,4 @@ publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
thiserror.workspace = true
|
||||
chanora_protocol = { path = "../chanora_protocol" }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
@@ -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 ~8–16 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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Chanora Git Commit Message Convention
|
||||
|
||||
**Document status:** DV meeting baseline candidate
|
||||
**Date:** 2026-05-29
|
||||
|
||||
Chanora uses Conventional Commits style:
|
||||
|
||||
```text
|
||||
<type>(<scope>): <summary>
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
feat(voice): add push-to-talk state handling
|
||||
fix(protocol): recover channel tree after reconnect snapshot
|
||||
sec(diagnostics): redact server password from export bundle
|
||||
release(android): prepare internal alpha build metadata
|
||||
```
|
||||
|
||||
Commits that affect requirements, design, verification, release, security, privacy, or legal gates should update the relevant document in the same change set.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user