Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c93c1c24f | ||
|
|
5564af02f9 | ||
|
|
ae29d1fea2 | ||
|
|
7ff7766ede | ||
|
|
b0fdd4e058 | ||
|
|
306bc295b1 | ||
|
|
8e0b4c16ec | ||
|
|
f52b5ea64e | ||
|
|
4d07e03395 | ||
|
|
20bfcffe6d | ||
|
|
185d47aa8d | ||
|
|
2eae95ede0 | ||
|
|
fdd532de53 | ||
|
|
46809153d8 | ||
|
|
6ccad0d37a | ||
|
|
ddb3d2aabc | ||
|
|
bbbe2d8f17 | ||
|
|
63b45e03ab | ||
|
|
2937e5191a | ||
|
|
47c58047ce | ||
|
|
85b12a26c9 | ||
|
|
e4767be210 | ||
|
|
10ee765ffd | ||
|
|
3034c42f71 | ||
|
|
465c7ba2b0 | ||
|
|
3ec09de87e | ||
|
|
4b8fb92dc5 | ||
|
|
1adf8a97bb | ||
|
|
10707152a3 | ||
|
|
f2e72624f7 | ||
|
|
319d0c5e29 | ||
|
|
d56c2c90b2 | ||
|
|
5086e86bd2 | ||
|
|
54780fa73b | ||
|
|
b1aa751a84 | ||
|
|
9efab491c7 | ||
|
|
f3f399a748 | ||
|
|
1afdccbefe | ||
|
|
92c9d585b8 | ||
|
|
8982df364e | ||
|
|
33e3998e7c | ||
|
|
44bad7b70b | ||
|
|
20643a24de |
@@ -0,0 +1,34 @@
|
||||
name: cargo-deny
|
||||
|
||||
# Enforce the supply-chain policy in deny.toml (advisories / bans / licenses /
|
||||
# sources) on every push to main and every PR. Runs on a *locked* tree so the
|
||||
# pinned, vetted versions in Cargo.lock are exactly what get audited — see the
|
||||
# deny.toml header and VERSIONING.md. A new poisoned release of a dependency
|
||||
# cannot reach CI until Cargo.lock is deliberately updated.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
cargo-deny:
|
||||
runs-on: ubuntu-latest
|
||||
# rust:1 provides the cargo toolchain that cargo-deny shells out to for
|
||||
# `cargo metadata`. Adjust the runner label if your act_runner uses a
|
||||
# different one.
|
||||
container: rust:1
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install cargo-deny (pinned prebuilt)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version=0.19.9
|
||||
curl -sSfL \
|
||||
"https://github.com/EmbarkStudios/cargo-deny/releases/download/${version}/cargo-deny-${version}-x86_64-unknown-linux-musl.tar.gz" \
|
||||
| tar -xz -C /usr/local/bin --strip-components=1 --wildcards '*/cargo-deny'
|
||||
cargo-deny --version
|
||||
|
||||
- name: cargo deny check
|
||||
run: cargo deny --locked check
|
||||
@@ -0,0 +1,85 @@
|
||||
name: windows-build
|
||||
|
||||
# Milestone M1 of the Windows port (see docs/handoff windows-migration-plan):
|
||||
# prove the tree compiles for `x86_64-pc-windows-msvc` and the unit tests pass.
|
||||
# The audio backend is the Phase 0 `CpalBackend` stub for now — this job guards
|
||||
# the *compile* boundary (cfg gating, platform deps, the PlatformAudioBackend
|
||||
# alias) so a Unix-only assumption can't sneak back in and break Windows.
|
||||
#
|
||||
# RUNNER REQUIREMENT: this needs a Windows act_runner registered with the
|
||||
# `windows-latest` label (the Linux `cargo-deny` job's container approach does
|
||||
# NOT apply here — Windows jobs run on the host, not a Linux container). If your
|
||||
# runner advertises a different label, change `runs-on` below. Until a Windows
|
||||
# runner exists this workflow is simply skipped/queued, not a failure of the
|
||||
# Linux CI.
|
||||
#
|
||||
# BUILD-HOST REQUIREMENTS (validated by the opus spike, see
|
||||
# peerspeak-windows-opus-spike.md):
|
||||
# - MSVC C toolchain (Visual Studio Build Tools) — to compile vendored libopus.
|
||||
# - CMake on PATH — `audiopus_sys` builds libopus from source via cmake.
|
||||
# - CMAKE_POLICY_VERSION_MINIMUM=3.5 (set below) — the vendored libopus declares
|
||||
# an ancient `cmake_minimum_required` that CMake >= 4.0 refuses without it.
|
||||
# GitHub-hosted `windows-latest` images ship MSVC + CMake; a self-hosted runner
|
||||
# must provide both.
|
||||
|
||||
on:
|
||||
push:
|
||||
# `main` plus the in-progress port branches, so the Windows path is exercised
|
||||
# before merge rather than only after.
|
||||
branches: [main, "windows-port-**"]
|
||||
pull_request:
|
||||
# Allow manual runs from the Gitea Actions UI.
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
# The vendored libopus (audiopus_sys -> cmake) uses cmake_minimum_required < 3.5,
|
||||
# which CMake 4.x rejects unless this is set. See the opus spike report.
|
||||
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
|
||||
|
||||
jobs:
|
||||
windows-build:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust (MSVC, pinned to repo toolchain if present)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-pc-windows-msvc
|
||||
components: clippy
|
||||
|
||||
- name: Show toolchain + build prerequisites
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rustc --version
|
||||
cargo --version
|
||||
# libopus is built from source via cmake; fail early with a clear
|
||||
# message if the runner lacks it rather than deep in the opus build.
|
||||
if ! command -v cmake >/dev/null 2>&1; then
|
||||
echo "::error::cmake not found on PATH. The opus crate builds libopus from source via cmake; install CMake on this runner."
|
||||
exit 1
|
||||
fi
|
||||
cmake --version
|
||||
|
||||
# Build on a *locked* tree so the pinned, vetted Cargo.lock versions are what
|
||||
# get compiled — same supply-chain stance as the cargo-deny job.
|
||||
- name: Build (all targets, msvc)
|
||||
run: cargo build --all-targets --locked --target x86_64-pc-windows-msvc
|
||||
|
||||
# Unit (lib) tests only: the `transport_loopback` integration tests stand up
|
||||
# real iroh/QUIC endpoints and need working loopback networking, which isn't
|
||||
# guaranteed on a CI runner. Add `--tests` here once a networked Windows
|
||||
# runner is confirmed.
|
||||
- name: Unit tests (lib, msvc)
|
||||
run: cargo test --lib --locked --target x86_64-pc-windows-msvc
|
||||
|
||||
# Informational for now (not `-D warnings`): the Windows tree may surface
|
||||
# platform-specific lints we haven't triaged. Tighten to deny-warnings once
|
||||
# it's clean.
|
||||
- name: Clippy (msvc)
|
||||
run: cargo clippy --all-targets --locked --target x86_64-pc-windows-msvc
|
||||
Generated
+269
-25
@@ -105,6 +105,28 @@ version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "alsa"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
|
||||
dependencies = [
|
||||
"alsa-sys",
|
||||
"bitflags 2.11.1",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alsa-sys"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android-activity"
|
||||
version = "0.6.1"
|
||||
@@ -114,12 +136,12 @@ dependencies = [
|
||||
"android-properties",
|
||||
"bitflags 2.11.1",
|
||||
"cc",
|
||||
"jni",
|
||||
"jni 0.22.4",
|
||||
"libc",
|
||||
"log",
|
||||
"ndk",
|
||||
"ndk 0.9.0",
|
||||
"ndk-context",
|
||||
"ndk-sys",
|
||||
"ndk-sys 0.6.0+11769913",
|
||||
"num_enum",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
@@ -712,6 +734,12 @@ dependencies = [
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cesu8"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
||||
|
||||
[[package]]
|
||||
name = "cexpr"
|
||||
version = "0.6.0"
|
||||
@@ -1002,6 +1030,26 @@ dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coreaudio-rs"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation-sys",
|
||||
"coreaudio-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coreaudio-sys"
|
||||
version = "0.2.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cosmic-text"
|
||||
version = "0.15.0"
|
||||
@@ -1026,6 +1074,29 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpal"
|
||||
version = "0.15.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
|
||||
dependencies = [
|
||||
"alsa",
|
||||
"core-foundation-sys",
|
||||
"coreaudio-rs",
|
||||
"dasp_sample",
|
||||
"jni 0.21.1",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"mach2",
|
||||
"ndk 0.8.0",
|
||||
"ndk-context",
|
||||
"oboe",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows 0.54.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -1228,6 +1299,12 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_sample"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.0"
|
||||
@@ -2227,7 +2304,7 @@ dependencies = [
|
||||
"http",
|
||||
"idna",
|
||||
"ipnet",
|
||||
"jni",
|
||||
"jni 0.22.4",
|
||||
"rand 0.10.1",
|
||||
"rustls",
|
||||
"thiserror 2.0.18",
|
||||
@@ -2247,7 +2324,7 @@ dependencies = [
|
||||
"data-encoding",
|
||||
"idna",
|
||||
"ipnet",
|
||||
"jni",
|
||||
"jni 0.22.4",
|
||||
"once_cell",
|
||||
"prefix-trie",
|
||||
"rand 0.10.1",
|
||||
@@ -2270,7 +2347,7 @@ dependencies = [
|
||||
"hickory-proto",
|
||||
"ipconfig",
|
||||
"ipnet",
|
||||
"jni",
|
||||
"jni 0.22.4",
|
||||
"moka",
|
||||
"ndk-context",
|
||||
"once_cell",
|
||||
@@ -3102,6 +3179,22 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
|
||||
dependencies = [
|
||||
"cesu8",
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"thiserror 1.0.69",
|
||||
"walkdir",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.22.4"
|
||||
@@ -3463,6 +3556,15 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f"
|
||||
|
||||
[[package]]
|
||||
name = "mach2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "malloc_buf"
|
||||
version = "0.0.6"
|
||||
@@ -3596,7 +3698,7 @@ dependencies = [
|
||||
"dispatch",
|
||||
"futures-channel",
|
||||
"futures-lite",
|
||||
"jni",
|
||||
"jni 0.22.4",
|
||||
"ndk-context",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
@@ -3695,6 +3797,20 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"ndk-sys 0.5.0+25.2.9519653",
|
||||
"num_enum",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@@ -3704,7 +3820,7 @@ dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"ndk-sys",
|
||||
"ndk-sys 0.6.0+11769913",
|
||||
"num_enum",
|
||||
"raw-window-handle",
|
||||
"thiserror 1.0.69",
|
||||
@@ -3716,6 +3832,15 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
|
||||
|
||||
[[package]]
|
||||
name = "ndk-sys"
|
||||
version = "0.5.0+25.2.9519653"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691"
|
||||
dependencies = [
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk-sys"
|
||||
version = "0.6.0+11769913"
|
||||
@@ -4466,6 +4591,29 @@ dependencies = [
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oboe"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
|
||||
dependencies = [
|
||||
"jni 0.21.1",
|
||||
"ndk 0.8.0",
|
||||
"ndk-context",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
"oboe-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oboe-sys"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -4594,12 +4742,13 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "peerspeak"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"base64",
|
||||
"bytes",
|
||||
"cpal",
|
||||
"dirs",
|
||||
"iced",
|
||||
"image",
|
||||
@@ -5436,7 +5585,7 @@ checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"jni",
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
@@ -5877,7 +6026,7 @@ dependencies = [
|
||||
"fastrand",
|
||||
"js-sys",
|
||||
"memmap2",
|
||||
"ndk",
|
||||
"ndk 0.9.0",
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
@@ -7162,7 +7311,7 @@ dependencies = [
|
||||
"log",
|
||||
"metal",
|
||||
"naga",
|
||||
"ndk-sys",
|
||||
"ndk-sys 0.6.0+11769913",
|
||||
"objc",
|
||||
"once_cell",
|
||||
"ordered-float",
|
||||
@@ -7247,6 +7396,16 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.54.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
|
||||
dependencies = [
|
||||
"windows-core 0.54.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.58.0"
|
||||
@@ -7254,7 +7413,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
|
||||
dependencies = [
|
||||
"windows-core 0.58.0",
|
||||
"windows-targets",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7278,6 +7437,16 @@ dependencies = [
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.54.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
|
||||
dependencies = [
|
||||
"windows-result 0.1.2",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.58.0"
|
||||
@@ -7288,7 +7457,7 @@ dependencies = [
|
||||
"windows-interface 0.58.0",
|
||||
"windows-result 0.2.0",
|
||||
"windows-strings 0.1.0",
|
||||
"windows-targets",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7386,13 +7555,22 @@ dependencies = [
|
||||
"windows-strings 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7411,7 +7589,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
|
||||
dependencies = [
|
||||
"windows-result 0.2.0",
|
||||
"windows-targets",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7423,13 +7601,22 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
|
||||
dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7441,20 +7628,35 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.42.2",
|
||||
"windows_aarch64_msvc 0.42.2",
|
||||
"windows_i686_gnu 0.42.2",
|
||||
"windows_i686_msvc 0.42.2",
|
||||
"windows_x86_64_gnu 0.42.2",
|
||||
"windows_x86_64_gnullvm 0.42.2",
|
||||
"windows_x86_64_msvc 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7466,18 +7668,36 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -7490,24 +7710,48 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -7536,7 +7780,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"libc",
|
||||
"memmap2",
|
||||
"ndk",
|
||||
"ndk 0.9.0",
|
||||
"objc2 0.5.2",
|
||||
"objc2-app-kit 0.2.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
|
||||
+27
-8
@@ -1,7 +1,10 @@
|
||||
[package]
|
||||
name = "peerspeak"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
# Application crate, not a crates.io library — refuse `cargo publish` and let
|
||||
# cargo-deny's [licenses.private] skip the missing-license check.
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "peerspeak"
|
||||
@@ -27,17 +30,12 @@ bytes = "1.11.1"
|
||||
dirs = "6.0.0"
|
||||
iced = { version = "0.14.0", features = ["canvas", "image"] }
|
||||
# W4 custom avatars: decode/resize an arbitrary user image (png/jpeg only to keep
|
||||
# the codec surface small) and a native file picker (xdg-portal backend, no GTK).
|
||||
# the codec surface small). The matching native file picker (`rfd`) is platform-
|
||||
# gated below — its backend differs per OS (xdg-portal on Linux, Win32 on Windows).
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
rfd = { version = "0.17", default-features = false, features = ["xdg-portal"] }
|
||||
iroh = "1.0.0-rc.0"
|
||||
iroh-gossip = "0.99.0"
|
||||
opus = "0.3.1"
|
||||
# v0_3_49 exposes `Buffer::requested()` (the graph's per-cycle quantum), used by
|
||||
# the playback RT callback to fill exactly what the device asks for instead of
|
||||
# pinning the buffer to a hard-coded 1024-frame quantum (crackle on non-1024
|
||||
# hardware). The field has existed in libpipewire since 0.3.49 (2022).
|
||||
pipewire = { version = "0.9", features = ["v0_3_49"] }
|
||||
rand = "0.10.1"
|
||||
ringbuf = "0.5.0"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
@@ -45,3 +43,24 @@ serde_json = "1.0.150"
|
||||
thiserror = "2.0.18"
|
||||
tokio = { version = "1.52.3", features = ["full"] }
|
||||
tokio-stream = "0.1.18"
|
||||
|
||||
# --- Platform-specific dependencies -----------------------------------------
|
||||
# Audio and the native file-picker backends differ per OS. Everything else in the
|
||||
# app talks to the `AudioBackend` trait and the `PlatformAudioBackend` alias (see
|
||||
# `src/audio/mod.rs`), so platform selection is confined to these few lines.
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Linux audio backend. v0_3_49 exposes `Buffer::requested()` (the graph's per-cycle
|
||||
# quantum), used by the playback RT callback to fill exactly what the device asks
|
||||
# for instead of a hard-coded 1024-frame quantum (crackle on non-1024 hardware).
|
||||
# The field has existed in libpipewire since 0.3.49 (2022).
|
||||
pipewire = { version = "0.9", features = ["v0_3_49"] }
|
||||
# Native file picker via the XDG desktop portal (no GTK) on Linux.
|
||||
rfd = { version = "0.17", default-features = false, features = ["xdg-portal"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
# Native file picker using the built-in Win32 dialog backend on Windows.
|
||||
rfd = { version = "0.17", default-features = false }
|
||||
# Windows audio backend: cpal drives WASAPI for capture/playback behind the
|
||||
# AudioBackend trait (src/audio/cpal_impl.rs). The Linux counterpart is pipewire.
|
||||
cpal = "0.15"
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Security Review: `security-scan` branch (PeerSpeak)
|
||||
|
||||
_Date: 2026-06-18_
|
||||
|
||||
**Scope:** Protocol-versioning migration (`src/protocol.rs`, `versioned_topic`,
|
||||
ALPN/domain centralization, gossip topic namespacing) and the `deny.toml`
|
||||
supply-chain policy addition.
|
||||
|
||||
## Result: No high-confidence security vulnerabilities found.
|
||||
|
||||
Each plausible attack surface introduced by this branch was investigated and
|
||||
confirmed safe:
|
||||
|
||||
### 1. `versioned_topic` XOR transform — topic secrecy preserved
|
||||
`src/protocol.rs:46`, used at `src/network/gossip.rs:255`
|
||||
|
||||
The room `topic_id` is a uniformly random 32-byte secret (`rand::random()`,
|
||||
`src/core/mod.rs:1012`) acting as the room capability. XOR-ing it with the public
|
||||
constant `GOSSIP_PROTO.to_le_bytes()` cyclically is **bijective and
|
||||
entropy-preserving** — the result is still uniformly random; no byte becomes
|
||||
predictable and no entropy is lost. The room secret is no more recoverable by an
|
||||
observer than before the change (previously the raw `topic_id` was the on-wire
|
||||
topic; now it's a trivial public XOR of it). Bijectivity also preserves room
|
||||
distinctness, so isolation is not weakened. **Not a vulnerability.**
|
||||
|
||||
### 2. Signature topic-binding — no raw/versioned confusion
|
||||
`src/network/gossip.rs`
|
||||
|
||||
`active_topic_bytes` stores the **raw** `ticket.topic_id` (line 293), and both
|
||||
`sign_gossip` and `verify_gossip` bind against that raw value. Only the
|
||||
*subscribed* swarm topic (line 255) uses the versioned value. There is one swarm
|
||||
per join and every peer signs/verifies against the same raw topic, so no second
|
||||
topic exists to enable a raw↔versioned replay/confusion attack. Code matches
|
||||
VERSIONING.md's claim. **Not a vulnerability.**
|
||||
|
||||
### 3. `GOSSIP_SIG_DOMAIN` — moved verbatim
|
||||
Value identical (`"peerspeak-gossip-v1"`, `src/protocol.rs:34`); cross-version
|
||||
cryptographic domain separation preserved. **Not a vulnerability.**
|
||||
|
||||
### 4. ALPN changes — handshake compatibility only
|
||||
Audio `peerspeak-audio` → `peerspeak/audio/1`, friends `/0` → `/1`. No security
|
||||
check keys off the old ALPN strings (audio admission is gated by live room
|
||||
membership per S8, not the ALPN literal); no residual references to old strings
|
||||
in non-test code. **Not a vulnerability.**
|
||||
|
||||
### 5. `deny.toml`
|
||||
Ignores only two *unmaintained* advisories (`RUSTSEC-2024-0436`,
|
||||
`RUSTSEC-2026-0150`) on compile-time/FFI-only crates — documented, and dependency
|
||||
advisories are out of scope. **Not a vulnerability.**
|
||||
|
||||
The versioning migration is a clean, security-preserving change.
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
# PeerSpeak Versioning Standard
|
||||
|
||||
PeerSpeak is a full-mesh P2P voice app. Its "API contract" is not a library
|
||||
surface — it is the **wire protocol** two nodes use to talk. So versioning here
|
||||
tracks one question above all others:
|
||||
|
||||
> **Can a node on build X talk to a node on build Y?**
|
||||
|
||||
There are two distinct version layers. Keep them straight.
|
||||
|
||||
---
|
||||
|
||||
## Layer 1 — Release version (`Cargo.toml`)
|
||||
|
||||
The human-facing label you put on a build ("install this one").
|
||||
|
||||
**Scheme: SemVer, pre-1.0 (`0.MINOR.PATCH`).**
|
||||
|
||||
While we are pre-1.0 (friends-only, no stability promise yet):
|
||||
|
||||
| Change | Bump | Example |
|
||||
| --- | --- | --- |
|
||||
| **Breaking wire/protocol change** — peers on the old build can no longer interoperate; *everyone must update* | **MINOR** | `0.4.2 → 0.5.0` |
|
||||
| Compatible change — bug fix, internal refactor, or a feature that does **not** change the wire (UI, local-only behavior, additive logic that old peers ignore safely) | **PATCH** | `0.4.2 → 0.4.3` |
|
||||
|
||||
- **Reaching `1.0.0`:** when PeerSpeak is first shared beyond the trusted-friends
|
||||
circle (a "public" release), and we are willing to commit to wire stability.
|
||||
After 1.0, MAJOR = wire break, MINOR = compatible feature, PATCH = fix (normal
|
||||
SemVer).
|
||||
- Bump `version` in `Cargo.toml` as part of the change that warrants it, in the
|
||||
same commit. The number in `Cargo.toml` is the source of truth; surface it in
|
||||
the UI (e.g. an About/Settings line) so a user can read their build.
|
||||
|
||||
**Rule of thumb:** if you find yourself writing "all peers must rebuild" or
|
||||
"breaking gossip wire change" in a commit message (as S2 and W4 did), that is a
|
||||
**MINOR** bump, and it must also bump the relevant protocol version in Layer 2.
|
||||
|
||||
---
|
||||
|
||||
## Layer 2 — Protocol compatibility (the one that actually breaks calls)
|
||||
|
||||
Wire incompatibility must **fail fast and legibly** — never as a silent
|
||||
signature/decode error that looks like a bug or an attack. We achieve this by
|
||||
embedding a protocol version into each transport plane, so incompatible peers
|
||||
are rejected at connect/subscribe time instead of mid-conversation.
|
||||
|
||||
PeerSpeak has **three independent planes**, each versioned **separately** — bump
|
||||
only the plane whose wire format actually changed (audio rarely changes; gossip
|
||||
changes often; they must not be forced to bump together).
|
||||
|
||||
### ALPN naming convention
|
||||
|
||||
All peerspeak ALPNs use the form **`peerspeak/<plane>/<N>`** where `<N>` is that
|
||||
plane's protocol version (an integer, starts at `1`). iroh refuses a connection
|
||||
whose ALPN does not match exactly, so two peers on different `<N>` for a plane
|
||||
simply cannot open that connection → we map that to a clean "peer is running an
|
||||
incompatible version" instead of garbage.
|
||||
|
||||
| Plane | ALPN / mechanism | Bump when… |
|
||||
| --- | --- | --- |
|
||||
| **Audio** | ALPN `peerspeak/audio/<N>` | the Opus/datagram framing, sequencing, or audio-handshake changes |
|
||||
| **Friends/presence** | ALPN `peerspeak/friends/<N>` | the `ControlMsg` / presence ping-pong shape changes |
|
||||
| **Gossip** | *(see below — cannot use a custom ALPN)* | `GossipPayload` / `GossipMessage` / `PeerState` shape, signing, or freshness rules change |
|
||||
|
||||
### Gossip is special
|
||||
|
||||
The gossip plane runs over **iroh-gossip's own `GOSSIP_ALPN`**, which we do not
|
||||
control, so we cannot version it via the ALPN. Instead, the gossip protocol
|
||||
version is bound in **two** places:
|
||||
|
||||
1. **Topic namespacing (primary, fail-fast):** the room's `topic_id` is a random
|
||||
32 bytes carried in the ticket, but the topic we actually *subscribe* to is
|
||||
`protocol::versioned_topic(topic_id)` — a deterministic, dependency-free
|
||||
transform that folds `GOSSIP_PROTO` into the bytes. Peers on different gossip
|
||||
versions therefore derive **different subscription topics from the same ticket**
|
||||
and never share a swarm — the same isolation a versioned ALPN gives the other
|
||||
planes. The ticket format and the room identity (`topic_id`) are unchanged; only
|
||||
the subscribed topic is namespaced. (The transform is for *isolation*, not
|
||||
security — cryptographic separation is the signature domain below.)
|
||||
2. **Signature domain (cryptographic separation):** the signing domain string
|
||||
(`peerspeak-gossip-v<N>`, bound into every signed payload) carries the version,
|
||||
so two versions that somehow met on a topic would fail each other's verification
|
||||
rather than misread it.
|
||||
|
||||
Bumping the gossip version = bump `protocol::GOSSIP_PROTO` (drives
|
||||
`versioned_topic`) **and** `protocol::GOSSIP_SIG_DOMAIN` together (a unit test in
|
||||
`protocol.rs` asserts the domain string matches `GOSSIP_PROTO`, so they can't drift).
|
||||
|
||||
### Single source of truth for protocol versions
|
||||
|
||||
All protocol versions, ALPNs, the gossip signature domain, and `versioned_topic`
|
||||
live in **`src/protocol.rs`**. Every call site derives from there (e.g.
|
||||
`crate::protocol::AUDIO_ALPN`); **never hand-write an ALPN literal inline.** A
|
||||
unit test asserts each ALPN/domain string matches its integer version so a bump
|
||||
can't half-apply.
|
||||
|
||||
---
|
||||
|
||||
## "I changed X — what do I bump?" (quick reference)
|
||||
|
||||
| You changed… | Layer 2 (plane version) | Layer 1 (`Cargo.toml`) |
|
||||
| --- | --- | --- |
|
||||
| Opus framing / audio datagram layout | `peerspeak/audio/N` → `N+1` | MINOR |
|
||||
| `ControlMsg` / presence shape | `peerspeak/friends/N` → `N+1` | MINOR |
|
||||
| `GossipPayload`/`PeerState`/signing | `GOSSIP_PROTO_VERSION` + sig domain → next | MINOR |
|
||||
| UI, local config, recording, a fix that doesn't touch any wire | nothing | PATCH |
|
||||
| An *additive* gossip field that old peers safely ignore | judgement call — if old peers misbehave without it, treat as breaking (MINOR + gossip bump); if truly ignorable, PATCH | PATCH or MINOR |
|
||||
|
||||
When in doubt about "is this additive-safe?", assume **breaking** and bump. A
|
||||
false MINOR bump costs a coordinated rebuild; a false PATCH costs silent broken
|
||||
calls in the field.
|
||||
|
||||
---
|
||||
|
||||
## Release checklist (per build handed to anyone)
|
||||
|
||||
1. Decide MINOR vs PATCH from the table above; bump `Cargo.toml`.
|
||||
2. If MINOR for a wire reason, confirm the matching Layer-2 plane version(s) were
|
||||
bumped in the same change.
|
||||
3. Note the version + "breaking?" in the commit / handoff.
|
||||
4. Tag the commit (`v0.x.y`) so a given binary maps to a known commit.
|
||||
5. Rebuild **every** peer that must interoperate (e.g. dopedart, staged friend
|
||||
releases) when the bump was a MINOR/wire break.
|
||||
|
||||
---
|
||||
|
||||
## Current baseline (standard adopted + migrated, 2026-06-18, `0.2.0`)
|
||||
|
||||
- `Cargo.toml`: **`0.2.0`** — the MINOR bump for the (deliberately breaking)
|
||||
migration to this standard. **All peers must run ≥ `0.2.0` to interoperate**
|
||||
(the ALPNs and gossip topics changed); the pre-standard `0.1.0`-era build
|
||||
(e.g. an un-resynced dopedart) cannot talk to a `0.2.0` peer — by design, and it
|
||||
now fails cleanly at the handshake instead of silently.
|
||||
- Protocol versions (all at `1`): `peerspeak/audio/1`, `peerspeak/friends/1`,
|
||||
gossip `peerspeak-gossip-v1` + `versioned_topic`. All sourced from
|
||||
`src/protocol.rs`.
|
||||
- **Remaining nicety (not blocking):** surface `env!("CARGO_PKG_VERSION")` in the
|
||||
UI (an About/Settings line) and/or log it at startup, so a running build is
|
||||
self-identifying in the field. Small follow-up.
|
||||
@@ -0,0 +1,88 @@
|
||||
# cargo-deny policy for peerspeak
|
||||
#
|
||||
# Supersedes a bare `cargo audit` run. Enforce with:
|
||||
# cargo install cargo-deny --locked
|
||||
# cargo deny check
|
||||
#
|
||||
# In CI, run `cargo deny check` on a locked tree so the pinned, vetted
|
||||
# versions in Cargo.lock are what actually get audited.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Advisories: RustSec database. Vulnerabilities and yanked crates are denied
|
||||
# by default. The two `ignore` entries below are *unmaintained* warnings only
|
||||
# (no known exploit); they are deep transitive deps we cannot remove. Pinning
|
||||
# them via Cargo.lock is our real protection — a future malicious release does
|
||||
# not reach us until we deliberately `cargo update`, so each update is a review
|
||||
# checkpoint. Revisit these if either advisory is upgraded to a vulnerability.
|
||||
# ---------------------------------------------------------------------------
|
||||
[advisories]
|
||||
ignore = [
|
||||
# paste: unmaintained, compile-time proc-macro only (zero runtime surface),
|
||||
# transitive via iroh/netdev/netlink and rav1e/image/iced. Maintained fork
|
||||
# `pastey` is already in the tree; stragglers will follow upstream.
|
||||
"RUSTSEC-2024-0436",
|
||||
# audiopus_sys: unmaintained FFI bindings to the stable libopus C library,
|
||||
# pulled in via our direct `opus 0.3.1` dep. No drop-in replacement.
|
||||
"RUSTSEC-2026-0150",
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bans: shape of the dependency graph.
|
||||
# ---------------------------------------------------------------------------
|
||||
[bans]
|
||||
# Multiple versions of the same crate bloat the build; warn rather than fail
|
||||
# since transitive graphs (iroh, iced) routinely carry duplicates we can't fix.
|
||||
multiple-versions = "warn"
|
||||
# Wildcard ("*") version requirements are a supply-chain footgun: they accept
|
||||
# any future release, defeating the lockfile-as-review-checkpoint model.
|
||||
wildcards = "deny"
|
||||
# ...but our own intra-repo path deps may use "*"; don't penalize those.
|
||||
allow-wildcard-paths = true
|
||||
|
||||
# Crates that may never appear in the graph. Add a maintained replacement's
|
||||
# predecessor here once you've migrated off it, to prevent regressions.
|
||||
deny = []
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sources: where crates are allowed to come from. This is the core anti-hijack
|
||||
# control — only the official crates.io registry is trusted; arbitrary git
|
||||
# sources (a common vector for slipping in unaudited code) are rejected.
|
||||
# ---------------------------------------------------------------------------
|
||||
[sources]
|
||||
unknown-registry = "deny"
|
||||
unknown-git = "deny"
|
||||
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
|
||||
# allow-git = [] # add a specific, pinned git repo here only if ever needed
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Licenses: permissive set covering the current graph. If `cargo deny check`
|
||||
# reports an unmatched license, vet it and add the SPDX id here (or add a
|
||||
# per-crate entry under [licenses.exceptions]) rather than widening blindly.
|
||||
# ---------------------------------------------------------------------------
|
||||
[licenses]
|
||||
allow = [
|
||||
"MIT",
|
||||
"Apache-2.0",
|
||||
"Apache-2.0 WITH LLVM-exception",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"ISC",
|
||||
"Zlib",
|
||||
"MPL-2.0",
|
||||
"Unicode-3.0",
|
||||
"Unicode-DFS-2016",
|
||||
"CC0-1.0",
|
||||
"0BSD",
|
||||
"Unlicense",
|
||||
"BSL-1.0",
|
||||
"NCSA", # University of Illinois/NCSA — BSD-like permissive
|
||||
"CDLA-Permissive-2.0", # Community Data License Agreement, permissive
|
||||
]
|
||||
confidence-threshold = 0.8
|
||||
exceptions = []
|
||||
|
||||
# peerspeak itself has no `license` field and is not published, so skip the
|
||||
# "unlicensed" check for our own (private) crate. Add a license to Cargo.toml
|
||||
# if/when this is ever published.
|
||||
[licenses.private]
|
||||
ignore = true
|
||||
@@ -0,0 +1,77 @@
|
||||
# PeerSpeak on Windows
|
||||
|
||||
Current status: the Windows port cross-compiles to `x86_64-pc-windows-gnu` and the `.exe`
|
||||
launches under Wine. A real Windows/WASAPI host is still needed for the final audio-device
|
||||
checks listed below.
|
||||
|
||||
## What works today
|
||||
|
||||
| Area | Status |
|
||||
|---|---|
|
||||
| GUI | Iced/wgpu builds and renders under Wine. |
|
||||
| Networking | Iroh QUIC transport and gossip compile on Windows. |
|
||||
| Audio backend | `cpal` drives WASAPI capture/playback behind `AudioBackend`. |
|
||||
| Codec | Opus remains 48 kHz mono, 20 ms frames. |
|
||||
| Identity | `ring` identity generation/load is platform-neutral. |
|
||||
| Chimes | Windows uses PowerShell `System.Media.SoundPlayer` for WAV playback. |
|
||||
|
||||
Windows paths are resolved through `dirs`:
|
||||
|
||||
- Config: `%APPDATA%\peerspeak\config.json`
|
||||
- Identity: `%APPDATA%\peerspeak\identity.key`
|
||||
- Log: `%LOCALAPPDATA%\peerspeak\peerspeak.log`
|
||||
|
||||
## Building
|
||||
|
||||
### Native Windows
|
||||
|
||||
Install MSVC Build Tools and CMake, then build normally:
|
||||
|
||||
```powershell
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
If CMake is 4.x or newer, the vendored `opus`/`libopus` build may need:
|
||||
|
||||
```powershell
|
||||
$env:CMAKE_POLICY_VERSION_MINIMUM = "3.5"
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
### Cross-compile from Linux
|
||||
|
||||
The current dev path cross-compiles from an Arch environment to the GNU Windows target:
|
||||
|
||||
```sh
|
||||
rustup target add x86_64-pc-windows-gnu
|
||||
sudo pacman -S mingw-w64-gcc cmake
|
||||
CMAKE_POLICY_VERSION_MINIMUM=3.5 cargo build --release --target x86_64-pc-windows-gnu --bin peerspeak
|
||||
```
|
||||
|
||||
Wine is useful for launch/render smoke tests, but it is not a substitute for a real
|
||||
Windows audio-device pass. The deeper migration plan (phases, decisions, the opus build
|
||||
spike) lives in the maintainer's handoff docs, outside the repo.
|
||||
|
||||
## First run and networking
|
||||
|
||||
Expect a Windows Firewall prompt the first time the app opens network sockets. Allow it:
|
||||
PeerSpeak uses UDP for QUIC, plus relay traffic when direct NAT traversal is not available.
|
||||
|
||||
The default network mode keeps the n0 relay available for NAT traversal without publishing
|
||||
presence to n0 DNS. Direct peer-to-peer paths may work when both networks allow them; relayed
|
||||
connections are expected and valid.
|
||||
|
||||
## Known gaps
|
||||
|
||||
| Item | Status |
|
||||
|---|---|
|
||||
| Echo cancellation | Linux-only PipeWire feature. The Windows UI shows it disabled as unavailable. |
|
||||
| Screen share | Requires a Windows `pixelpass.exe` on `PATH` or a configured override. |
|
||||
| Chimes | Now routed through Windows `SoundPlayer`; needs a real Windows host to audibly verify. |
|
||||
| Resampling/device format | Cross-compiled. cpal/WASAPI now chooses native 48 kHz when available and otherwise resamples/remaps at the device boundary; needs real Windows hardware audio verification. |
|
||||
| Device persistence | Open. WASAPI friendly names may duplicate or change across driver/profile changes. |
|
||||
| Playback pacing | Cross-compiled. The fixed playback target under WASAPI shared mode still needs real-hardware verification with `audio_probe`. |
|
||||
|
||||
Before calling Windows support done, verify a real Windows machine can create/join a room,
|
||||
capture mic audio, hear remote audio, select devices, restart with selections preserved, and
|
||||
play notification chimes.
|
||||
@@ -0,0 +1,112 @@
|
||||
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
|
||||
#
|
||||
# Test-pack split package: ONE `makepkg -si` builds + installs BOTH peerspeak
|
||||
# (voice chat) and pixelpass (screen sharing) from the public gitbutter repos
|
||||
# over https. pixelpass lands on /usr/bin so peerspeak's screen-share button
|
||||
# finds it. Shared version string is derived from peerspeak's git.
|
||||
#
|
||||
# Clone this repo and build from here:
|
||||
# git clone https://gitbutter.xyz/mollusk/peerspeak.git
|
||||
# cd peerspeak/packaging/test-pack
|
||||
# makepkg -si
|
||||
pkgbase=peerspeak-git
|
||||
pkgname=('peerspeak-git' 'pixelpass')
|
||||
pkgver=0.1.0
|
||||
pkgrel=1
|
||||
arch=('x86_64')
|
||||
url="https://gitbutter.xyz/mollusk/peerspeak"
|
||||
license=('custom' 'MIT' 'Apache-2.0' 'OFL-1.1')
|
||||
makedepends=('git' 'cargo' 'pkgconf')
|
||||
options=('!lto' '!debug')
|
||||
source=("peerspeak::git+https://gitbutter.xyz/mollusk/peerspeak.git"
|
||||
"pixelpass::git+https://gitbutter.xyz/mollusk/pixelpass.git#branch=main")
|
||||
sha256sums=('SKIP'
|
||||
'SKIP')
|
||||
|
||||
pkgver() {
|
||||
cd "$srcdir/peerspeak"
|
||||
# Shared across both split packages. 0.1.0.r<commits>.g<short-sha>.
|
||||
printf '%s.r%s.g%s' \
|
||||
"$(awk -F'\"' '/^version =/{print $2; exit}' Cargo.toml)" \
|
||||
"$(git rev-list --count HEAD)" \
|
||||
"$(git rev-parse --short HEAD)"
|
||||
}
|
||||
|
||||
prepare() {
|
||||
# Vendor deps up front so build() can run --frozen (no surprise network).
|
||||
export CARGO_HOME="$srcdir/cargo-home"
|
||||
local host; host="$(rustc -vV | sed -n 's/host: //p')"
|
||||
cd "$srcdir/peerspeak"; cargo fetch --locked --target "$host"
|
||||
cd "$srcdir/pixelpass"; cargo fetch --locked --target "$host"
|
||||
}
|
||||
|
||||
build() {
|
||||
export CARGO_HOME="$srcdir/cargo-home"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
export CARGO_TARGET_DIR=target
|
||||
|
||||
cd "$srcdir/peerspeak"
|
||||
cargo build --frozen --release --bin peerspeak
|
||||
|
||||
cd "$srcdir/pixelpass"
|
||||
# --features gui so the .desktop launcher (pixelpass --gui) works.
|
||||
cargo build --frozen --release --features gui
|
||||
}
|
||||
|
||||
check() {
|
||||
export CARGO_HOME="$srcdir/cargo-home"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
# peerspeak library unit tests only — its integration suites bind real
|
||||
# iroh/QUIC endpoints and fail in a sandboxed/offline build environment.
|
||||
cd "$srcdir/peerspeak"
|
||||
cargo test --frozen --release --lib
|
||||
}
|
||||
|
||||
package_peerspeak-git() {
|
||||
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
||||
depends=('pipewire' 'opus')
|
||||
optdepends=('pixelpass: screen sharing inside a room'
|
||||
'mpv: screen-share viewer (vlc is used as a fallback)')
|
||||
provides=('peerspeak')
|
||||
conflicts=('peerspeak')
|
||||
license=('custom')
|
||||
|
||||
cd "$srcdir/peerspeak"
|
||||
install -Dm755 "target/release/peerspeak" "$pkgdir/usr/bin/peerspeak"
|
||||
install -Dm644 "packaging/peerspeak.desktop" \
|
||||
"$pkgdir/usr/share/applications/peerspeak.desktop"
|
||||
|
||||
# Hicolor icon theme (scalable SVG + the rendered raster sizes).
|
||||
install -Dm644 "assets/icons/peerspeak.svg" \
|
||||
"$pkgdir/usr/share/icons/hicolor/scalable/apps/peerspeak.svg"
|
||||
local s
|
||||
for s in 16 24 32 48 64 128 256 512; do
|
||||
install -Dm644 "assets/icons/peerspeak-$s.png" \
|
||||
"$pkgdir/usr/share/icons/hicolor/${s}x${s}/apps/peerspeak.png"
|
||||
done
|
||||
}
|
||||
|
||||
package_pixelpass() {
|
||||
pkgdesc='P2P screen sharing over iroh — no port forwarding, no signup'
|
||||
depends=('gstreamer' 'gst-plugins-base' 'gst-plugins-good' 'gst-plugins-bad'
|
||||
'gst-libav' 'gst-plugin-va' 'libpulse' 'hicolor-icon-theme'
|
||||
'libglvnd' 'libxkbcommon' 'wayland')
|
||||
optdepends=('mpv: recommended stream viewer (the GUI launches mpv)'
|
||||
'vlc: alternative stream viewer'
|
||||
'gst-plugins-ugly: software x264 encoding for `pixelpass --no-hwencode`'
|
||||
'gst-plugin-pipewire: screen capture on Wayland sessions'
|
||||
'xorg-xwininfo: share a single window on X11 (`pixelpass --window`)')
|
||||
license=('MIT' 'Apache-2.0' 'OFL-1.1')
|
||||
|
||||
cd "$srcdir/pixelpass"
|
||||
install -Dm0755 "target/release/pixelpass" "$pkgdir/usr/bin/pixelpass"
|
||||
install -Dm0644 assets/pixelpass.desktop \
|
||||
"$pkgdir/usr/share/applications/pixelpass.desktop"
|
||||
install -Dm0644 assets/pixelpass.svg \
|
||||
"$pkgdir/usr/share/icons/hicolor/scalable/apps/pixelpass.svg"
|
||||
install -Dm0644 README.md "$pkgdir/usr/share/doc/pixelpass/README.md"
|
||||
install -Dm0644 LICENSE-MIT "$pkgdir/usr/share/licenses/pixelpass/LICENSE-MIT"
|
||||
install -Dm0644 LICENSE-APACHE "$pkgdir/usr/share/licenses/pixelpass/LICENSE-APACHE"
|
||||
install -Dm0644 assets/NotoSans-OFL.txt \
|
||||
"$pkgdir/usr/share/licenses/pixelpass/NotoSans-OFL.txt"
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
# PeerSpeak + PixelPass — CachyOS/Arch test pack
|
||||
|
||||
A single **split PKGBUILD** that builds the latest code from the public gitbutter
|
||||
repos and installs **both** programs at once:
|
||||
|
||||
- `peerspeak` — decentralized P2P voice chat
|
||||
- `pixelpass` — P2P screen sharing (peerspeak launches it for the screen-share button)
|
||||
|
||||
## Build & install (one command)
|
||||
|
||||
```sh
|
||||
git clone https://gitbutter.xyz/mollusk/peerspeak.git
|
||||
cd peerspeak/packaging/test-pack
|
||||
makepkg -si
|
||||
```
|
||||
|
||||
`makepkg -si` auto-installs every dependency via pacman before building —
|
||||
including the Rust toolchain itself (the `cargo` makedepend is provided by the
|
||||
`rust` package), `git`, `pkgconf`, pipewire + opus for peerspeak, and the
|
||||
gstreamer/VA-API stack for pixelpass. The only prerequisite is the `base-devel`
|
||||
group (which provides `makepkg`). If you already use `rustup`, that satisfies the
|
||||
`cargo` makedepend and the `rust` package won't be pulled in — no conflict.
|
||||
|
||||
When it finishes you'll have `peerspeak` and `pixelpass` on your PATH at
|
||||
`/usr/bin`. To rebuild later with fresh upstream code, re-run `makepkg -si`; the
|
||||
git sources re-pull `main` and the version bumps automatically.
|
||||
|
||||
> Skip the test step with `makepkg -si --nocheck` for a faster build.
|
||||
|
||||
## Running the cross-internet test
|
||||
|
||||
1. Launch `peerspeak` on both machines.
|
||||
2. One person **creates** a room and shares the room code/ticket with the other.
|
||||
3. The other **joins** with that code.
|
||||
4. iroh does NAT hole-punching automatically; if a direct path can't be made it
|
||||
falls back to a public n0 relay — **no port forwarding required**.
|
||||
5. Allow the app through any local firewall if prompted (outbound UDP / QUIC;
|
||||
nothing needs to be opened inbound for relay mode).
|
||||
|
||||
### What we're smoke-testing
|
||||
- Two real humans, two networks, over the internet.
|
||||
- Mic capture + remote playback both directions, no crackle/dropouts.
|
||||
- Mute / deafen, push-to-talk.
|
||||
- Text chat in-room.
|
||||
- Avatars (presets + custom upload) show up on the other side.
|
||||
- Screen share: click the screen-share control → it launches `pixelpass`; the
|
||||
viewer opens in `mpv` on the receiving side.
|
||||
- Notification chimes (join/leave/etc.).
|
||||
- Leave / rejoin cleanly.
|
||||
|
||||
If anything misbehaves, grab the log path peerspeak prints on startup and the
|
||||
exact repro steps.
|
||||
+770
-112
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+316
@@ -0,0 +1,316 @@
|
||||
//! Per-peer listener-side voice EQ.
|
||||
//!
|
||||
//! The EQ is deliberately small and local: three RBJ cookbook biquads at fixed
|
||||
//! voice-oriented frequencies, with only gain exposed to the UI. State lives per
|
||||
//! peer in the playout mixer so filter delay registers are continuous across 20ms
|
||||
//! Opus frames; flat settings are treated as bypass so the default path is cheap
|
||||
//! and sample-exact.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const DEFAULT_SAMPLE_RATE: f32 = 48_000.0;
|
||||
const LOW_SHELF_HZ: f32 = 160.0;
|
||||
const MID_PEAK_HZ: f32 = 2_400.0;
|
||||
const HIGH_SHELF_HZ: f32 = 6_500.0;
|
||||
const MID_Q: f32 = 1.0;
|
||||
const SHELF_Q: f32 = std::f32::consts::FRAC_1_SQRT_2;
|
||||
const FLAT_EPSILON_DB: f32 = 0.001;
|
||||
|
||||
/// UI and config clamp for each band. Wide enough to be useful for voice, narrow
|
||||
/// enough that a peer cannot accidentally make the listener-side limiter do all
|
||||
/// the work.
|
||||
pub const EQ_GAIN_DB_MIN: f32 = -12.0;
|
||||
pub const EQ_GAIN_DB_MAX: f32 = 12.0;
|
||||
|
||||
/// Persisted per-peer EQ gains, in decibels. `Default` is flat/bypassed.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||||
pub struct EqSettings {
|
||||
#[serde(default)]
|
||||
pub low_gain_db: f32,
|
||||
#[serde(default)]
|
||||
pub mid_gain_db: f32,
|
||||
#[serde(default)]
|
||||
pub high_gain_db: f32,
|
||||
}
|
||||
|
||||
impl Default for EqSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
low_gain_db: 0.0,
|
||||
mid_gain_db: 0.0,
|
||||
high_gain_db: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EqSettings {
|
||||
pub fn flat() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Clamp all public gains to the supported UI/DSP range.
|
||||
pub fn clamped(self) -> Self {
|
||||
Self {
|
||||
low_gain_db: self.low_gain_db.clamp(EQ_GAIN_DB_MIN, EQ_GAIN_DB_MAX),
|
||||
mid_gain_db: self.mid_gain_db.clamp(EQ_GAIN_DB_MIN, EQ_GAIN_DB_MAX),
|
||||
high_gain_db: self.high_gain_db.clamp(EQ_GAIN_DB_MIN, EQ_GAIN_DB_MAX),
|
||||
}
|
||||
}
|
||||
|
||||
/// True when the EQ should be bypassed entirely.
|
||||
pub fn is_flat(self) -> bool {
|
||||
self.low_gain_db.abs() <= FLAT_EPSILON_DB
|
||||
&& self.mid_gain_db.abs() <= FLAT_EPSILON_DB
|
||||
&& self.high_gain_db.abs() <= FLAT_EPSILON_DB
|
||||
}
|
||||
}
|
||||
|
||||
/// A stateful three-band EQ. One instance belongs to one decoded peer stream.
|
||||
pub struct Eq {
|
||||
settings: EqSettings,
|
||||
low: Biquad,
|
||||
mid: Biquad,
|
||||
high: Biquad,
|
||||
}
|
||||
|
||||
impl Eq {
|
||||
/// Build an EQ at the application's audio rate (48 kHz).
|
||||
pub fn new(settings: EqSettings) -> Self {
|
||||
Self::with_sample_rate(settings, DEFAULT_SAMPLE_RATE)
|
||||
}
|
||||
|
||||
fn with_sample_rate(settings: EqSettings, sample_rate: f32) -> Self {
|
||||
let settings = settings.clamped();
|
||||
Self {
|
||||
settings,
|
||||
low: Biquad::low_shelf(sample_rate, LOW_SHELF_HZ, settings.low_gain_db, SHELF_Q),
|
||||
mid: Biquad::peaking(sample_rate, MID_PEAK_HZ, settings.mid_gain_db, MID_Q),
|
||||
high: Biquad::high_shelf(sample_rate, HIGH_SHELF_HZ, settings.high_gain_db, SHELF_Q),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn settings(&self) -> EqSettings {
|
||||
self.settings
|
||||
}
|
||||
|
||||
/// Process one mono PCM frame in place. Flat settings are sample-exact bypass.
|
||||
pub fn process_frame(&mut self, frame: &mut [i16]) {
|
||||
if self.settings.is_flat() {
|
||||
return;
|
||||
}
|
||||
for sample in frame {
|
||||
let x = *sample as f32;
|
||||
let y = self.high.process(self.mid.process(self.low.process(x)));
|
||||
*sample = y.round().clamp(i16::MIN as f32, i16::MAX as f32) as i16;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Coeffs {
|
||||
b0: f32,
|
||||
b1: f32,
|
||||
b2: f32,
|
||||
a1: f32,
|
||||
a2: f32,
|
||||
}
|
||||
|
||||
impl Coeffs {
|
||||
fn normalized(b0: f32, b1: f32, b2: f32, a0: f32, a1: f32, a2: f32) -> Self {
|
||||
let inv_a0 = 1.0 / a0;
|
||||
Self {
|
||||
b0: b0 * inv_a0,
|
||||
b1: b1 * inv_a0,
|
||||
b2: b2 * inv_a0,
|
||||
a1: a1 * inv_a0,
|
||||
a2: a2 * inv_a0,
|
||||
}
|
||||
}
|
||||
|
||||
fn all_finite(self) -> bool {
|
||||
self.b0.is_finite()
|
||||
&& self.b1.is_finite()
|
||||
&& self.b2.is_finite()
|
||||
&& self.a1.is_finite()
|
||||
&& self.a2.is_finite()
|
||||
}
|
||||
}
|
||||
|
||||
/// Direct Form II transposed biquad. The two delay registers are the state that
|
||||
/// must survive across frames.
|
||||
struct Biquad {
|
||||
coeffs: Coeffs,
|
||||
z1: f32,
|
||||
z2: f32,
|
||||
}
|
||||
|
||||
impl Biquad {
|
||||
fn new(coeffs: Coeffs) -> Self {
|
||||
debug_assert!(coeffs.all_finite());
|
||||
Self {
|
||||
coeffs,
|
||||
z1: 0.0,
|
||||
z2: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn low_shelf(sample_rate: f32, freq: f32, gain_db: f32, q: f32) -> Self {
|
||||
let (a, cos_w0, alpha) = rbj_terms(sample_rate, freq, gain_db, q);
|
||||
let sqrt_a = a.sqrt();
|
||||
let b0 = a * ((a + 1.0) - (a - 1.0) * cos_w0 + 2.0 * sqrt_a * alpha);
|
||||
let b1 = 2.0 * a * ((a - 1.0) - (a + 1.0) * cos_w0);
|
||||
let b2 = a * ((a + 1.0) - (a - 1.0) * cos_w0 - 2.0 * sqrt_a * alpha);
|
||||
let a0 = (a + 1.0) + (a - 1.0) * cos_w0 + 2.0 * sqrt_a * alpha;
|
||||
let a1 = -2.0 * ((a - 1.0) + (a + 1.0) * cos_w0);
|
||||
let a2 = (a + 1.0) + (a - 1.0) * cos_w0 - 2.0 * sqrt_a * alpha;
|
||||
Self::new(Coeffs::normalized(b0, b1, b2, a0, a1, a2))
|
||||
}
|
||||
|
||||
fn peaking(sample_rate: f32, freq: f32, gain_db: f32, q: f32) -> Self {
|
||||
let (a, cos_w0, alpha) = rbj_terms(sample_rate, freq, gain_db, q);
|
||||
let b0 = 1.0 + alpha * a;
|
||||
let b1 = -2.0 * cos_w0;
|
||||
let b2 = 1.0 - alpha * a;
|
||||
let a0 = 1.0 + alpha / a;
|
||||
let a1 = -2.0 * cos_w0;
|
||||
let a2 = 1.0 - alpha / a;
|
||||
Self::new(Coeffs::normalized(b0, b1, b2, a0, a1, a2))
|
||||
}
|
||||
|
||||
fn high_shelf(sample_rate: f32, freq: f32, gain_db: f32, q: f32) -> Self {
|
||||
let (a, cos_w0, alpha) = rbj_terms(sample_rate, freq, gain_db, q);
|
||||
let sqrt_a = a.sqrt();
|
||||
let b0 = a * ((a + 1.0) + (a - 1.0) * cos_w0 + 2.0 * sqrt_a * alpha);
|
||||
let b1 = -2.0 * a * ((a - 1.0) + (a + 1.0) * cos_w0);
|
||||
let b2 = a * ((a + 1.0) + (a - 1.0) * cos_w0 - 2.0 * sqrt_a * alpha);
|
||||
let a0 = (a + 1.0) - (a - 1.0) * cos_w0 + 2.0 * sqrt_a * alpha;
|
||||
let a1 = 2.0 * ((a - 1.0) - (a + 1.0) * cos_w0);
|
||||
let a2 = (a + 1.0) - (a - 1.0) * cos_w0 - 2.0 * sqrt_a * alpha;
|
||||
Self::new(Coeffs::normalized(b0, b1, b2, a0, a1, a2))
|
||||
}
|
||||
|
||||
fn process(&mut self, x: f32) -> f32 {
|
||||
let y = self.coeffs.b0 * x + self.z1;
|
||||
self.z1 = self.coeffs.b1 * x - self.coeffs.a1 * y + self.z2;
|
||||
self.z2 = self.coeffs.b2 * x - self.coeffs.a2 * y;
|
||||
|
||||
// Avoid carrying denormal-sized state forever on long quiet tails.
|
||||
if self.z1.abs() < 1.0e-20 {
|
||||
self.z1 = 0.0;
|
||||
}
|
||||
if self.z2.abs() < 1.0e-20 {
|
||||
self.z2 = 0.0;
|
||||
}
|
||||
y
|
||||
}
|
||||
}
|
||||
|
||||
fn rbj_terms(sample_rate: f32, freq: f32, gain_db: f32, q: f32) -> (f32, f32, f32) {
|
||||
let sr = sample_rate.max(1.0);
|
||||
let f = freq.clamp(1.0, sr * 0.49);
|
||||
let w0 = 2.0 * std::f32::consts::PI * f / sr;
|
||||
let a = 10.0f32.powf(gain_db / 40.0);
|
||||
let alpha = w0.sin() / (2.0 * q.max(0.001));
|
||||
(a, w0.cos(), alpha)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sine(freq: f32, len: usize, amp: f32) -> Vec<i16> {
|
||||
(0..len)
|
||||
.map(|n| {
|
||||
let t = n as f32 / DEFAULT_SAMPLE_RATE;
|
||||
(amp * (2.0 * std::f32::consts::PI * freq * t).sin()).round() as i16
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn rms(frame: &[i16]) -> f32 {
|
||||
let sum: f32 = frame.iter().map(|&s| (s as f32).powi(2)).sum();
|
||||
(sum / frame.len().max(1) as f32).sqrt()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_eq_is_sample_exact_identity() {
|
||||
let mut eq = Eq::new(EqSettings::flat());
|
||||
let mut frame: Vec<i16> = (-480..480).map(|n| (n * 31) as i16).collect();
|
||||
let original = frame.clone();
|
||||
eq.process_frame(&mut frame);
|
||||
assert_eq!(frame, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_shelf_boost_raises_low_frequency_energy() {
|
||||
let mut eq = Eq::new(EqSettings {
|
||||
low_gain_db: 9.0,
|
||||
..EqSettings::flat()
|
||||
});
|
||||
let mut low = sine(100.0, 48_000, 3_000.0);
|
||||
let before = rms(&low);
|
||||
eq.process_frame(&mut low);
|
||||
let after = rms(&low);
|
||||
assert!(after > before * 1.6, "low shelf should boost low RMS: {before} -> {after}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_shelf_boost_raises_high_frequency_energy() {
|
||||
let mut eq = Eq::new(EqSettings {
|
||||
high_gain_db: 9.0,
|
||||
..EqSettings::flat()
|
||||
});
|
||||
let mut high = sine(8_000.0, 48_000, 3_000.0);
|
||||
let before = rms(&high);
|
||||
eq.process_frame(&mut high);
|
||||
let after = rms(&high);
|
||||
assert!(after > before * 1.6, "high shelf should boost high RMS: {before} -> {after}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coefficients_are_finite_across_supported_gain_range() {
|
||||
for gain in [EQ_GAIN_DB_MIN, -6.0, 0.0, 6.0, EQ_GAIN_DB_MAX] {
|
||||
for b in [
|
||||
Biquad::low_shelf(DEFAULT_SAMPLE_RATE, LOW_SHELF_HZ, gain, SHELF_Q),
|
||||
Biquad::peaking(DEFAULT_SAMPLE_RATE, MID_PEAK_HZ, gain, MID_Q),
|
||||
Biquad::high_shelf(DEFAULT_SAMPLE_RATE, HIGH_SHELF_HZ, gain, SHELF_Q),
|
||||
] {
|
||||
assert!(b.coeffs.all_finite(), "coefficients must be finite at {gain} dB");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hot_signal_does_not_nan_or_wrap() {
|
||||
let mut eq = Eq::new(EqSettings {
|
||||
low_gain_db: 12.0,
|
||||
mid_gain_db: 12.0,
|
||||
high_gain_db: 12.0,
|
||||
});
|
||||
let mut frame = sine(1_000.0, 48_000, 30_000.0);
|
||||
eq.process_frame(&mut frame);
|
||||
let peak = frame
|
||||
.iter()
|
||||
.map(|&s| i32::from(s).abs())
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
assert!(peak > 1_000, "processed signal should retain audible energy");
|
||||
assert!(
|
||||
frame.iter().any(|&s| s > 0) && frame.iter().any(|&s| s < 0),
|
||||
"a boosted sine should retain both polarities"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_are_clamped() {
|
||||
let s = EqSettings {
|
||||
low_gain_db: -99.0,
|
||||
mid_gain_db: 2.0,
|
||||
high_gain_db: 99.0,
|
||||
}
|
||||
.clamped();
|
||||
assert_eq!(s.low_gain_db, EQ_GAIN_DB_MIN);
|
||||
assert_eq!(s.mid_gain_db, 2.0);
|
||||
assert_eq!(s.high_gain_db, EQ_GAIN_DB_MAX);
|
||||
}
|
||||
}
|
||||
+60
-5
@@ -1,17 +1,22 @@
|
||||
use std::sync::mpsc::{Sender, Receiver};
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Target depth of the playback ring buffer, in samples (48kHz mono).
|
||||
/// Playback output channel count. Capture/encode/network remain mono; only the
|
||||
/// listener-side playout bus is stereo.
|
||||
pub const PLAYBACK_CHANNELS: usize = 2;
|
||||
|
||||
/// Target depth of the playback ring buffer, in interleaved samples (48kHz
|
||||
/// stereo).
|
||||
///
|
||||
/// The playout chain is paced to keep the ring near this level: production is
|
||||
/// driven by how fast PipeWire actually drains the ring (the hardware clock),
|
||||
/// not by a fixed software timer — which is what eliminates the producer/
|
||||
/// consumer beat that otherwise churns ~20% of audio into drops + silence.
|
||||
/// 2880 = 60ms = 3×20ms frames, comfortably above the 2048-sample max quantum
|
||||
/// 5760 = 60ms = 3×20ms stereo frames, comfortably above the 2048-frame max quantum
|
||||
/// so a single hardware pull can never empty the ring before the mixer refills.
|
||||
pub const PLAYBACK_TARGET_SAMPLES: usize = 2880;
|
||||
pub const PLAYBACK_TARGET_SAMPLES: usize = 2880 * PLAYBACK_CHANNELS;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AudioError {
|
||||
@@ -51,10 +56,60 @@ pub trait AudioBackend: Send + Sync {
|
||||
fn stop(&self) -> Result<(), AudioError>;
|
||||
}
|
||||
|
||||
pub mod echo_cancel;
|
||||
pub mod eq;
|
||||
pub mod gate;
|
||||
pub mod limiter;
|
||||
pub mod multitrack;
|
||||
pub mod pan;
|
||||
// Linear resamplers used by the Windows/cpal backend (W4). Platform-neutral and
|
||||
// pure, so it builds (and its tests run) everywhere even though only the cpal
|
||||
// backend wires it in.
|
||||
pub mod resample;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod echo_cancel;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod pipewire_impl;
|
||||
#[cfg(windows)]
|
||||
pub mod cpal_impl;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod pw_cli;
|
||||
pub mod recorder;
|
||||
|
||||
/// A selectable audio device for the input/output pickers. `name` is the stable
|
||||
/// identifier the backend uses to request the device (`target_node`);
|
||||
/// `description` is the human-facing label shown in the UI. The two may be equal
|
||||
/// (cpal/WASAPI) or differ (PipeWire node name vs. description).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AudioDevice {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub is_input: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AudioDevice {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.description)
|
||||
}
|
||||
}
|
||||
|
||||
// Enumerate audio input/output devices for the pickers (sorted by description),
|
||||
// returning the same `AudioDevice` shape regardless of platform: PipeWire
|
||||
// (`pw-cli`) on Linux, cpal/WASAPI on Windows.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use pw_cli::enumerate_audio_devices;
|
||||
#[cfg(windows)]
|
||||
pub use cpal_impl::enumerate_audio_devices;
|
||||
|
||||
/// The audio backend implementation for the current platform.
|
||||
///
|
||||
/// The whole app constructs and threads this alias (via
|
||||
/// `PlatformAudioBackend::new()`) rather than any concrete backend type, so
|
||||
/// platform selection lives entirely here. Both implementations satisfy the
|
||||
/// [`AudioBackend`] trait, which is the only interface the core talks to.
|
||||
///
|
||||
/// - Linux → PipeWire ([`pipewire_impl::PipeWireBackend`]).
|
||||
/// - Windows → cpal/WASAPI ([`cpal_impl::CpalBackend`]).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub type PlatformAudioBackend = pipewire_impl::PipeWireBackend;
|
||||
#[cfg(windows)]
|
||||
pub type PlatformAudioBackend = cpal_impl::CpalBackend;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Listener-side stereo pan law.
|
||||
//!
|
||||
//! Capture, Opus, and the network stay mono. These helpers are used only after a
|
||||
//! peer has been decoded locally, just before the playout mix is written to the
|
||||
//! stereo playback bus.
|
||||
|
||||
/// Clamp and compute constant-power pan gains for `pan` in `[-1.0, 1.0]`.
|
||||
///
|
||||
/// - `-1.0` is hard left `(1, 0)`
|
||||
/// - `0.0` is center `(sqrt(1/2), sqrt(1/2))`
|
||||
/// - `1.0` is hard right `(0, 1)`
|
||||
pub fn pan_gains(pan: f32) -> (f32, f32) {
|
||||
let pan = pan.clamp(-1.0, 1.0);
|
||||
let theta = (pan + 1.0) * std::f32::consts::FRAC_PI_4;
|
||||
(theta.cos(), theta.sin())
|
||||
}
|
||||
|
||||
/// Gains used by the legacy-compatible playback mixer.
|
||||
///
|
||||
/// The pure law above is constant-power. The existing application, however, was
|
||||
/// mono and users heard the full old mono signal in both ears. Scaling by sqrt(2)
|
||||
/// makes `pan = 0` exactly dual-mono `(1, 1)`, preserving the default sound while
|
||||
/// still following the same equal-power curve as a peer is moved away from center.
|
||||
pub fn playback_pan_gains(pan: f32) -> (f32, f32) {
|
||||
let (left, right) = pan_gains(pan);
|
||||
(left * std::f32::consts::SQRT_2, right * std::f32::consts::SQRT_2)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const EPS: f32 = 1.0e-6;
|
||||
|
||||
#[test]
|
||||
fn hard_left_and_right_are_endpoints() {
|
||||
assert_eq!(pan_gains(-1.0), (1.0, 0.0));
|
||||
let (l, r) = pan_gains(1.0);
|
||||
assert!(l.abs() < EPS, "left at hard-right should be zero-ish, got {l}");
|
||||
assert!((r - 1.0).abs() < EPS, "right at hard-right should be one, got {r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn center_is_equal_and_power_preserving() {
|
||||
let (l, r) = pan_gains(0.0);
|
||||
assert!((l - r).abs() < EPS);
|
||||
assert!((l - std::f32::consts::FRAC_1_SQRT_2).abs() < EPS);
|
||||
assert!(((l * l + r * r) - 1.0).abs() < EPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gains_move_monotonically() {
|
||||
let pans = [-1.0, -0.5, 0.0, 0.5, 1.0];
|
||||
let mut prev_l = f32::INFINITY;
|
||||
let mut prev_r = f32::NEG_INFINITY;
|
||||
for pan in pans {
|
||||
let (l, r) = pan_gains(pan);
|
||||
assert!(l <= prev_l + EPS, "left gain must not rise as pan moves right");
|
||||
assert!(r >= prev_r - EPS, "right gain must not fall as pan moves right");
|
||||
prev_l = l;
|
||||
prev_r = r;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_center_preserves_legacy_dual_mono() {
|
||||
let (l, r) = playback_pan_gains(0.0);
|
||||
assert!((l - 1.0).abs() < EPS);
|
||||
assert!((r - 1.0).abs() < EPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_is_clamped() {
|
||||
assert_eq!(pan_gains(-9.0), pan_gains(-1.0));
|
||||
assert_eq!(pan_gains(9.0), pan_gains(1.0));
|
||||
}
|
||||
}
|
||||
+23
-18
@@ -283,8 +283,9 @@ fn run_playback(
|
||||
let core = context.connect_rc(None)
|
||||
.map_err(|e| AudioError::Init(e.to_string()))?;
|
||||
|
||||
// Ring buffer setup: 9600 samples (200ms capacity for mono 48kHz).
|
||||
const RING_CAPACITY: usize = 9600;
|
||||
// Ring buffer setup: 19200 interleaved samples (200ms capacity for stereo
|
||||
// 48kHz).
|
||||
const RING_CAPACITY: usize = 9600 * crate::audio::PLAYBACK_CHANNELS;
|
||||
let rb = HeapRb::<i16>::new(RING_CAPACITY);
|
||||
let (mut producer, consumer) = rb.split();
|
||||
|
||||
@@ -371,7 +372,7 @@ fn run_playback(
|
||||
let data = &mut datas[0];
|
||||
let mut total_size = 0;
|
||||
if let Some(slice) = data.data() {
|
||||
let stride = 2; // S16LE Mono = 2 bytes per frame
|
||||
let stride = 2 * crate::audio::PLAYBACK_CHANNELS; // S16LE stereo
|
||||
// Fill exactly what the graph asked for this cycle (with
|
||||
// a safe fallback), never the whole mapped slice — that
|
||||
// over-pull past the ring depth was the original crackle.
|
||||
@@ -383,17 +384,20 @@ fn run_playback(
|
||||
user_data.callback_count.fetch_add(1, Ordering::Relaxed);
|
||||
let mut starved = 0u64;
|
||||
for i in 0..n_frames {
|
||||
let val = match user_data.consumer.try_pop() {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
starved += 1;
|
||||
0
|
||||
}
|
||||
};
|
||||
let bytes = val.to_le_bytes();
|
||||
let start = i * stride;
|
||||
slice[start] = bytes[0];
|
||||
slice[start + 1] = bytes[1];
|
||||
for ch in 0..crate::audio::PLAYBACK_CHANNELS {
|
||||
let val = match user_data.consumer.try_pop() {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
starved += 1;
|
||||
0
|
||||
}
|
||||
};
|
||||
let bytes = val.to_le_bytes();
|
||||
let offset = start + ch * 2;
|
||||
slice[offset] = bytes[0];
|
||||
slice[offset + 1] = bytes[1];
|
||||
}
|
||||
}
|
||||
if starved > 0 {
|
||||
// One wait-free atomic add per quantum — RT-safe.
|
||||
@@ -403,7 +407,8 @@ fn run_playback(
|
||||
// actually pulled (excluding underruns, which removed
|
||||
// nothing) so the mixer paces against true ring depth.
|
||||
// Wait-free fetch_sub, RT-safe.
|
||||
let popped = n_frames - starved as usize;
|
||||
let requested_samples = n_frames * crate::audio::PLAYBACK_CHANNELS;
|
||||
let popped = requested_samples - starved as usize;
|
||||
if popped > 0 {
|
||||
user_data.fill_gauge.fetch_sub(popped, Ordering::Relaxed);
|
||||
}
|
||||
@@ -411,7 +416,7 @@ fn run_playback(
|
||||
}
|
||||
let chunk = data.chunk_mut();
|
||||
*chunk.offset_mut() = 0;
|
||||
*chunk.stride_mut() = 2;
|
||||
*chunk.stride_mut() = (2 * crate::audio::PLAYBACK_CHANNELS) as _;
|
||||
*chunk.size_mut() = total_size as _;
|
||||
}
|
||||
}
|
||||
@@ -422,7 +427,7 @@ fn run_playback(
|
||||
let mut audio_info = spa::param::audio::AudioInfoRaw::new();
|
||||
audio_info.set_format(spa::param::audio::AudioFormat::S16LE);
|
||||
audio_info.set_rate(48000);
|
||||
audio_info.set_channels(1); // Mono
|
||||
audio_info.set_channels(crate::audio::PLAYBACK_CHANNELS as u32); // Stereo playback
|
||||
|
||||
let obj = pw::spa::pod::Object {
|
||||
type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
|
||||
@@ -450,7 +455,7 @@ fn run_playback(
|
||||
// `frames_to_produce`). `requested()`, not the buffer size, now governs
|
||||
// per-cycle output, so this is a generous max rather than a hard pin.
|
||||
const MAX_QUANTUM_FRAMES: i32 = 8192;
|
||||
const STRIDE: i32 = 2; // S16LE mono = 2 bytes/frame
|
||||
const STRIDE: i32 = 2 * crate::audio::PLAYBACK_CHANNELS as i32; // S16LE stereo
|
||||
let buffers_obj = pw::spa::pod::Object {
|
||||
type_: pw::spa::utils::SpaTypes::ObjectParamBuffers.as_raw(),
|
||||
id: pw::spa::param::ParamType::Buffers.as_raw(),
|
||||
@@ -555,7 +560,7 @@ fn run_playback(
|
||||
if verbose || du > 0 || dd > 0 {
|
||||
crate::log_msg(&format!(
|
||||
"playout-health: fill={fill} samples (~{}ms) | underrun +{du} samples/s (total {u}) | dropped +{dd} frames/s (total {d}) | quantum={q} frames, {dc} callbacks/s",
|
||||
fill / 48,
|
||||
fill / (48 * crate::audio::PLAYBACK_CHANNELS),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
+1
-13
@@ -1,18 +1,6 @@
|
||||
use super::AudioDevice;
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AudioDevice {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub is_input: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AudioDevice {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.description)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enumerate_audio_devices() -> Vec<AudioDevice> {
|
||||
let output = Command::new("pw-cli")
|
||||
.arg("list-objects")
|
||||
|
||||
+55
-8
@@ -22,6 +22,8 @@ use std::path::{Path, PathBuf};
|
||||
const SAMPLE_RATE: u32 = 48_000;
|
||||
const BITS_PER_SAMPLE: u16 = 16;
|
||||
const CHANNELS: u16 = 1;
|
||||
const RIFF_DATA_OVERHEAD: u64 = 36;
|
||||
const MAX_RIFF_DATA_BYTES: u64 = u32::MAX as u64 - RIFF_DATA_OVERHEAD;
|
||||
|
||||
/// Cap on buffered mic samples (~200ms). Bounds how far recording lag can drift
|
||||
/// if the capture clock runs persistently faster than playout — past this we drop
|
||||
@@ -34,7 +36,7 @@ const MAX_MIC_FIFO: usize = SAMPLE_RATE as usize / 5;
|
||||
pub struct WavWriter {
|
||||
file: File,
|
||||
/// Bytes of PCM data written so far (for the size fields).
|
||||
data_bytes: u32,
|
||||
data_bytes: u64,
|
||||
}
|
||||
|
||||
impl WavWriter {
|
||||
@@ -42,7 +44,10 @@ impl WavWriter {
|
||||
pub fn new(path: &Path) -> io::Result<Self> {
|
||||
let mut file = File::create(path)?;
|
||||
file.write_all(&Self::header(0))?;
|
||||
Ok(Self { file, data_bytes: 0 })
|
||||
Ok(Self {
|
||||
file,
|
||||
data_bytes: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// The 44-byte canonical WAV/PCM header for the given data length in bytes.
|
||||
@@ -68,21 +73,40 @@ impl WavWriter {
|
||||
|
||||
/// Append PCM samples to the data chunk.
|
||||
pub fn write_samples(&mut self, samples: &[i16]) -> io::Result<()> {
|
||||
let added_bytes = u64::try_from(samples.len())
|
||||
.ok()
|
||||
.and_then(|len| len.checked_mul(2))
|
||||
.ok_or_else(|| io::Error::other("WAV sample buffer too large"))?;
|
||||
let new_data_bytes = self
|
||||
.data_bytes
|
||||
.checked_add(added_bytes)
|
||||
.ok_or_else(|| io::Error::other("WAV data size overflow"))?;
|
||||
if new_data_bytes > MAX_RIFF_DATA_BYTES {
|
||||
return Err(io::Error::other("WAV too large for RIFF"));
|
||||
}
|
||||
|
||||
let mut buf = Vec::with_capacity(samples.len() * 2);
|
||||
for &s in samples {
|
||||
buf.extend_from_slice(&s.to_le_bytes());
|
||||
}
|
||||
self.file.write_all(&buf)?;
|
||||
self.data_bytes += (samples.len() * 2) as u32;
|
||||
self.data_bytes = new_data_bytes;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Patch the RIFF + data size fields and flush. Consumes the writer.
|
||||
pub fn finalize(mut self) -> io::Result<()> {
|
||||
let data_bytes = u32::try_from(self.data_bytes)
|
||||
.map_err(|_| io::Error::other("WAV too large for RIFF"))?;
|
||||
let riff_size = self
|
||||
.data_bytes
|
||||
.checked_add(RIFF_DATA_OVERHEAD)
|
||||
.and_then(|size| u32::try_from(size).ok())
|
||||
.ok_or_else(|| io::Error::other("WAV too large for RIFF"))?;
|
||||
self.file.seek(SeekFrom::Start(4))?;
|
||||
self.file.write_all(&(36 + self.data_bytes).to_le_bytes())?;
|
||||
self.file.write_all(&riff_size.to_le_bytes())?;
|
||||
self.file.seek(SeekFrom::Start(40))?;
|
||||
self.file.write_all(&self.data_bytes.to_le_bytes())?;
|
||||
self.file.write_all(&data_bytes.to_le_bytes())?;
|
||||
self.file.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -209,11 +233,29 @@ mod tests {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_writer_rejects_data_that_would_overflow_riff_header() {
|
||||
let dir = std::env::temp_dir();
|
||||
let path = dir.join(format!("peerspeak-overflow-{}.wav", std::process::id()));
|
||||
let mut w = WavWriter::new(&path).unwrap();
|
||||
w.data_bytes = MAX_RIFF_DATA_BYTES - 1;
|
||||
let before_len = std::fs::metadata(&path).unwrap().len();
|
||||
|
||||
let err = w.write_samples(&[0]).unwrap_err();
|
||||
|
||||
assert_eq!(err.kind(), io::ErrorKind::Other);
|
||||
assert_eq!(w.data_bytes, MAX_RIFF_DATA_BYTES - 1);
|
||||
assert_eq!(std::fs::metadata(&path).unwrap().len(), before_len);
|
||||
drop(w);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mic_is_summed_with_mix_when_present() {
|
||||
let dir = std::env::temp_dir();
|
||||
let mut r = Recorder {
|
||||
writer: WavWriter::new(&dir.join(format!("ps-sum-{}.wav", std::process::id()))).unwrap(),
|
||||
writer: WavWriter::new(&dir.join(format!("ps-sum-{}.wav", std::process::id())))
|
||||
.unwrap(),
|
||||
mic_fifo: VecDeque::new(),
|
||||
path: PathBuf::new(),
|
||||
};
|
||||
@@ -223,7 +265,11 @@ mod tests {
|
||||
r.write_frame(&[10, 20]).unwrap();
|
||||
assert_eq!(r.mic_fifo.len(), 1, "two samples consumed, one mic left");
|
||||
r.write_frame(&[0, 0]).unwrap();
|
||||
assert_eq!(r.mic_fifo.len(), 0, "remaining mic sample consumed; rest is silence");
|
||||
assert_eq!(
|
||||
r.mic_fifo.len(),
|
||||
0,
|
||||
"remaining mic sample consumed; rest is silence"
|
||||
);
|
||||
let _ = r.finalize();
|
||||
}
|
||||
|
||||
@@ -231,7 +277,8 @@ mod tests {
|
||||
fn mic_fifo_is_capped() {
|
||||
let dir = std::env::temp_dir();
|
||||
let mut r = Recorder {
|
||||
writer: WavWriter::new(&dir.join(format!("ps-cap-{}.wav", std::process::id()))).unwrap(),
|
||||
writer: WavWriter::new(&dir.join(format!("ps-cap-{}.wav", std::process::id())))
|
||||
.unwrap(),
|
||||
mic_fifo: VecDeque::new(),
|
||||
path: PathBuf::new(),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
//! Dep-free linear-interpolation resamplers for the Windows/cpal backend (W4).
|
||||
//!
|
||||
//! The pipeline runs internally at 48 kHz (Opus + the 20 ms frame), but a WASAPI
|
||||
//! endpoint may run at a different rate (commonly 44.1 kHz) and/or a non-stereo
|
||||
//! channel layout. These convert at the device boundary so such a device plays and
|
||||
//! captures instead of hard-erroring (the W4 limitation in the Windows port).
|
||||
//!
|
||||
//! ## Where each is used
|
||||
//! - [`PushResampler`] (single channel) converts **capture** from the device rate
|
||||
//! to 48 kHz on the capture drain thread — off the RT callback.
|
||||
//! - [`StereoPullResampler`] converts **playback** from the internal 48 kHz stereo
|
||||
//! bus to the device rate inside the output RT callback, pulling internal frames
|
||||
//! from the ring on demand. It allocates nothing in `next`, so it is RT-safe.
|
||||
//!
|
||||
//! ## Quality
|
||||
//! This is plain linear interpolation with no anti-aliasing filter: correct,
|
||||
//! allocation-free, and adequate for speech, but it adds some aliasing when
|
||||
//! downsampling. The seam is intentionally tiny so a higher-quality polyphase/FIR
|
||||
//! resampler (e.g. the `rubato` crate, pending a supply-chain decision) can later
|
||||
//! replace the internals without touching the cpal backend. The matching-rate /
|
||||
//! matching-layout path in the backend bypasses these entirely and stays bit-exact.
|
||||
|
||||
/// Linear interpolation between `a` and `b` at fractional position `frac` in `[0, 1)`.
|
||||
#[inline]
|
||||
fn lerp(a: f32, b: f32, frac: f32) -> f32 {
|
||||
a + (b - a) * frac
|
||||
}
|
||||
|
||||
/// Stateful single-channel **push** resampler: feed input samples at `in_rate`,
|
||||
/// receive output samples at `out_rate` through an `emit` callback. It carries the
|
||||
/// fractional read position and the previous input sample across calls, so feeding
|
||||
/// the stream block-by-block joins seamlessly. Neither [`push`](Self::push) nor
|
||||
/// [`process`](Self::process) allocates.
|
||||
pub struct PushResampler {
|
||||
/// Input samples consumed per output sample (`in_rate / out_rate`).
|
||||
step: f64,
|
||||
/// Position of the next output sample, in input-sample units, measured from the
|
||||
/// index of `prev` (the most recent input). Always advanced to stay `< 1.0`
|
||||
/// after each input is consumed.
|
||||
next: f64,
|
||||
/// The previous input sample (left edge of the current interpolation segment).
|
||||
prev: f32,
|
||||
/// Whether any input has been seen yet (anchors the first output at input[0]).
|
||||
started: bool,
|
||||
}
|
||||
|
||||
impl PushResampler {
|
||||
/// Build a resampler from `in_rate` to `out_rate` (both in Hz). Rates are
|
||||
/// clamped to `>= 1` so `step` is always finite and non-zero: a zero `step`
|
||||
/// would make [`push`](Self::push)'s `while self.next < 1.0` loop forever. The
|
||||
/// cpal backend's `resolve()` also rejects such rates up front, so this is
|
||||
/// belt-and-suspenders against a future caller (review W7).
|
||||
pub fn new(in_rate: u32, out_rate: u32) -> Self {
|
||||
Self {
|
||||
step: in_rate.max(1) as f64 / out_rate.max(1) as f64,
|
||||
next: 0.0,
|
||||
prev: 0.0,
|
||||
started: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed one input sample; `emit` is called for each output sample produced
|
||||
/// (zero or more, depending on the rate ratio).
|
||||
pub fn push(&mut self, cur: f32, mut emit: impl FnMut(f32)) {
|
||||
if !self.started {
|
||||
// First sample: just establish the left edge. Linear interpolation
|
||||
// needs the next input as the right edge, so the first output is
|
||||
// produced on the next push. This gives exact alignment
|
||||
// (`output[k] == input[k]` at equal rates) with one input-sample of
|
||||
// latency — negligible (~20 µs at 48 kHz).
|
||||
self.started = true;
|
||||
self.prev = cur;
|
||||
self.next = 0.0;
|
||||
return;
|
||||
}
|
||||
// `prev` sits at position 0 of this segment and `cur` at position 1; emit
|
||||
// every output whose position falls in [0, 1).
|
||||
while self.next < 1.0 {
|
||||
emit(lerp(self.prev, cur, self.next as f32));
|
||||
self.next += self.step;
|
||||
}
|
||||
self.next -= 1.0;
|
||||
self.prev = cur;
|
||||
}
|
||||
|
||||
/// Convenience for tests / batch callers: push a whole slice.
|
||||
pub fn process(&mut self, input: &[f32], mut emit: impl FnMut(f32)) {
|
||||
for &s in input {
|
||||
self.push(s, &mut emit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stateful stereo **pull** resampler: produce output frames at `out_rate` by
|
||||
/// pulling input frames at `in_rate` from a closure on demand. Call
|
||||
/// [`next`](Self::next) once per output frame; it pulls as many input frames as the
|
||||
/// ratio requires and returns the interpolated `(left, right)`, or `None` when the
|
||||
/// puller runs dry (an underrun). Allocates nothing, so it is safe in an RT output
|
||||
/// callback.
|
||||
pub struct StereoPullResampler {
|
||||
/// Input frames consumed per output frame (`in_rate / out_rate`).
|
||||
step: f64,
|
||||
/// Position of the next output frame within `[prev, cur)`, in `[0, 1)`.
|
||||
frac: f64,
|
||||
/// Left edge of the current interpolation segment.
|
||||
prev: (f32, f32),
|
||||
/// Right edge of the current interpolation segment.
|
||||
cur: (f32, f32),
|
||||
/// Whether `prev`/`cur` have been primed from the puller yet.
|
||||
primed: bool,
|
||||
}
|
||||
|
||||
impl StereoPullResampler {
|
||||
/// Build a resampler from `in_rate` to `out_rate` (both in Hz). Rates are
|
||||
/// clamped to `>= 1` so `step` is finite and non-zero — otherwise
|
||||
/// [`next`](Self::next)'s `while self.frac >= 1.0` could spin (review W7).
|
||||
pub fn new(in_rate: u32, out_rate: u32) -> Self {
|
||||
Self {
|
||||
step: in_rate.max(1) as f64 / out_rate.max(1) as f64,
|
||||
frac: 0.0,
|
||||
prev: (0.0, 0.0),
|
||||
cur: (0.0, 0.0),
|
||||
primed: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Produce the next output frame, pulling input frames via `pull` as needed.
|
||||
/// Returns `None` if `pull` returns `None` before the frame can be formed
|
||||
/// (underrun); the caller should substitute silence for that frame.
|
||||
pub fn next(&mut self, mut pull: impl FnMut() -> Option<(f32, f32)>) -> Option<(f32, f32)> {
|
||||
if !self.primed {
|
||||
// Prime both edges from two pulls so the first output frame aligns
|
||||
// exactly with the first input frame (`out[0] == in[0]` at equal
|
||||
// rates). Needs two frames available to start, which the prefilled
|
||||
// playback ring always has.
|
||||
self.prev = pull()?;
|
||||
self.cur = pull()?;
|
||||
self.primed = true;
|
||||
self.frac = 0.0;
|
||||
}
|
||||
// Advance the segment until the read position lands inside [prev, cur).
|
||||
while self.frac >= 1.0 {
|
||||
self.prev = self.cur;
|
||||
self.cur = pull()?;
|
||||
self.frac -= 1.0;
|
||||
}
|
||||
let f = self.frac as f32;
|
||||
let out = (lerp(self.prev.0, self.cur.0, f), lerp(self.prev.1, self.cur.1, f));
|
||||
self.frac += self.step;
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Equal rates align exactly: `output[k] == input[k]`. The final input lands on
|
||||
/// the next push (one-sample streaming latency), so we get `n - 1` outputs.
|
||||
#[test]
|
||||
fn push_identity_when_rates_match() {
|
||||
let mut r = PushResampler::new(48_000, 48_000);
|
||||
let input = [0.0, 0.1, 0.2, 0.3, 0.4];
|
||||
let mut out = Vec::new();
|
||||
r.process(&input, |s| out.push(s));
|
||||
assert_eq!(out.len(), input.len() - 1);
|
||||
for (a, b) in out.iter().zip(input.iter()) {
|
||||
assert!((a - b).abs() < 1e-6, "{a} vs {b}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Upsampling 2x roughly doubles the output count and the midpoints interpolate.
|
||||
#[test]
|
||||
fn push_upsample_2x_interpolates_midpoints() {
|
||||
let mut r = PushResampler::new(24_000, 48_000); // step = 0.5
|
||||
let input = [0.0, 1.0, 2.0, 3.0];
|
||||
let mut out = Vec::new();
|
||||
r.process(&input, |s| out.push(s));
|
||||
// (n - 1) segments at 2 outputs each = 6.
|
||||
assert_eq!(out.len(), 6, "out {out:?}");
|
||||
// A half-step between 1.0 and 2.0 must appear near 1.5.
|
||||
assert!(
|
||||
out.iter().any(|&s| (s - 1.5).abs() < 1e-3),
|
||||
"expected a ~1.5 midpoint in {out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Downsampling drops the rate: fewer outputs than inputs, monotonic ramp preserved.
|
||||
#[test]
|
||||
fn push_downsample_reduces_count() {
|
||||
let mut r = PushResampler::new(48_000, 44_100); // step ~1.088
|
||||
let input: Vec<f32> = (0..441).map(|i| i as f32).collect();
|
||||
let mut out = Vec::new();
|
||||
r.process(&input, |s| out.push(s));
|
||||
// 441 in @ 48k -> ~405 out @ 44.1k.
|
||||
assert!(
|
||||
(390..=410).contains(&out.len()),
|
||||
"expected ~405 outputs, got {}",
|
||||
out.len()
|
||||
);
|
||||
// Output stays within the input's value range and is non-decreasing.
|
||||
for w in out.windows(2) {
|
||||
assert!(w[1] >= w[0] - 1e-3, "ramp should not reverse: {w:?}");
|
||||
}
|
||||
assert!(*out.last().unwrap() <= 440.0 + 1e-3);
|
||||
}
|
||||
|
||||
/// Pull resampler at equal rates returns each input frame in order, aligned.
|
||||
/// Two-pull priming uses one frame of lookahead, so `n` inputs yield `n - 1`
|
||||
/// outputs (the last frame emits once a successor arrives).
|
||||
#[test]
|
||||
fn pull_identity_when_rates_match() {
|
||||
let mut r = StereoPullResampler::new(48_000, 48_000);
|
||||
let frames = [(0.0, 9.0), (1.0, 8.0), (2.0, 7.0), (3.0, 6.0)];
|
||||
let mut idx = 0;
|
||||
let mut out = Vec::new();
|
||||
while let Some(f) = r.next(|| {
|
||||
let v = frames.get(idx).copied();
|
||||
idx += 1;
|
||||
v
|
||||
}) {
|
||||
out.push(f);
|
||||
}
|
||||
assert_eq!(out.len(), frames.len() - 1, "out {out:?}");
|
||||
for (got, want) in out.iter().zip(frames.iter()) {
|
||||
assert!((got.0 - want.0).abs() < 1e-6 && (got.1 - want.1).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull resampler reports underrun (`None`) once the source is exhausted.
|
||||
#[test]
|
||||
fn pull_returns_none_on_underrun() {
|
||||
let mut r = StereoPullResampler::new(48_000, 44_100); // step ~1.088 -> pulls >1 per out
|
||||
let frames = [(0.0, 0.0), (1.0, -1.0)];
|
||||
let mut idx = 0;
|
||||
let mut pull = || {
|
||||
let v = frames.get(idx).copied();
|
||||
idx += 1;
|
||||
v
|
||||
};
|
||||
// First frame primes + emits; subsequent calls eventually exhaust the source.
|
||||
let mut produced = 0;
|
||||
let mut hit_none = false;
|
||||
for _ in 0..10 {
|
||||
if r.next(&mut pull).is_some() {
|
||||
produced += 1;
|
||||
} else {
|
||||
hit_none = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(produced >= 1, "should produce at least the primed frame");
|
||||
assert!(hit_none, "should report underrun once the puller is dry");
|
||||
}
|
||||
|
||||
/// Downsampling via pull consumes more input frames than it emits output frames.
|
||||
#[test]
|
||||
fn pull_downsample_consumes_more_than_it_emits() {
|
||||
let mut r = StereoPullResampler::new(48_000, 24_000); // step = 2.0
|
||||
let input: Vec<(f32, f32)> = (0..100).map(|i| (i as f32, -(i as f32))).collect();
|
||||
let mut idx = 0;
|
||||
let mut emitted = 0;
|
||||
for _ in 0..40 {
|
||||
let f = r.next(|| {
|
||||
let v = input.get(idx).copied();
|
||||
idx += 1;
|
||||
v
|
||||
});
|
||||
if f.is_some() {
|
||||
emitted += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// At step 2.0 we consume ~2 input frames per output frame.
|
||||
assert!(idx > emitted, "consumed {idx} input, emitted {emitted} output");
|
||||
}
|
||||
|
||||
/// A zero rate must not produce a zero `step` (which would spin `push`'s inner
|
||||
/// `while self.next < 1.0` forever). Clamping makes the call terminate (W7).
|
||||
#[test]
|
||||
fn push_zero_rate_does_not_spin() {
|
||||
let mut r = PushResampler::new(0, 48_000);
|
||||
let mut count = 0usize;
|
||||
// Feed two samples; with a clamped non-zero step this returns promptly.
|
||||
r.push(0.0, |_| count += 1);
|
||||
r.push(1.0, |_| count += 1);
|
||||
// Reaching here at all is the assertion (no hang); some output is produced.
|
||||
assert!(count >= 1);
|
||||
}
|
||||
|
||||
/// A zero output rate must not make the pull resampler's segment-advance loop
|
||||
/// spin. Clamping keeps `step` finite so `next` terminates (W7).
|
||||
#[test]
|
||||
fn pull_zero_out_rate_does_not_spin() {
|
||||
let mut r = StereoPullResampler::new(48_000, 0);
|
||||
let frames = [(0.0, 0.0), (1.0, 1.0), (2.0, 2.0)];
|
||||
let mut idx = 0;
|
||||
let got = r.next(|| {
|
||||
let v = frames.get(idx).copied();
|
||||
idx += 1;
|
||||
v
|
||||
});
|
||||
// Terminates and yields the primed frame instead of hanging.
|
||||
assert!(got.is_some());
|
||||
}
|
||||
}
|
||||
+229
-97
@@ -1,11 +1,11 @@
|
||||
//! Audio playout diagnostic probe.
|
||||
//!
|
||||
//! Drives a phase-continuous sine tone through the *real* PipeWire playback path
|
||||
//! (`PipeWireBackend::start_playback`), using the *same* fill-paced production
|
||||
//! the production mixer uses (`core/mod.rs`): generate a frame only while the
|
||||
//! Drives a phase-continuous sine tone through the *real* playback path
|
||||
//! (PipeWire on Linux, cpal/WASAPI on Windows), using the *same* fill-paced
|
||||
//! production the production mixer uses (`core/mod.rs`): generate a frame only while the
|
||||
//! playback ring is below `PLAYBACK_TARGET_SAMPLES`, so production tracks the
|
||||
//! PipeWire hardware clock. No network, no microphone — this isolates the local
|
||||
//! output path so we can confirm the clock-paced playout is glitch-free.
|
||||
//! hardware clock. No network, no microphone — this isolates the local output
|
||||
//! path so we can confirm the clock-paced playout is glitch-free.
|
||||
//!
|
||||
//! Use your ears on the tone (any click/pop is a glitch) together with the
|
||||
//! `playout-health:` lines tailed to stdout:
|
||||
@@ -17,103 +17,235 @@
|
||||
//!
|
||||
//! Run: cargo run --bin audio_probe -- [freq_hz] [seconds] [target_node]
|
||||
//! e.g. cargo run --release --bin audio_probe -- 440 30
|
||||
//!
|
||||
//! This probe exercises the platform playback backend directly: PipeWire on Linux
|
||||
//! and cpal/WASAPI on Windows. Other targets use a stub that explains the limitation.
|
||||
|
||||
use std::io::{BufRead, BufReader, Seek, SeekFrom};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use peerspeak::audio::AudioBackend;
|
||||
use peerspeak::audio::pipewire_impl::PipeWireBackend;
|
||||
use peerspeak::core::jitter::FRAME_SAMPLES; // 960 samples = 20ms @ 48kHz mono
|
||||
|
||||
const SAMPLE_RATE: f32 = 48_000.0;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let freq: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(440.0);
|
||||
let secs: u64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(30);
|
||||
let target_node: Option<String> = args.next();
|
||||
|
||||
// The playout-health logger is quiet in normal operation (it only logs
|
||||
// glitches); ask it for the full once-per-second heartbeat so the probe can
|
||||
// show the steady-state numbers.
|
||||
// SAFETY: set before any playback thread starts, so no concurrent env read.
|
||||
unsafe { std::env::set_var("PEERSPEAK_AUDIO_VERBOSE", "1") };
|
||||
|
||||
println!("audio_probe: {freq} Hz tone for {secs}s through the real playback path.");
|
||||
println!("Listen for clicks/pops; watch the playout-health lines below.\n");
|
||||
|
||||
// Tail the app log (where playout-health lines land) to stdout in the
|
||||
// background so it's all in one terminal.
|
||||
spawn_log_tailer();
|
||||
|
||||
let backend = PipeWireBackend::new();
|
||||
let (tx, rx) = mpsc::channel::<Vec<i16>>();
|
||||
let ring_fill = Arc::new(AtomicUsize::new(0));
|
||||
if let Err(e) = backend.start_playback(rx, target_node, ring_fill.clone()) {
|
||||
eprintln!("failed to start playback: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase-continuous sine, generated one 20ms frame at a time, fill-paced
|
||||
// exactly like the production mixer: only produce while the ring is below
|
||||
// target, so production tracks the PipeWire hardware clock.
|
||||
use std::sync::atomic::Ordering;
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(secs);
|
||||
let mut n: u64 = 0; // running sample index keeps phase continuous across frames
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
if ring_fill.load(Ordering::Relaxed) >= peerspeak::audio::PLAYBACK_TARGET_SAMPLES {
|
||||
tokio::time::sleep(Duration::from_millis(2)).await;
|
||||
continue;
|
||||
}
|
||||
let mut frame = Vec::with_capacity(FRAME_SAMPLES);
|
||||
for _ in 0..FRAME_SAMPLES {
|
||||
let t = n as f32 / SAMPLE_RATE;
|
||||
// 0.25 amplitude: clearly audible but not harsh.
|
||||
let sample = (0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16;
|
||||
frame.push(sample);
|
||||
n += 1;
|
||||
}
|
||||
if tx.send(frame).is_err() {
|
||||
eprintln!("playback channel closed early");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Let the ring drain, then stop.
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
let _ = backend.stop();
|
||||
println!("\naudio_probe: done.");
|
||||
#[cfg(target_os = "linux")]
|
||||
fn main() {
|
||||
unix_probe::run();
|
||||
}
|
||||
|
||||
/// Open the app log, seek to the end, and echo new lines (the `playout-health:`
|
||||
/// reports) to stdout once they appear.
|
||||
fn spawn_log_tailer() {
|
||||
let path = peerspeak::log_file_path();
|
||||
std::thread::spawn(move || {
|
||||
// Wait for the file to exist (first log_msg creates it).
|
||||
let file = loop {
|
||||
if let Ok(f) = std::fs::File::open(&path) {
|
||||
break f;
|
||||
#[cfg(windows)]
|
||||
fn main() {
|
||||
win_probe::run();
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", windows)))]
|
||||
fn main() {
|
||||
eprintln!(
|
||||
"audio_probe is only supported on Linux and Windows builds (it drives the platform playback backend directly)."
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod unix_probe {
|
||||
use std::io::{BufRead, BufReader, Seek, SeekFrom};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use peerspeak::audio::AudioBackend;
|
||||
use peerspeak::audio::pipewire_impl::PipeWireBackend;
|
||||
use peerspeak::core::jitter::FRAME_SAMPLES; // 960 mono frames = 20ms @ 48kHz
|
||||
|
||||
const SAMPLE_RATE: f32 = 48_000.0;
|
||||
|
||||
#[tokio::main]
|
||||
pub async fn run() {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let freq: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(440.0);
|
||||
let secs: u64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(30);
|
||||
let target_node: Option<String> = args.next();
|
||||
|
||||
// The playout-health logger is quiet in normal operation (it only logs
|
||||
// glitches); ask it for the full once-per-second heartbeat so the probe can
|
||||
// show the steady-state numbers.
|
||||
// SAFETY: set before any playback thread starts, so no concurrent env read.
|
||||
unsafe { std::env::set_var("PEERSPEAK_AUDIO_VERBOSE", "1") };
|
||||
|
||||
println!("audio_probe: {freq} Hz tone for {secs}s through the real playback path.");
|
||||
println!("Listen for clicks/pops; watch the playout-health lines below.\n");
|
||||
|
||||
// Tail the app log (where playout-health lines land) to stdout in the
|
||||
// background so it's all in one terminal.
|
||||
spawn_log_tailer();
|
||||
|
||||
let backend = PipeWireBackend::new();
|
||||
let (tx, rx) = mpsc::channel::<Vec<i16>>();
|
||||
let ring_fill = Arc::new(AtomicUsize::new(0));
|
||||
if let Err(e) = backend.start_playback(rx, target_node, ring_fill.clone()) {
|
||||
eprintln!("failed to start playback: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase-continuous sine, generated one 20ms frame at a time, fill-paced
|
||||
// exactly like the production mixer: only produce while the ring is below
|
||||
// target, so production tracks the PipeWire hardware clock.
|
||||
use std::sync::atomic::Ordering;
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(secs);
|
||||
let mut n: u64 = 0; // running sample index keeps phase continuous across frames
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
if ring_fill.load(Ordering::Relaxed) >= peerspeak::audio::PLAYBACK_TARGET_SAMPLES {
|
||||
tokio::time::sleep(Duration::from_millis(2)).await;
|
||||
continue;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
};
|
||||
let mut reader = BufReader::new(file);
|
||||
let _ = reader.seek(SeekFrom::End(0));
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
match reader.read_line(&mut line) {
|
||||
Ok(0) => std::thread::sleep(Duration::from_millis(150)),
|
||||
Ok(_) => {
|
||||
if line.contains("playout-health:") {
|
||||
print!("{line}");
|
||||
}
|
||||
let mut frame = Vec::with_capacity(FRAME_SAMPLES * peerspeak::audio::PLAYBACK_CHANNELS);
|
||||
for _ in 0..FRAME_SAMPLES {
|
||||
let t = n as f32 / SAMPLE_RATE;
|
||||
// 0.25 amplitude: clearly audible but not harsh.
|
||||
let sample =
|
||||
(0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16;
|
||||
// Stereo playback bus: duplicate the probe tone to L/R.
|
||||
frame.push(sample);
|
||||
frame.push(sample);
|
||||
n += 1;
|
||||
}
|
||||
if tx.send(frame).is_err() {
|
||||
eprintln!("playback channel closed early");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Let the ring drain, then stop.
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
let _ = backend.stop();
|
||||
println!("\naudio_probe: done.");
|
||||
}
|
||||
|
||||
/// Open the app log, seek to the end, and echo new lines (the `playout-health:`
|
||||
/// reports) to stdout once they appear.
|
||||
fn spawn_log_tailer() {
|
||||
let path = peerspeak::log_file_path();
|
||||
std::thread::spawn(move || {
|
||||
// Wait for the file to exist (first log_msg creates it).
|
||||
let file = loop {
|
||||
if let Ok(f) = std::fs::File::open(&path) {
|
||||
break f;
|
||||
}
|
||||
Err(_) => std::thread::sleep(Duration::from_millis(150)),
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
};
|
||||
let mut reader = BufReader::new(file);
|
||||
let _ = reader.seek(SeekFrom::End(0));
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
match reader.read_line(&mut line) {
|
||||
Ok(0) => std::thread::sleep(Duration::from_millis(150)),
|
||||
Ok(_) => {
|
||||
if line.contains("playout-health:") {
|
||||
print!("{line}");
|
||||
}
|
||||
}
|
||||
Err(_) => std::thread::sleep(Duration::from_millis(150)),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod win_probe {
|
||||
use std::io::{BufRead, BufReader, Seek, SeekFrom};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use peerspeak::audio::AudioBackend;
|
||||
use peerspeak::audio::cpal_impl::CpalBackend;
|
||||
use peerspeak::core::jitter::FRAME_SAMPLES; // 960 mono frames = 20ms @ 48kHz
|
||||
|
||||
const SAMPLE_RATE: f32 = 48_000.0;
|
||||
|
||||
#[tokio::main]
|
||||
pub async fn run() {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let freq: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(440.0);
|
||||
let secs: u64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(30);
|
||||
let target_node: Option<String> = args.next();
|
||||
|
||||
// The playout-health logger is quiet in normal operation (it only logs
|
||||
// glitches); ask it for the full once-per-second heartbeat so the probe can
|
||||
// show the steady-state numbers.
|
||||
// SAFETY: set before any playback thread starts, so no concurrent env read.
|
||||
unsafe { std::env::set_var("PEERSPEAK_AUDIO_VERBOSE", "1") };
|
||||
|
||||
println!("audio_probe: {freq} Hz tone for {secs}s through the real playback path.");
|
||||
println!("Listen for clicks/pops; watch the playout-health lines below.\n");
|
||||
|
||||
// Tail the app log (where playout-health lines land) to stdout in the
|
||||
// background so it's all in one terminal.
|
||||
spawn_log_tailer();
|
||||
|
||||
let backend = CpalBackend::new();
|
||||
let (tx, rx) = mpsc::channel::<Vec<i16>>();
|
||||
let ring_fill = Arc::new(AtomicUsize::new(0));
|
||||
if let Err(e) = backend.start_playback(rx, target_node, ring_fill.clone()) {
|
||||
eprintln!("failed to start playback: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase-continuous sine, generated one 20ms frame at a time, fill-paced
|
||||
// exactly like the production mixer: only produce while the ring is below
|
||||
// target, so production tracks the cpal/WASAPI hardware clock.
|
||||
use std::sync::atomic::Ordering;
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(secs);
|
||||
let mut n: u64 = 0; // running sample index keeps phase continuous across frames
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
if ring_fill.load(Ordering::Relaxed) >= peerspeak::audio::PLAYBACK_TARGET_SAMPLES {
|
||||
tokio::time::sleep(Duration::from_millis(2)).await;
|
||||
continue;
|
||||
}
|
||||
let mut frame = Vec::with_capacity(FRAME_SAMPLES * peerspeak::audio::PLAYBACK_CHANNELS);
|
||||
for _ in 0..FRAME_SAMPLES {
|
||||
let t = n as f32 / SAMPLE_RATE;
|
||||
// 0.25 amplitude: clearly audible but not harsh.
|
||||
let sample =
|
||||
(0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16;
|
||||
// Stereo playback bus: duplicate the probe tone to L/R.
|
||||
frame.push(sample);
|
||||
frame.push(sample);
|
||||
n += 1;
|
||||
}
|
||||
if tx.send(frame).is_err() {
|
||||
eprintln!("playback channel closed early");
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Let the ring drain, then stop.
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
let _ = backend.stop();
|
||||
println!("\naudio_probe: done.");
|
||||
}
|
||||
|
||||
/// Open the app log, seek to the end, and echo new lines (the `playout-health:`
|
||||
/// reports) to stdout once they appear.
|
||||
fn spawn_log_tailer() {
|
||||
let path = peerspeak::log_file_path();
|
||||
std::thread::spawn(move || {
|
||||
// Wait for the file to exist (first log_msg creates it).
|
||||
let file = loop {
|
||||
if let Ok(f) = std::fs::File::open(&path) {
|
||||
break f;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
};
|
||||
let mut reader = BufReader::new(file);
|
||||
let _ = reader.seek(SeekFrom::End(0));
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
match reader.read_line(&mut line) {
|
||||
Ok(0) => std::thread::sleep(Duration::from_millis(150)),
|
||||
Ok(_) => {
|
||||
if line.contains("playout-health:") {
|
||||
print!("{line}");
|
||||
}
|
||||
}
|
||||
Err(_) => std::thread::sleep(Duration::from_millis(150)),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+27
-1
@@ -1,6 +1,7 @@
|
||||
use crate::notify::Sound;
|
||||
use crate::theme::AppTheme;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -232,6 +233,17 @@ pub struct AppConfig {
|
||||
/// capped (see `recents`). Defaulted empty so older configs upgrade cleanly.
|
||||
#[serde(default)]
|
||||
pub recents: Vec<crate::recents::Recent>,
|
||||
/// Per-peer listener-side EQ settings, keyed by peer node id string. Local
|
||||
/// preference only; never sent to peers.
|
||||
#[serde(default)]
|
||||
pub peer_eq: HashMap<String, crate::audio::eq::EqSettings>,
|
||||
/// Per-peer listener-side pan (`-1.0` left, `0.0` center, `1.0` right),
|
||||
/// keyed by peer node id string. Local preference only.
|
||||
#[serde(default)]
|
||||
pub peer_pan: HashMap<String, f32>,
|
||||
/// Focused app-local keyboard shortcuts.
|
||||
#[serde(default)]
|
||||
pub hotkeys: crate::hotkeys::HotkeyMap,
|
||||
/// Last window size (px), restored as the initial size on next launch.
|
||||
/// Saved on close.
|
||||
#[serde(default = "default_window_width")]
|
||||
@@ -287,6 +299,9 @@ impl Default for AppConfig {
|
||||
sound_reconnect_failed_enabled: true,
|
||||
pixelpass_path: None,
|
||||
recents: Vec::new(),
|
||||
peer_eq: HashMap::new(),
|
||||
peer_pan: HashMap::new(),
|
||||
hotkeys: crate::hotkeys::HotkeyMap::default(),
|
||||
window_width: default_window_width(),
|
||||
window_height: default_window_height(),
|
||||
window_x: None,
|
||||
@@ -411,6 +426,18 @@ mod tests {
|
||||
assert_eq!(deserialized.window_height, 760.0);
|
||||
// Configs predating the recents list load an empty list.
|
||||
assert!(deserialized.recents.is_empty());
|
||||
// Configs predating per-peer listener shaping load flat/center/default
|
||||
// shortcut settings.
|
||||
assert!(deserialized.peer_eq.is_empty());
|
||||
assert!(deserialized.peer_pan.is_empty());
|
||||
assert_eq!(
|
||||
crate::hotkeys::format_binding(
|
||||
deserialized
|
||||
.hotkeys
|
||||
.binding(crate::hotkeys::HotkeyAction::PushToTalk)
|
||||
),
|
||||
"Space"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -596,4 +623,3 @@ mod tests {
|
||||
assert_eq!(config.noise_gate_threshold, 0.01);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+62
-3
@@ -59,6 +59,10 @@ const PRIME_TIMEOUT_TICKS: usize = 25;
|
||||
/// badly behind, so we drop the oldest and resync rather than grow unbounded.
|
||||
const MAX_BUFFERED_FRAMES: usize = 32;
|
||||
|
||||
/// Sequence discontinuities larger than this (~10s at 20ms/frame) are treated
|
||||
/// as a restarted/new stream, not ordinary packet loss or reordering.
|
||||
const MAX_REASONABLE_SEQ_GAP: u32 = 500;
|
||||
|
||||
pub struct JitterBuffer {
|
||||
decoder: OpusDecoder,
|
||||
/// Reorder window: sequence number -> encoded Opus payload.
|
||||
@@ -116,6 +120,14 @@ impl JitterBuffer {
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_to_stream(&mut self, seq: u32, payload: Vec<u8>) {
|
||||
self.packets.clear();
|
||||
self.packets.insert(seq, payload);
|
||||
self.next_seq = None;
|
||||
self.clean_run = 0;
|
||||
self.buffering_ticks = 0;
|
||||
}
|
||||
|
||||
/// Store a received packet, dropping ones we've already played past and
|
||||
/// bounding total depth.
|
||||
pub fn insert(&mut self, seq: u32, payload: Vec<u8>) {
|
||||
@@ -124,9 +136,19 @@ impl JitterBuffer {
|
||||
if let Some(next) = self.next_seq
|
||||
&& seq_before(seq, next)
|
||||
{
|
||||
if next.wrapping_sub(seq) > MAX_REASONABLE_SEQ_GAP {
|
||||
self.reset_to_stream(seq, payload);
|
||||
return;
|
||||
}
|
||||
self.note_disruption();
|
||||
return;
|
||||
}
|
||||
if let Some(next) = self.next_seq
|
||||
&& seq.wrapping_sub(next) > MAX_REASONABLE_SEQ_GAP
|
||||
{
|
||||
self.reset_to_stream(seq, payload);
|
||||
return;
|
||||
}
|
||||
self.packets.insert(seq, payload);
|
||||
|
||||
while self.packets.len() > MAX_BUFFERED_FRAMES {
|
||||
@@ -283,6 +305,44 @@ mod tests {
|
||||
assert_eq!(jb.packets.len(), 1); // only seq 7 remains buffered
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn far_behind_sequence_resets_as_restarted_stream() {
|
||||
let mut jb = JitterBuffer::new().unwrap();
|
||||
jb.next_seq = Some(5_000);
|
||||
jb.packets.insert(5_000, vec![9]);
|
||||
jb.clean_run = 12;
|
||||
jb.buffering_ticks = 4;
|
||||
|
||||
jb.insert(0, vec![1]);
|
||||
|
||||
assert_eq!(jb.next_seq, None);
|
||||
assert_eq!(jb.packets.len(), 1);
|
||||
assert_eq!(jb.packets.get(&0).map(Vec::as_slice), Some(&[1][..]));
|
||||
assert_eq!(jb.clean_run, 0);
|
||||
assert_eq!(jb.buffering_ticks, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn far_ahead_sequence_resets_to_bound_plc_run() {
|
||||
let mut jb = JitterBuffer::new().unwrap();
|
||||
jb.next_seq = Some(10);
|
||||
jb.packets.insert(10, vec![9]);
|
||||
jb.clean_run = 12;
|
||||
jb.buffering_ticks = 4;
|
||||
|
||||
let jumped_seq = 10 + MAX_REASONABLE_SEQ_GAP + 1;
|
||||
jb.insert(jumped_seq, vec![2]);
|
||||
|
||||
assert_eq!(jb.next_seq, None);
|
||||
assert_eq!(jb.packets.len(), 1);
|
||||
assert_eq!(
|
||||
jb.packets.get(&jumped_seq).map(Vec::as_slice),
|
||||
Some(&[2][..])
|
||||
);
|
||||
assert_eq!(jb.clean_run, 0);
|
||||
assert_eq!(jb.buffering_ticks, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seq_before_ordering() {
|
||||
// Basic ordering
|
||||
@@ -358,7 +418,7 @@ mod tests {
|
||||
fn is_idle_reflects_buffer_state() {
|
||||
let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
|
||||
let mut jb = JitterBuffer::new().unwrap();
|
||||
|
||||
|
||||
// Fresh buffer
|
||||
assert!(jb.is_idle());
|
||||
|
||||
@@ -369,7 +429,7 @@ mod tests {
|
||||
// Prime (3 frames)
|
||||
jb.insert(1, frame(&mut enc, 1000));
|
||||
jb.insert(2, frame(&mut enc, 1000));
|
||||
|
||||
|
||||
// Drain past the end so it underruns
|
||||
assert!(jb.pop_frame().is_some());
|
||||
assert!(jb.pop_frame().is_some());
|
||||
@@ -635,4 +695,3 @@ mod tests {
|
||||
assert_eq!(jb.clean_run, 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
-5
@@ -11,6 +11,10 @@ pub enum CoreCommand {
|
||||
/// for a NEW room; it's ignored when joining (the label rides in the ticket).
|
||||
Join { name: String, ticket: String, room_name: String, input_device: Option<String>, output_device: Option<String>, echo_cancellation: bool, avatar: crate::avatar::Avatar },
|
||||
Leave,
|
||||
/// Orderly app shutdown: finalize recordings, leave any active room, stop local
|
||||
/// audio/screen-share work, close the persistent network stack, then ack with
|
||||
/// [`UiEvent::ShutdownComplete`].
|
||||
Shutdown,
|
||||
ToggleMute,
|
||||
/// Change our avatar (W4) and re-announce it to the room over presence.
|
||||
SetAvatar(crate::avatar::Avatar),
|
||||
@@ -18,6 +22,10 @@ pub enum CoreCommand {
|
||||
SetPttMode(bool),
|
||||
SetPttActive(bool),
|
||||
SetPeerVolume(EndpointId, f32),
|
||||
/// Listener-side per-peer EQ. Local only; never leaves this app instance.
|
||||
SetPeerEq(EndpointId, crate::audio::eq::EqSettings),
|
||||
/// Listener-side per-peer pan. Local only; never leaves this app instance.
|
||||
SetPeerPan(EndpointId, f32),
|
||||
/// Locally mute/unmute a peer: when muted, their audio is decoded (so levels
|
||||
/// still show) but not mixed into our output.
|
||||
SetPeerMuted(EndpointId, bool),
|
||||
@@ -77,6 +85,9 @@ pub enum UiEvent {
|
||||
RoomLeft,
|
||||
PeerJoined { id: EndpointId, state: PeerState },
|
||||
PeerLeft { id: EndpointId },
|
||||
/// The fixed reconnect grace expired and bounded background gossip recovery
|
||||
/// has started. This is non-terminal and must not play the failure chime.
|
||||
PeerRecoveryStarted { id: EndpointId },
|
||||
PeerConnectionFailed { id: EndpointId },
|
||||
PeerUpdated { id: EndpointId, state: PeerState },
|
||||
/// Audio link to a peer is being (re)established — show a connecting state.
|
||||
@@ -116,11 +127,12 @@ pub enum UiEvent {
|
||||
/// joinable gathering (with a one-click ticket). Emitted by the outbound ping
|
||||
/// scheduler; absence of a recent event = treat as offline.
|
||||
FriendPresence { id: EndpointId, presence: FriendPresence },
|
||||
/// The Discoverable time-box elapsed (W7 P6): the core auto-reverted our presence
|
||||
/// posture to the carried `mode` (always `Normal`) and stopped publishing. The
|
||||
/// GUI must mirror + persist this so its presence picker stops showing
|
||||
/// Discoverable. Distinct from a user-driven change so the GUI knows to update
|
||||
/// without having issued the command itself.
|
||||
/// Core corrected the committed presence posture. Usually the Discoverable
|
||||
/// time-box elapsed and the core auto-reverted to `Normal`; on discovery apply
|
||||
/// failure, this carries the previous truthful mode. The GUI must mirror +
|
||||
/// persist this so its presence picker matches the endpoint's discovery state.
|
||||
PresenceModeReverted { mode: PresenceMode },
|
||||
/// Core finished orderly app shutdown and the GUI can exit.
|
||||
ShutdownComplete,
|
||||
Error(String),
|
||||
}
|
||||
|
||||
+534
-93
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,264 @@
|
||||
use crate::network::{RoomState, gossip::IrohGossipState};
|
||||
use iroh::{EndpointAddr, EndpointId};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::Instant;
|
||||
|
||||
const RECOVERY_COMMAND_CAPACITY: usize = 64;
|
||||
const RECOVERY_DELAYS: [Duration; 7] = [
|
||||
Duration::from_secs(1),
|
||||
Duration::from_secs(2),
|
||||
Duration::from_secs(4),
|
||||
Duration::from_secs(8),
|
||||
Duration::from_secs(15),
|
||||
Duration::from_secs(30),
|
||||
Duration::from_secs(60),
|
||||
];
|
||||
|
||||
fn recovery_delay(attempt: usize) -> Duration {
|
||||
RECOVERY_DELAYS[attempt.min(RECOVERY_DELAYS.len() - 1)]
|
||||
}
|
||||
|
||||
enum RecoveryCommand {
|
||||
Start {
|
||||
peer_id: EndpointId,
|
||||
addr: EndpointAddr,
|
||||
},
|
||||
Cancel(EndpointId),
|
||||
}
|
||||
|
||||
struct RecoveryEntry {
|
||||
addr: EndpointAddr,
|
||||
attempt: usize,
|
||||
next_attempt: Instant,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
trait RecoveryRoom: Send + Sync {
|
||||
async fn rebootstrap_peers(&self, peers: Vec<EndpointAddr>) -> Result<(), String>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl RecoveryRoom for IrohGossipState {
|
||||
async fn rebootstrap_peers(&self, peers: Vec<EndpointAddr>) -> Result<(), String> {
|
||||
RoomState::rebootstrap_peers(self, peers)
|
||||
.await
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Cloneable command side of the single per-session recovery coordinator.
|
||||
/// `active` is shared with transport/event handlers so cancellation is visible
|
||||
/// immediately even while the coordinator is awaiting an in-flight gossip call.
|
||||
#[derive(Clone)]
|
||||
pub(super) struct RecoveryCoordinator {
|
||||
tx: mpsc::Sender<RecoveryCommand>,
|
||||
active: Arc<Mutex<HashSet<EndpointId>>>,
|
||||
}
|
||||
|
||||
impl RecoveryCoordinator {
|
||||
pub(super) fn spawn(room_state: Arc<IrohGossipState>) -> (Self, JoinHandle<()>) {
|
||||
Self::spawn_inner(room_state)
|
||||
}
|
||||
|
||||
fn spawn_inner(room_state: Arc<dyn RecoveryRoom>) -> (Self, JoinHandle<()>) {
|
||||
let (tx, rx) = mpsc::channel(RECOVERY_COMMAND_CAPACITY);
|
||||
let active = Arc::new(Mutex::new(HashSet::new()));
|
||||
let handle = Self {
|
||||
tx,
|
||||
active: active.clone(),
|
||||
};
|
||||
let task = tokio::spawn(run_coordinator(room_state, active, rx));
|
||||
(handle, task)
|
||||
}
|
||||
|
||||
/// Reserve one recovery slot before grace-expiry teardown begins. Returns
|
||||
/// false when the peer is already recovering, preventing duplicate work.
|
||||
pub(super) fn begin(&self, peer_id: EndpointId) -> bool {
|
||||
self.active.lock().unwrap().insert(peer_id)
|
||||
}
|
||||
|
||||
/// Activate the reserved slot with its retained authenticated address.
|
||||
/// Uses a bounded non-blocking send while holding the active-set lock so a
|
||||
/// concurrent cancellation is ordered before or after this command.
|
||||
pub(super) fn activate(&self, peer_id: EndpointId, addr: EndpointAddr) -> Result<bool, ()> {
|
||||
let mut active = self.active.lock().unwrap();
|
||||
if !active.contains(&peer_id) {
|
||||
return Ok(false);
|
||||
}
|
||||
if self
|
||||
.tx
|
||||
.try_send(RecoveryCommand::Start { peer_id, addr })
|
||||
.is_err()
|
||||
{
|
||||
active.remove(&peer_id);
|
||||
return Err(());
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(super) fn cancel(&self, peer_id: EndpointId) {
|
||||
self.active.lock().unwrap().remove(&peer_id);
|
||||
// Cancellation is governed by the shared active set, so it remains
|
||||
// immediate even if the bounded command queue is temporarily full.
|
||||
let _ = self.tx.try_send(RecoveryCommand::Cancel(peer_id));
|
||||
}
|
||||
|
||||
pub(super) fn is_active(&self, peer_id: &EndpointId) -> bool {
|
||||
self.active.lock().unwrap().contains(peer_id)
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_coordinator(
|
||||
room_state: Arc<dyn RecoveryRoom>,
|
||||
active: Arc<Mutex<HashSet<EndpointId>>>,
|
||||
mut rx: mpsc::Receiver<RecoveryCommand>,
|
||||
) {
|
||||
let mut entries: HashMap<EndpointId, RecoveryEntry> = HashMap::new();
|
||||
|
||||
loop {
|
||||
// The shared active set is the authoritative cancellation gate. Prune
|
||||
// here as well as on Cancel commands so a saturated command queue cannot
|
||||
// leave an inactive, past-due entry spinning the timer loop.
|
||||
let active_snapshot = active.lock().unwrap().clone();
|
||||
entries.retain(|peer_id, _| active_snapshot.contains(peer_id));
|
||||
let next_deadline = entries.values().map(|entry| entry.next_attempt).min();
|
||||
let command = match next_deadline {
|
||||
Some(deadline) => {
|
||||
tokio::select! {
|
||||
command = rx.recv() => command,
|
||||
_ = tokio::time::sleep_until(deadline) => {
|
||||
let now = Instant::now();
|
||||
let active_snapshot = active.lock().unwrap().clone();
|
||||
let due: Vec<(EndpointId, EndpointAddr)> = entries
|
||||
.iter()
|
||||
.filter(|(id, entry)| {
|
||||
entry.next_attempt <= now && active_snapshot.contains(*id)
|
||||
})
|
||||
.map(|(id, entry)| (*id, entry.addr.clone()))
|
||||
.collect();
|
||||
|
||||
if !due.is_empty() {
|
||||
let addrs = due.iter().map(|(_, addr)| addr.clone()).collect();
|
||||
if let Err(error) = room_state.rebootstrap_peers(addrs).await {
|
||||
crate::log_msg(&format!(
|
||||
"Background peer recovery attempt failed: {error}"
|
||||
));
|
||||
}
|
||||
|
||||
let scheduled_at = Instant::now();
|
||||
for (peer_id, _) in due {
|
||||
if !active.lock().unwrap().contains(&peer_id) {
|
||||
entries.remove(&peer_id);
|
||||
continue;
|
||||
}
|
||||
if let Some(entry) = entries.get_mut(&peer_id) {
|
||||
entry.next_attempt = scheduled_at + recovery_delay(entry.attempt);
|
||||
entry.attempt = entry.attempt.saturating_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
None => rx.recv().await,
|
||||
};
|
||||
|
||||
match command {
|
||||
Some(RecoveryCommand::Start { peer_id, addr }) => {
|
||||
if active.lock().unwrap().contains(&peer_id) {
|
||||
entries.entry(peer_id).or_insert(RecoveryEntry {
|
||||
addr,
|
||||
attempt: 0,
|
||||
next_attempt: Instant::now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(RecoveryCommand::Cancel(peer_id)) => {
|
||||
entries.remove(&peer_id);
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use iroh::SecretKey;
|
||||
|
||||
struct RecordingRoom {
|
||||
attempts: mpsc::UnboundedSender<Vec<EndpointAddr>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl RecoveryRoom for RecordingRoom {
|
||||
async fn rebootstrap_peers(&self, peers: Vec<EndpointAddr>) -> Result<(), String> {
|
||||
self.attempts.send(peers).map_err(|error| error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_backoff_reaches_and_stays_at_sixty_seconds() {
|
||||
let actual: Vec<u64> = (0..10)
|
||||
.map(|attempt| recovery_delay(attempt).as_secs())
|
||||
.collect();
|
||||
assert_eq!(actual, vec![1, 2, 4, 8, 15, 30, 60, 60, 60, 60]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_slots_are_deduplicated_and_cancel_immediately() {
|
||||
let (tx, mut rx) = mpsc::channel(4);
|
||||
let coordinator = RecoveryCoordinator {
|
||||
tx,
|
||||
active: Arc::new(Mutex::new(HashSet::new())),
|
||||
};
|
||||
let peer_id = SecretKey::generate().public();
|
||||
|
||||
assert!(coordinator.begin(peer_id));
|
||||
assert!(
|
||||
!coordinator.begin(peer_id),
|
||||
"a peer gets only one recovery slot"
|
||||
);
|
||||
assert_eq!(
|
||||
coordinator.activate(peer_id, EndpointAddr::from(peer_id)),
|
||||
Ok(true)
|
||||
);
|
||||
assert!(matches!(
|
||||
rx.try_recv(),
|
||||
Ok(RecoveryCommand::Start { peer_id: id, .. }) if id == peer_id
|
||||
));
|
||||
|
||||
coordinator.cancel(peer_id);
|
||||
assert!(!coordinator.is_active(&peer_id));
|
||||
assert!(matches!(
|
||||
rx.try_recv(),
|
||||
Ok(RecoveryCommand::Cancel(id)) if id == peer_id
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn coordinator_attempts_rebootstrap_immediately() {
|
||||
let (attempts_tx, mut attempts_rx) = mpsc::unbounded_channel();
|
||||
let (coordinator, task) = RecoveryCoordinator::spawn_inner(Arc::new(RecordingRoom {
|
||||
attempts: attempts_tx,
|
||||
}));
|
||||
let peer_id = SecretKey::generate().public();
|
||||
let addr = EndpointAddr::from(peer_id);
|
||||
|
||||
assert!(coordinator.begin(peer_id));
|
||||
assert_eq!(coordinator.activate(peer_id, addr.clone()), Ok(true));
|
||||
let attempted = tokio::time::timeout(Duration::from_secs(1), attempts_rx.recv())
|
||||
.await
|
||||
.expect("first recovery attempt should be immediate")
|
||||
.expect("recording room remains subscribed");
|
||||
assert_eq!(attempted, vec![addr]);
|
||||
|
||||
coordinator.cancel(peer_id);
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
+96
-10
@@ -8,14 +8,19 @@
|
||||
//! The model (from `docs/contacts-plan.md` P6, decided 2026-06-16):
|
||||
//! - **Resolving is always allowed on relay-capable modes** — a stationary friend
|
||||
//! (typically in `Normal`) must be able to look up a friend who moved networks. A
|
||||
//! resolve is a DNS query to n0 that publishes nothing; it only fires when a saved
|
||||
//! address is stale and the dial falls through to discovery.
|
||||
//! resolve is a DNS query to n0 that publishes nothing, but still exposes query
|
||||
//! timing/source metadata to n0; it only fires when a saved address is stale and
|
||||
//! the dial falls through to discovery.
|
||||
//! - **Publishing is gated on `Discoverable`** and asymmetric: only the mover
|
||||
//! publishes their address to n0 DNS; everyone else just looks it up.
|
||||
//! - **Stopping publishing removes the local publisher service**; iroh does not
|
||||
//! expose an explicit unpublish call here, so already-published pkarr records can
|
||||
//! linger until their default ~30s TTL expires.
|
||||
//! - **`DirectOnly` is the explicit no-server posture** — neither resolve nor publish
|
||||
//! ever touches n0 there, regardless of the Discoverable toggle.
|
||||
|
||||
use crate::config::NetworkMode;
|
||||
use crate::presence::PresenceMode;
|
||||
use std::time::Duration;
|
||||
|
||||
/// How long `Discoverable` stays on before auto-reverting to `Normal`. Discovery is
|
||||
@@ -46,12 +51,43 @@ pub fn lookup_plan(network_mode: NetworkMode, want_publish: bool) -> LookupPlan
|
||||
match network_mode {
|
||||
// The explicit serverless posture: no n0 contact at all, even to resolve.
|
||||
// A Discoverable toggle here is intentionally inert.
|
||||
NetworkMode::DirectOnly => LookupPlan { resolver: false, publisher: false },
|
||||
NetworkMode::DirectOnly => LookupPlan {
|
||||
resolver: false,
|
||||
publisher: false,
|
||||
},
|
||||
// Relay-capable: always resolve (so a stationary friend can find a mover);
|
||||
// publish only when the user opted into Discoverable.
|
||||
NetworkMode::RelayNoDiscovery | NetworkMode::N0Full => {
|
||||
LookupPlan { resolver: true, publisher: want_publish }
|
||||
}
|
||||
NetworkMode::RelayNoDiscovery | NetworkMode::N0Full => LookupPlan {
|
||||
resolver: true,
|
||||
publisher: want_publish,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide which presence mode may be committed after attempting to apply discovery
|
||||
/// services for `requested`.
|
||||
///
|
||||
/// On failure, keep the previous mode: it is the only locally truthful state because
|
||||
/// the endpoint's discovery services may still reflect the old posture. Same-mode
|
||||
/// requests are no-ops from a presence-truth perspective and do not surface an error.
|
||||
pub fn resolve_presence_transition(
|
||||
previous: PresenceMode,
|
||||
requested: PresenceMode,
|
||||
apply_ok: bool,
|
||||
) -> (PresenceMode, Option<String>) {
|
||||
if previous == requested {
|
||||
return (previous, None);
|
||||
}
|
||||
|
||||
if apply_ok {
|
||||
(requested, None)
|
||||
} else {
|
||||
(
|
||||
previous,
|
||||
Some(format!(
|
||||
"Couldn't update discovery mode; keeping {previous}."
|
||||
)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,12 +100,18 @@ mod tests {
|
||||
for mode in [NetworkMode::RelayNoDiscovery, NetworkMode::N0Full] {
|
||||
assert_eq!(
|
||||
lookup_plan(mode, false),
|
||||
LookupPlan { resolver: true, publisher: false },
|
||||
LookupPlan {
|
||||
resolver: true,
|
||||
publisher: false
|
||||
},
|
||||
"{mode:?}: resolve always on, no publish when not Discoverable"
|
||||
);
|
||||
assert_eq!(
|
||||
lookup_plan(mode, true),
|
||||
LookupPlan { resolver: true, publisher: true },
|
||||
LookupPlan {
|
||||
resolver: true,
|
||||
publisher: true
|
||||
},
|
||||
"{mode:?}: Discoverable adds publish on top of resolve"
|
||||
);
|
||||
}
|
||||
@@ -79,12 +121,18 @@ mod tests {
|
||||
fn direct_only_never_touches_n0_even_when_discoverable() {
|
||||
assert_eq!(
|
||||
lookup_plan(NetworkMode::DirectOnly, false),
|
||||
LookupPlan { resolver: false, publisher: false }
|
||||
LookupPlan {
|
||||
resolver: false,
|
||||
publisher: false
|
||||
}
|
||||
);
|
||||
// The serverless posture overrides the Discoverable request entirely.
|
||||
assert_eq!(
|
||||
lookup_plan(NetworkMode::DirectOnly, true),
|
||||
LookupPlan { resolver: false, publisher: false }
|
||||
LookupPlan {
|
||||
resolver: false,
|
||||
publisher: false
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,4 +140,42 @@ mod tests {
|
||||
fn timebox_is_thirty_minutes() {
|
||||
assert_eq!(DISCOVERY_TIMEBOX, Duration::from_secs(1800));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_transition_commits_requested_mode_after_successful_apply() {
|
||||
assert_eq!(
|
||||
resolve_presence_transition(PresenceMode::Normal, PresenceMode::Discoverable, true),
|
||||
(PresenceMode::Discoverable, None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_transition_keeps_previous_mode_when_apply_fails() {
|
||||
let (mode, err) =
|
||||
resolve_presence_transition(PresenceMode::Normal, PresenceMode::Discoverable, false);
|
||||
|
||||
assert_eq!(mode, PresenceMode::Normal);
|
||||
assert!(err.unwrap().contains("keeping Normal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_transition_keeps_discoverable_when_off_transition_fails() {
|
||||
let (mode, err) =
|
||||
resolve_presence_transition(PresenceMode::Discoverable, PresenceMode::Normal, false);
|
||||
|
||||
assert_eq!(mode, PresenceMode::Discoverable);
|
||||
assert!(err.unwrap().contains("keeping Discoverable"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_transition_same_mode_is_noop_without_error() {
|
||||
assert_eq!(
|
||||
resolve_presence_transition(
|
||||
PresenceMode::Discoverable,
|
||||
PresenceMode::Discoverable,
|
||||
false
|
||||
),
|
||||
(PresenceMode::Discoverable, None)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
//! Focused, app-local keyboard shortcuts.
|
||||
//!
|
||||
//! These helpers are intentionally pure: key serialization, formatting, lookup,
|
||||
//! and conflict detection live here, while iced event handling stays at the app
|
||||
//! edge. There are no OS-global shortcuts.
|
||||
|
||||
use iced::keyboard;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A serializable key identity. Modifiers are deliberately out of scope for this
|
||||
/// first pass; iced delivers the focused app key and we compare that exact key.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum KeyBinding {
|
||||
Named(String),
|
||||
Character(String),
|
||||
}
|
||||
|
||||
impl KeyBinding {
|
||||
pub fn from_key(key: &keyboard::Key) -> Option<Self> {
|
||||
match key {
|
||||
keyboard::Key::Named(named) => Some(Self::Named(format!("{named:?}"))),
|
||||
keyboard::Key::Character(ch) => {
|
||||
let s = ch.to_string();
|
||||
if s.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Self::Character(s.to_lowercase()))
|
||||
}
|
||||
}
|
||||
keyboard::Key::Unidentified => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
KeyBinding::Named(name) => name.clone(),
|
||||
KeyBinding::Character(ch) => ch.to_uppercase(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a hand-editable binding string from config/docs/tests. Empty and
|
||||
/// `"unset"` are unbound.
|
||||
pub fn parse_binding(input: &str) -> Option<KeyBinding> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("unset") {
|
||||
return None;
|
||||
}
|
||||
if trimmed.chars().count() == 1 {
|
||||
Some(KeyBinding::Character(trimmed.to_lowercase()))
|
||||
} else {
|
||||
Some(KeyBinding::Named(trimmed.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_binding(binding: Option<&KeyBinding>) -> String {
|
||||
binding
|
||||
.map(KeyBinding::label)
|
||||
.unwrap_or_else(|| "unset".to_string())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum HotkeyAction {
|
||||
ToggleMute,
|
||||
ToggleDeafen,
|
||||
OpenSettings,
|
||||
PushToTalk,
|
||||
LeaveRoom,
|
||||
}
|
||||
|
||||
impl HotkeyAction {
|
||||
pub const ALL: [HotkeyAction; 5] = [
|
||||
HotkeyAction::ToggleMute,
|
||||
HotkeyAction::ToggleDeafen,
|
||||
HotkeyAction::OpenSettings,
|
||||
HotkeyAction::PushToTalk,
|
||||
HotkeyAction::LeaveRoom,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
HotkeyAction::ToggleMute => "Toggle mute",
|
||||
HotkeyAction::ToggleDeafen => "Toggle deafen",
|
||||
HotkeyAction::OpenSettings => "Open Settings",
|
||||
HotkeyAction::PushToTalk => "Push-to-talk",
|
||||
HotkeyAction::LeaveRoom => "Leave room",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tier(self) -> HotkeyTier {
|
||||
match self {
|
||||
HotkeyAction::ToggleMute
|
||||
| HotkeyAction::ToggleDeafen
|
||||
| HotkeyAction::OpenSettings => HotkeyTier::AppWide,
|
||||
HotkeyAction::PushToTalk | HotkeyAction::LeaveRoom => HotkeyTier::RoomOnly,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HotkeyTier {
|
||||
AppWide,
|
||||
RoomOnly,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct HotkeyContext {
|
||||
pub in_call: bool,
|
||||
}
|
||||
|
||||
impl HotkeyContext {
|
||||
fn allows(self, action: HotkeyAction) -> bool {
|
||||
matches!(action.tier(), HotkeyTier::AppWide) || self.in_call
|
||||
}
|
||||
}
|
||||
|
||||
/// Persisted shortcut map. Defaults preserve the old Space push-to-talk binding
|
||||
/// and add a few function-key app shortcuts that do not collide with typing.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct HotkeyMap {
|
||||
#[serde(default = "default_mute")]
|
||||
pub toggle_mute: Option<KeyBinding>,
|
||||
#[serde(default = "default_deafen")]
|
||||
pub toggle_deafen: Option<KeyBinding>,
|
||||
#[serde(default = "default_settings")]
|
||||
pub open_settings: Option<KeyBinding>,
|
||||
#[serde(default = "default_ptt")]
|
||||
pub push_to_talk: Option<KeyBinding>,
|
||||
#[serde(default)]
|
||||
pub leave_room: Option<KeyBinding>,
|
||||
}
|
||||
|
||||
impl Default for HotkeyMap {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
toggle_mute: default_mute(),
|
||||
toggle_deafen: default_deafen(),
|
||||
open_settings: default_settings(),
|
||||
push_to_talk: default_ptt(),
|
||||
leave_room: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn named(name: &str) -> Option<KeyBinding> {
|
||||
Some(KeyBinding::Named(name.to_string()))
|
||||
}
|
||||
|
||||
fn default_mute() -> Option<KeyBinding> {
|
||||
named("F9")
|
||||
}
|
||||
|
||||
fn default_deafen() -> Option<KeyBinding> {
|
||||
named("F10")
|
||||
}
|
||||
|
||||
fn default_settings() -> Option<KeyBinding> {
|
||||
named("F2")
|
||||
}
|
||||
|
||||
fn default_ptt() -> Option<KeyBinding> {
|
||||
named("Space")
|
||||
}
|
||||
|
||||
impl HotkeyMap {
|
||||
pub fn binding(&self, action: HotkeyAction) -> Option<&KeyBinding> {
|
||||
match action {
|
||||
HotkeyAction::ToggleMute => self.toggle_mute.as_ref(),
|
||||
HotkeyAction::ToggleDeafen => self.toggle_deafen.as_ref(),
|
||||
HotkeyAction::OpenSettings => self.open_settings.as_ref(),
|
||||
HotkeyAction::PushToTalk => self.push_to_talk.as_ref(),
|
||||
HotkeyAction::LeaveRoom => self.leave_room.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_binding(&mut self, action: HotkeyAction, binding: Option<KeyBinding>) {
|
||||
match action {
|
||||
HotkeyAction::ToggleMute => self.toggle_mute = binding,
|
||||
HotkeyAction::ToggleDeafen => self.toggle_deafen = binding,
|
||||
HotkeyAction::OpenSettings => self.open_settings = binding,
|
||||
HotkeyAction::PushToTalk => self.push_to_talk = binding,
|
||||
HotkeyAction::LeaveRoom => self.leave_room = binding,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn lookup_key(&self, key: &keyboard::Key, context: HotkeyContext) -> Option<HotkeyAction> {
|
||||
let pressed = KeyBinding::from_key(key)?;
|
||||
HotkeyAction::ALL
|
||||
.into_iter()
|
||||
.find(|&action| context.allows(action) && self.binding(action) == Some(&pressed))
|
||||
}
|
||||
|
||||
pub fn lookup_binding(
|
||||
&self,
|
||||
binding: &KeyBinding,
|
||||
context: HotkeyContext,
|
||||
) -> Option<HotkeyAction> {
|
||||
HotkeyAction::ALL
|
||||
.into_iter()
|
||||
.find(|&action| context.allows(action) && self.binding(action) == Some(binding))
|
||||
}
|
||||
|
||||
pub fn conflicts(&self) -> Vec<HotkeyConflict> {
|
||||
let mut conflicts = Vec::new();
|
||||
let actions = HotkeyAction::ALL;
|
||||
for i in 0..actions.len() {
|
||||
for j in (i + 1)..actions.len() {
|
||||
let a = actions[i];
|
||||
let b = actions[j];
|
||||
if let (Some(ab), Some(bb)) = (self.binding(a), self.binding(b))
|
||||
&& ab == bb
|
||||
{
|
||||
conflicts.push(HotkeyConflict {
|
||||
binding: ab.clone(),
|
||||
first: a,
|
||||
second: b,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
conflicts
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct HotkeyConflict {
|
||||
pub binding: KeyBinding,
|
||||
pub first: HotkeyAction,
|
||||
pub second: HotkeyAction,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unset_actions_format_as_unset() {
|
||||
assert_eq!(format_binding(None), "unset");
|
||||
assert_eq!(parse_binding("unset"), None);
|
||||
assert_eq!(parse_binding(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_binding_is_detected() {
|
||||
let mut map = HotkeyMap::default();
|
||||
map.set_binding(HotkeyAction::ToggleMute, parse_binding("M"));
|
||||
map.set_binding(HotkeyAction::ToggleDeafen, parse_binding("m"));
|
||||
let conflicts = map.conflicts();
|
||||
assert_eq!(conflicts.len(), 1);
|
||||
assert_eq!(conflicts[0].first, HotkeyAction::ToggleMute);
|
||||
assert_eq!(conflicts[0].second, HotkeyAction::ToggleDeafen);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_respects_room_tier() {
|
||||
let mut map = HotkeyMap::default();
|
||||
map.set_binding(HotkeyAction::LeaveRoom, parse_binding("Escape"));
|
||||
let binding = parse_binding("Escape").unwrap();
|
||||
assert_eq!(
|
||||
map.lookup_binding(&binding, HotkeyContext { in_call: false }),
|
||||
None,
|
||||
"room-only shortcuts should not fire outside a call"
|
||||
);
|
||||
assert_eq!(
|
||||
map.lookup_binding(&binding, HotkeyContext { in_call: true }),
|
||||
Some(HotkeyAction::LeaveRoom)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_ptt_is_space() {
|
||||
let map = HotkeyMap::default();
|
||||
assert_eq!(
|
||||
format_binding(map.binding(HotkeyAction::PushToTalk)),
|
||||
"Space"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_single_character_case_folds() {
|
||||
assert_eq!(parse_binding("M"), Some(KeyBinding::Character("m".to_string())));
|
||||
assert_eq!(format_binding(parse_binding("m").as_ref()), "M");
|
||||
}
|
||||
}
|
||||
+135
-6
@@ -2,6 +2,7 @@ pub mod audio;
|
||||
pub mod codec;
|
||||
pub mod dsp;
|
||||
pub mod network;
|
||||
pub mod protocol;
|
||||
pub mod core;
|
||||
pub mod app;
|
||||
pub mod config;
|
||||
@@ -16,10 +17,18 @@ pub mod sanitize;
|
||||
pub mod avatar;
|
||||
pub mod recents;
|
||||
pub mod discovery;
|
||||
pub mod hotkeys;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::fs::File;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
const LOG_MAX_BYTES: u64 = 5 * 1024 * 1024;
|
||||
// Owner-only log permissions are a Unix concept (mode bits); on Windows the log
|
||||
// inherits the directory's default ACL. Only referenced under `cfg(unix)`.
|
||||
#[cfg(unix)]
|
||||
const LOG_MODE: u32 = 0o600;
|
||||
|
||||
/// Resolves the log file path once: `$XDG_STATE_HOME/peerspeak/peerspeak.log`
|
||||
/// (via `dirs::state_dir`), falling back to the system temp dir. Computed lazily
|
||||
/// so we never hardcode a per-user path.
|
||||
@@ -42,6 +51,74 @@ pub fn log_file_path() -> PathBuf {
|
||||
log_path().clone()
|
||||
}
|
||||
|
||||
/// Short, human-matchable id prefix for diagnostics. Never use this where the
|
||||
/// full value is needed for protocol behavior.
|
||||
pub fn short_id(id: &str) -> String {
|
||||
id.chars().take(8).collect()
|
||||
}
|
||||
|
||||
/// Redact a capability-bearing value for logs while keeping a tiny prefix for
|
||||
/// support correlation. Tickets and endpoint addresses are bearer capabilities:
|
||||
/// logging the full string is equivalent to leaking the room/share.
|
||||
pub fn redact_for_log(value: &str) -> String {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
"<redacted:empty>".to_string()
|
||||
} else {
|
||||
format!("<redacted:{}...>", short_id(value))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn short_bytes_hex(bytes: &[u8]) -> String {
|
||||
bytes.iter()
|
||||
.take(6)
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
}
|
||||
|
||||
fn rotated_log_path(path: &Path) -> PathBuf {
|
||||
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("peerspeak.log");
|
||||
path.with_file_name(format!("{file_name}.1"))
|
||||
}
|
||||
|
||||
fn prepare_log_file(path: &Path) -> std::io::Result<File> {
|
||||
prepare_log_file_with_limit(path, LOG_MAX_BYTES)
|
||||
}
|
||||
|
||||
fn prepare_log_file_with_limit(path: &Path, max_bytes: u64) -> std::io::Result<File> {
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
if std::fs::metadata(path).is_ok_and(|m| m.len() > max_bytes) {
|
||||
let rotated = rotated_log_path(path);
|
||||
let _ = std::fs::remove_file(&rotated);
|
||||
if std::fs::rename(path, &rotated).is_err() {
|
||||
let _ = std::fs::OpenOptions::new().write(true).truncate(true).open(path);
|
||||
}
|
||||
}
|
||||
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.create(true).append(true);
|
||||
// The log can carry capability-bearing values (redacted, but still): keep it
|
||||
// owner-only on Unix via the open mode. Windows has no mode bits; it inherits
|
||||
// the directory ACL, so this hardening is Unix-only.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
opts.mode(LOG_MODE);
|
||||
}
|
||||
let file = opts.open(path)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
// Re-assert the mode in case the file pre-existed with looser perms.
|
||||
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(LOG_MODE));
|
||||
}
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
pub fn log_msg(msg: &str) {
|
||||
// Format the whole line into one buffer first, then emit it with a single
|
||||
// `write_all`. The file is opened with `O_APPEND`, so a lone `write()` is
|
||||
@@ -51,13 +128,65 @@ pub fn log_msg(msg: &str) {
|
||||
Ok(time) => format!("[{}.{:03}] {}\n", time.as_secs(), time.subsec_millis(), msg),
|
||||
Err(_) => format!("{}\n", msg),
|
||||
};
|
||||
if let Ok(mut file) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(log_path())
|
||||
{
|
||||
if let Ok(mut file) = prepare_log_file(log_path()) {
|
||||
use std::io::Write;
|
||||
let _ = file.write_all(line.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
fn temp_log_dir() -> PathBuf {
|
||||
let stamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("peerspeak-log-test-{}-{stamp}", std::process::id()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redaction_keeps_only_a_short_prefix() {
|
||||
let secret = "abcdefghijklmnopqrstuvwxyz";
|
||||
let redacted = redact_for_log(secret);
|
||||
assert!(redacted.contains("abcdefgh"));
|
||||
assert!(!redacted.contains("ijklmnopqrstuvwxyz"));
|
||||
assert_eq!(redact_for_log(" "), "<redacted:empty>");
|
||||
}
|
||||
|
||||
// Owner-only log perms are a Unix concept; on Windows the file inherits the
|
||||
// directory ACL and there's no mode to assert.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn log_file_is_created_private() {
|
||||
let dir = temp_log_dir();
|
||||
let path = dir.join("peerspeak.log");
|
||||
let _file = prepare_log_file(&path).unwrap();
|
||||
|
||||
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, LOG_MODE);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_log_is_rotated_on_open() {
|
||||
let dir = temp_log_dir();
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("peerspeak.log");
|
||||
{
|
||||
let mut file = std::fs::File::create(&path).unwrap();
|
||||
file.write_all(b"oversized").unwrap();
|
||||
}
|
||||
|
||||
let _file = prepare_log_file_with_limit(&path, 4).unwrap();
|
||||
let rotated = rotated_log_path(&path);
|
||||
|
||||
assert_eq!(std::fs::read_to_string(rotated).unwrap(), "oversized");
|
||||
assert_eq!(std::fs::metadata(&path).unwrap().len(), 0);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
+226
-17
@@ -5,14 +5,14 @@ use iroh_gossip::proto::TopicId;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use async_trait::async_trait;
|
||||
use tokio_stream::StreamExt;
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
/// Domain-separation tag mixed into every signed gossip payload so a signature
|
||||
/// can never be lifted out of this protocol/version into another context.
|
||||
const GOSSIP_SIG_DOMAIN: &str = "peerspeak-gossip-v1";
|
||||
use crate::protocol::GOSSIP_SIG_DOMAIN;
|
||||
|
||||
/// How far a payload's sender-stamped timestamp may differ from local time
|
||||
/// before it's rejected as stale (replayed) or implausibly future. Bounds the
|
||||
@@ -43,7 +43,7 @@ impl std::fmt::Debug for GossipPayload {
|
||||
f.debug_struct("GossipPayload")
|
||||
.field("author", &self.author)
|
||||
.field("ts", &self.ts)
|
||||
.field("msg", &self.msg)
|
||||
.field("msg_kind", &gossip_message_kind(&self.msg))
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,58 @@ enum GossipReject {
|
||||
BadSignature,
|
||||
/// Timestamp outside the freshness window — stale (replay) or implausibly future.
|
||||
OutOfWindow,
|
||||
/// A signed Announce advertised an address for a different node id.
|
||||
AnnounceAddressMismatch,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
enum StateMutationKind {
|
||||
Announce,
|
||||
Leave,
|
||||
}
|
||||
|
||||
fn gossip_message_kind(msg: &GossipMessage) -> &'static str {
|
||||
match msg {
|
||||
GossipMessage::Announce(_) => "Announce",
|
||||
GossipMessage::Leave => "Leave",
|
||||
GossipMessage::Chat { .. } => "Chat",
|
||||
}
|
||||
}
|
||||
|
||||
fn state_mutation_kind(msg: &GossipMessage) -> Option<StateMutationKind> {
|
||||
match msg {
|
||||
GossipMessage::Announce(_) => Some(StateMutationKind::Announce),
|
||||
GossipMessage::Leave => Some(StateMutationKind::Leave),
|
||||
GossipMessage::Chat { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn admit_state_mutation(
|
||||
seen: &mut HashMap<(EndpointId, StateMutationKind), u64>,
|
||||
author: EndpointId,
|
||||
msg: &GossipMessage,
|
||||
ts: u64,
|
||||
) -> bool {
|
||||
let Some(kind) = state_mutation_kind(msg) else {
|
||||
return true;
|
||||
};
|
||||
let key = (author, kind);
|
||||
if seen.get(&key).is_some_and(|last_ts| ts <= *last_ts) {
|
||||
return false;
|
||||
}
|
||||
seen.insert(key, ts);
|
||||
true
|
||||
}
|
||||
|
||||
fn peer_state_for_log(state: &PeerState) -> String {
|
||||
format!(
|
||||
"name={:?}, muted={}, addr_id={}, addrs={}, sharing={}",
|
||||
state.name,
|
||||
state.is_muted,
|
||||
crate::short_id(&state.addr.id.to_string()),
|
||||
state.addr.addrs.len(),
|
||||
state.sharing.is_some()
|
||||
)
|
||||
}
|
||||
|
||||
/// Authenticate a received payload against the room topic and local clock. The
|
||||
@@ -99,6 +151,10 @@ fn verify_gossip(
|
||||
if now_ms.abs_diff(payload.ts) > window_ms {
|
||||
return Err(GossipReject::OutOfWindow);
|
||||
}
|
||||
if let GossipMessage::Announce(state) = &payload.msg
|
||||
&& state.addr.id != payload.author {
|
||||
return Err(GossipReject::AnnounceAddressMismatch);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -142,6 +198,10 @@ pub struct IrohGossipState {
|
||||
secret_key: SecretKey,
|
||||
self_state: Arc<Mutex<Option<PeerState>>>,
|
||||
peers: Arc<Mutex<HashMap<EndpointId, PeerState>>>,
|
||||
/// Previously verified peers whose live roster entry was removed by a
|
||||
/// transient disconnect. Retained only so a later authenticated `Leave`
|
||||
/// still reaches core and cancels background recovery.
|
||||
disconnected_peers: Arc<Mutex<HashSet<EndpointId>>>,
|
||||
event_tx: mpsc::Sender<RoomEvent>,
|
||||
event_rx: Mutex<Option<mpsc::Receiver<RoomEvent>>>,
|
||||
active_topic: Mutex<Option<tokio::task::JoinHandle<()>>>,
|
||||
@@ -167,6 +227,7 @@ impl IrohGossipState {
|
||||
secret_key,
|
||||
self_state: Arc::new(Mutex::new(None)),
|
||||
peers: Arc::new(Mutex::new(HashMap::new())),
|
||||
disconnected_peers: Arc::new(Mutex::new(HashSet::new())),
|
||||
event_tx,
|
||||
event_rx: Mutex::new(Some(event_rx)),
|
||||
active_topic: Mutex::new(None),
|
||||
@@ -185,11 +246,25 @@ impl RoomState for IrohGossipState {
|
||||
self_state: PeerState,
|
||||
extra_bootstrap: Vec<EndpointAddr>,
|
||||
) -> Result<(), NetError> {
|
||||
crate::log_msg(&format!("RoomState::join: self_id={:?}, self_name={:?}, ticket={}", self_state.addr.id, self_state.name, ticket_str));
|
||||
crate::log_msg(&format!(
|
||||
"RoomState::join: self_id={}, self_name={:?}, ticket={}",
|
||||
crate::short_id(&self_state.addr.id.to_string()),
|
||||
self_state.name,
|
||||
crate::redact_for_log(ticket_str)
|
||||
));
|
||||
let ticket = ticket_str.parse::<PeerSpeakTicket>()?;
|
||||
let topic_id = TopicId::from_bytes(ticket.topic_id);
|
||||
// Version-namespace the subscribed topic (VERSIONING.md): peers on a
|
||||
// different gossip protocol version derive a different topic from the same
|
||||
// ticket and never share a swarm. The raw ticket.topic_id stays the room
|
||||
// identity (and what signatures bind, below).
|
||||
let topic_id = TopicId::from_bytes(crate::protocol::versioned_topic(ticket.topic_id));
|
||||
|
||||
crate::log_msg(&format!("Parsed ticket. host_id={:?}, host_addrs={:?}, topic={:?}", ticket.host_addr.id, ticket.host_addr.addrs, topic_id));
|
||||
crate::log_msg(&format!(
|
||||
"Parsed ticket. host_id={}, host_addrs={}, topic={}",
|
||||
crate::short_id(&ticket.host_addr.id.to_string()),
|
||||
ticket.host_addr.addrs.len(),
|
||||
crate::short_bytes_hex(&ticket.topic_id)
|
||||
));
|
||||
|
||||
// Stop any currently running topic
|
||||
let _ = self.leave().await;
|
||||
@@ -225,6 +300,7 @@ impl RoomState for IrohGossipState {
|
||||
|
||||
let event_tx = self.event_tx.clone();
|
||||
let peers = self.peers.clone();
|
||||
let disconnected_peers = self.disconnected_peers.clone();
|
||||
let address_lookup = self.address_lookup.clone();
|
||||
let self_state_clone = self.self_state.clone();
|
||||
let gossip_sender_clone = gossip_sender.clone();
|
||||
@@ -236,6 +312,7 @@ impl RoomState for IrohGossipState {
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
crate::log_msg(&format!("Spawned gossip topic loop for self_id={:?}", self_id));
|
||||
let mut state_mutations_seen = HashMap::new();
|
||||
|
||||
// Broadcast initial state
|
||||
let initial_payload = {
|
||||
@@ -285,7 +362,26 @@ impl RoomState for IrohGossipState {
|
||||
continue;
|
||||
}
|
||||
|
||||
crate::log_msg(&format!("Gossip Event::Received from author={:?}, payload={:?}", payload.author, payload.msg));
|
||||
if !admit_state_mutation(
|
||||
&mut state_mutations_seen,
|
||||
payload.author,
|
||||
&payload.msg,
|
||||
payload.ts,
|
||||
) {
|
||||
crate::log_msg(&format!(
|
||||
"Gossip dropped replayed state mutation author={}, kind={}, ts={}",
|
||||
crate::short_id(&payload.author.to_string()),
|
||||
gossip_message_kind(&payload.msg),
|
||||
payload.ts
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
crate::log_msg(&format!(
|
||||
"Gossip Event::Received author={}, kind={}",
|
||||
crate::short_id(&payload.author.to_string()),
|
||||
gossip_message_kind(&payload.msg)
|
||||
));
|
||||
|
||||
match payload.msg {
|
||||
GossipMessage::Announce(mut state) => {
|
||||
@@ -299,6 +395,11 @@ impl RoomState for IrohGossipState {
|
||||
// monogram, so a malformed/oversized/bomb
|
||||
// image can't crash or exhaust us (W4).
|
||||
state.avatar = state.avatar.sanitize_incoming();
|
||||
// Screen-share tickets are capabilities and
|
||||
// peer-supplied: cap/validate once at ingest
|
||||
// so invalid offers never render a Watch button.
|
||||
state.sharing = state.sharing.and_then(crate::screenshare::sanitize_ticket);
|
||||
disconnected_peers.lock().unwrap().remove(&payload.author);
|
||||
let (is_new, state_changed) = {
|
||||
let mut peer_map = peers.lock().unwrap();
|
||||
let is_new = !peer_map.contains_key(&payload.author);
|
||||
@@ -310,18 +411,30 @@ impl RoomState for IrohGossipState {
|
||||
};
|
||||
|
||||
if is_new {
|
||||
crate::log_msg(&format!("Gossip new peer joined: {:?}, state: {:?}", payload.author, state));
|
||||
crate::log_msg(&format!(
|
||||
"Gossip new peer joined: {}, state: {}",
|
||||
crate::short_id(&payload.author.to_string()),
|
||||
peer_state_for_log(&state)
|
||||
));
|
||||
address_lookup.add_endpoint_info(state.addr.clone());
|
||||
let _ = event_tx.send(RoomEvent::PeerJoined(payload.author, state)).await;
|
||||
} else if state_changed {
|
||||
crate::log_msg(&format!("Gossip peer state updated: {:?}, state: {:?}", payload.author, state));
|
||||
crate::log_msg(&format!(
|
||||
"Gossip peer state updated: {}, state: {}",
|
||||
crate::short_id(&payload.author.to_string()),
|
||||
peer_state_for_log(&state)
|
||||
));
|
||||
let _ = event_tx.send(RoomEvent::PeerUpdated(payload.author, state)).await;
|
||||
}
|
||||
}
|
||||
GossipMessage::Leave => {
|
||||
crate::log_msg(&format!("Gossip peer leave request from author={:?}", payload.author));
|
||||
let removed = peers.lock().unwrap().remove(&payload.author).is_some();
|
||||
if removed {
|
||||
let was_disconnected = disconnected_peers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&payload.author);
|
||||
if removed || was_disconnected {
|
||||
let _ = event_tx.send(RoomEvent::PeerLeft(payload.author)).await;
|
||||
}
|
||||
}
|
||||
@@ -370,6 +483,7 @@ impl RoomState for IrohGossipState {
|
||||
// cached presence entry; a rejoin re-announces as new.
|
||||
let removed = peers.lock().unwrap().remove(&peer_id).is_some();
|
||||
if removed {
|
||||
disconnected_peers.lock().unwrap().insert(peer_id);
|
||||
crate::log_msg(&format!("Peer connection lost (NeighborDown): {:?}", peer_id));
|
||||
let _ = event_tx.send(RoomEvent::PeerConnectionLost(peer_id)).await;
|
||||
}
|
||||
@@ -390,7 +504,10 @@ impl RoomState for IrohGossipState {
|
||||
}
|
||||
|
||||
async fn update_self_state(&self, self_state: PeerState) -> Result<(), NetError> {
|
||||
crate::log_msg(&format!("RoomState::update_self_state: state={:?}", self_state));
|
||||
crate::log_msg(&format!(
|
||||
"RoomState::update_self_state: state: {}",
|
||||
peer_state_for_log(&self_state)
|
||||
));
|
||||
*self.self_state.lock().unwrap() = Some(self_state.clone());
|
||||
|
||||
let sender_opt = self.active_sender.lock().unwrap().clone();
|
||||
@@ -411,6 +528,43 @@ impl RoomState for IrohGossipState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn rebootstrap_peers(&self, peers: Vec<EndpointAddr>) -> Result<(), NetError> {
|
||||
let self_id = self._endpoint.id();
|
||||
let mut peer_ids = Vec::new();
|
||||
for addr in peers {
|
||||
if addr.id == self_id || peer_ids.contains(&addr.id) {
|
||||
continue;
|
||||
}
|
||||
self.address_lookup.add_endpoint_info(addr.clone());
|
||||
peer_ids.push(addr.id);
|
||||
}
|
||||
|
||||
if peer_ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Clone the sender before awaiting: active_sender is a standard mutex and
|
||||
// must never be held across an async gossip operation.
|
||||
let sender = self
|
||||
.active_sender
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.ok_or_else(|| NetError::Other("Not in a room".to_string()))?;
|
||||
|
||||
crate::log_msg(&format!("Rebootstrapping gossip peers: {:?}", peer_ids));
|
||||
sender
|
||||
.join_peers(peer_ids)
|
||||
.await
|
||||
.map_err(|e| NetError::Gossip(e.to_string()))
|
||||
}
|
||||
|
||||
fn mark_peer_disconnected(&self, peer_id: EndpointId) {
|
||||
if self.peers.lock().unwrap().remove(&peer_id).is_some() {
|
||||
self.disconnected_peers.lock().unwrap().insert(peer_id);
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_chat(&self, text: String) -> Result<(), NetError> {
|
||||
let name = {
|
||||
let guard = self.self_state.lock().unwrap();
|
||||
@@ -466,6 +620,7 @@ impl RoomState for IrohGossipState {
|
||||
}
|
||||
|
||||
self.peers.lock().unwrap().clear();
|
||||
self.disconnected_peers.lock().unwrap().clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -489,11 +644,10 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::network::PeerState;
|
||||
use iroh::SecretKey;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn sample_peer_state() -> PeerState {
|
||||
let secret = SecretKey::generate();
|
||||
let public = secret.public();
|
||||
let addr = iroh::EndpointAddr::from(public);
|
||||
fn sample_peer_state_for(id: EndpointId) -> PeerState {
|
||||
let addr = iroh::EndpointAddr::from(id);
|
||||
PeerState {
|
||||
name: "TestPeerGossip".to_string(),
|
||||
is_muted: true,
|
||||
@@ -563,7 +717,7 @@ mod tests {
|
||||
fn test_gossip_payload_announce_round_trip() {
|
||||
let secret = SecretKey::generate();
|
||||
let topic = [9u8; 32];
|
||||
let peer_state = sample_peer_state();
|
||||
let peer_state = sample_peer_state_for(secret.public());
|
||||
let payload = sign_gossip(&secret, &topic, 1000, GossipMessage::Announce(peer_state.clone()));
|
||||
|
||||
let serialized = serde_json::to_string(&payload).unwrap();
|
||||
@@ -732,5 +886,60 @@ mod tests {
|
||||
// Within the window (clock skew tolerance) → accepted.
|
||||
assert!(verify_gossip(&p, &topic, 1_000_000 + GOSSIP_FRESHNESS_MS - 1, GOSSIP_FRESHNESS_MS).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_announce_with_address_for_another_identity() {
|
||||
let signer = SecretKey::generate();
|
||||
let advertised = SecretKey::generate();
|
||||
let topic = [6u8; 32];
|
||||
let state = sample_peer_state_for(advertised.public());
|
||||
let p = sign_gossip(&signer, &topic, 5_000, GossipMessage::Announce(state));
|
||||
|
||||
assert_eq!(
|
||||
verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS),
|
||||
Err(GossipReject::AnnounceAddressMismatch)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_mutation_replay_gate_drops_replayed_leave_and_announce() {
|
||||
let author = fresh_id();
|
||||
let mut seen = HashMap::new();
|
||||
|
||||
assert!(admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 10));
|
||||
assert!(!admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 10));
|
||||
assert!(!admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 9));
|
||||
assert!(admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 11));
|
||||
|
||||
let announce = GossipMessage::Announce(sample_peer_state_for(author));
|
||||
assert!(admit_state_mutation(&mut seen, author, &announce, 10));
|
||||
assert!(!admit_state_mutation(&mut seen, author, &announce, 10));
|
||||
assert!(!admit_state_mutation(&mut seen, author, &announce, 9));
|
||||
assert!(admit_state_mutation(&mut seen, author, &announce, 12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_mutation_replay_gate_leaves_chat_ordering_untouched() {
|
||||
let author = fresh_id();
|
||||
let mut seen = HashMap::new();
|
||||
let later_chat = GossipMessage::Chat { name: "A".into(), text: "later".into(), ts: 200 };
|
||||
let earlier_chat = GossipMessage::Chat { name: "A".into(), text: "earlier".into(), ts: 100 };
|
||||
|
||||
assert!(admit_state_mutation(&mut seen, author, &later_chat, 200));
|
||||
assert!(admit_state_mutation(&mut seen, author, &earlier_chat, 100));
|
||||
assert!(admit_state_mutation(&mut seen, author, &later_chat, 200));
|
||||
assert!(seen.is_empty(), "chat must not populate the state-mutation replay map");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_mutation_replay_gate_is_per_author_and_kind() {
|
||||
let author = fresh_id();
|
||||
let other = fresh_id();
|
||||
let mut seen = HashMap::new();
|
||||
let announce = GossipMessage::Announce(sample_peer_state_for(author));
|
||||
|
||||
assert!(admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 5));
|
||||
assert!(admit_state_mutation(&mut seen, author, &announce, 5));
|
||||
assert!(admit_state_mutation(&mut seen, other, &GossipMessage::Leave, 5));
|
||||
}
|
||||
}
|
||||
|
||||
+168
-5
@@ -5,11 +5,11 @@ use bytes::Bytes;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::Duration;
|
||||
use async_trait::async_trait;
|
||||
|
||||
const AUDIO_ALPN: &[u8] = b"peerspeak-audio";
|
||||
use crate::protocol::AUDIO_ALPN;
|
||||
|
||||
/// Per-peer datagram send queue depth. Audio is real-time, so a backlog is
|
||||
/// useless latency — keep it shallow and drop the oldest frame when full.
|
||||
@@ -56,6 +56,10 @@ struct Shared {
|
||||
/// supervisor inserts its connection when the link comes up and removes it
|
||||
/// when the link dies.
|
||||
live_conns: StdMutex<HashMap<EndpointId, Connection>>,
|
||||
/// Core-owned audio admission snapshot for this room session. It mirrors the
|
||||
/// verified gossip roster plus peers still inside reconnect grace; transport
|
||||
/// connections alone never mutate this set.
|
||||
admitted_audio: StdMutex<HashSet<EndpointId>>,
|
||||
incoming_tx: mpsc::Sender<(EndpointId, Bytes)>,
|
||||
/// Best-effort link-state notifications for the UI (connecting / connected).
|
||||
conn_events_tx: mpsc::Sender<ConnEvent>,
|
||||
@@ -112,6 +116,16 @@ impl Shared {
|
||||
crate::log_msg(&format!("Transport: stopped supervising peer {:?}", peer_id));
|
||||
}
|
||||
}
|
||||
|
||||
fn audio_sender_admitted(&self, peer_id: EndpointId) -> bool {
|
||||
let roster = self.admitted_audio.lock().unwrap();
|
||||
audio_sender_admitted(peer_id, &roster)
|
||||
}
|
||||
|
||||
fn apply_audio_admission(&self, peer_id: EndpointId, event: AudioAdmissionEvent) {
|
||||
let mut roster = self.admitted_audio.lock().unwrap();
|
||||
apply_audio_admission_event(&mut roster, peer_id, event);
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a peer's live-link wait woke up.
|
||||
@@ -135,6 +149,41 @@ fn is_graceful_leave(err: &ConnectionError) -> bool {
|
||||
matches!(err, ConnectionError::ApplicationClosed(frame) if frame.error_code == VarInt::from_u32(GOODBYE_CODE))
|
||||
}
|
||||
|
||||
/// Pure S8 membership decision: iroh already authenticated `remote` as the
|
||||
/// connection's endpoint id, so audio admission is exactly live roster membership.
|
||||
pub(crate) fn audio_sender_admitted(remote: EndpointId, roster: &HashSet<EndpointId>) -> bool {
|
||||
roster.contains(&remote)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum AudioAdmissionEvent {
|
||||
/// A signed gossip Announce/Update says the peer is in the live room roster.
|
||||
RosterPresent,
|
||||
/// Gossip reported a transient drop; keep admission during reconnect grace.
|
||||
TransientDropGrace,
|
||||
/// Graceful leave, transport Left eviction, or reconnect-grace expiry.
|
||||
Remove,
|
||||
}
|
||||
|
||||
pub(crate) fn apply_audio_admission_event(
|
||||
roster: &mut HashSet<EndpointId>,
|
||||
peer_id: EndpointId,
|
||||
event: AudioAdmissionEvent,
|
||||
) {
|
||||
match event {
|
||||
AudioAdmissionEvent::RosterPresent => {
|
||||
roster.insert(peer_id);
|
||||
}
|
||||
AudioAdmissionEvent::TransientDropGrace => {
|
||||
// Grace is not an authority to add membership; it only preserves an
|
||||
// already-admitted peer until either rejoin or grace expiry.
|
||||
}
|
||||
AudioAdmissionEvent::Remove => {
|
||||
roster.remove(&peer_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns a single peer's connection lifecycle for as long as the peer is in the
|
||||
/// room: obtain a link, run the send/read loops, and on loss obtain a new one —
|
||||
/// with capped backoff on the dialing side. The deterministic-initiator rule
|
||||
@@ -333,10 +382,17 @@ impl iroh::protocol::ProtocolHandler for AudioRouter {
|
||||
if shared.self_id.to_string() < peer_id.to_string() {
|
||||
return Ok(());
|
||||
}
|
||||
if !shared.audio_sender_admitted(peer_id) {
|
||||
crate::log_msg(&format!(
|
||||
"Transport: rejected inbound audio from non-member {}",
|
||||
crate::short_id(&peer_id.to_string())
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
// Route the connection to this peer's supervisor (creating it if the
|
||||
// inbound link beat the gossip join event). try_send keeps the
|
||||
// protocol handler from ever blocking; a full queue only happens if
|
||||
// links are churning, and the supervisor will get the next one.
|
||||
// inbound link arrives after the signed gossip Announce admitted it).
|
||||
// try_send keeps the protocol handler from ever blocking; a full queue
|
||||
// only happens if links are churning, and the supervisor gets the next one.
|
||||
let inbound_tx = shared.ensure_supervisor(peer_id).await;
|
||||
if inbound_tx.try_send(connection).is_err() {
|
||||
crate::log_msg(&format!("Transport: dropped inbound link from {:?} (queue full)", peer_id));
|
||||
@@ -369,6 +425,7 @@ impl IrohTransport {
|
||||
addrs: StdMutex::new(HashMap::new()),
|
||||
peers: tokio::sync::Mutex::new(HashMap::new()),
|
||||
live_conns: StdMutex::new(HashMap::new()),
|
||||
admitted_audio: StdMutex::new(HashSet::new()),
|
||||
incoming_tx,
|
||||
conn_events_tx,
|
||||
});
|
||||
@@ -397,11 +454,35 @@ impl IrohTransport {
|
||||
}
|
||||
self.shared.senders.lock().unwrap().clear();
|
||||
self.shared.addrs.lock().unwrap().clear();
|
||||
self.shared.admitted_audio.lock().unwrap().clear();
|
||||
// Give the CONNECTION_CLOSE frames a moment to flush before the caller
|
||||
// shuts the endpoint/router down (the `conns` clones are still alive
|
||||
// here, so the endpoint can still transmit them).
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
}
|
||||
|
||||
/// Admit a peer to this session's audio plane. Core calls this from verified
|
||||
/// gossip roster events; the transport never derives membership on its own.
|
||||
pub fn admit_audio_sender(&self, peer_id: EndpointId) {
|
||||
self.shared
|
||||
.apply_audio_admission(peer_id, AudioAdmissionEvent::RosterPresent);
|
||||
}
|
||||
|
||||
/// Preserve an already-admitted peer through the reconnect grace window.
|
||||
pub fn keep_audio_sender_for_reconnect_grace(&self, peer_id: EndpointId) {
|
||||
self.shared
|
||||
.apply_audio_admission(peer_id, AudioAdmissionEvent::TransientDropGrace);
|
||||
}
|
||||
|
||||
/// Remove a peer from audio admission before tearing down transport/jitter state.
|
||||
pub fn remove_audio_sender(&self, peer_id: EndpointId) {
|
||||
self.shared
|
||||
.apply_audio_admission(peer_id, AudioAdmissionEvent::Remove);
|
||||
}
|
||||
|
||||
pub fn audio_sender_admitted(&self, peer_id: EndpointId) -> bool {
|
||||
self.shared.audio_sender_admitted(peer_id)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -443,3 +524,85 @@ impl NetworkTransport for IrohTransport {
|
||||
.ok_or_else(|| NetError::Other("Connection events already subscribed".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use iroh::SecretKey;
|
||||
|
||||
fn endpoint_id() -> EndpointId {
|
||||
SecretKey::generate().public()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_sender_admission_accepts_roster_member() {
|
||||
let member = endpoint_id();
|
||||
let roster = HashSet::from([member]);
|
||||
|
||||
assert!(audio_sender_admitted(member, &roster));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_sender_admission_rejects_unknown_sender() {
|
||||
let member = endpoint_id();
|
||||
let stranger = endpoint_id();
|
||||
let roster = HashSet::from([member]);
|
||||
|
||||
assert!(!audio_sender_admitted(stranger, &roster));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_sender_admission_rejects_former_member_after_roster_removal() {
|
||||
let former = endpoint_id();
|
||||
let mut roster = HashSet::from([former]);
|
||||
assert!(audio_sender_admitted(former, &roster));
|
||||
|
||||
roster.remove(&former);
|
||||
|
||||
assert!(!audio_sender_admitted(former, &roster));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_sender_admission_waits_for_mid_join_announce() {
|
||||
let joining_peer = endpoint_id();
|
||||
let mut roster = HashSet::new();
|
||||
|
||||
assert!(!audio_sender_admitted(joining_peer, &roster));
|
||||
|
||||
roster.insert(joining_peer);
|
||||
|
||||
assert!(audio_sender_admitted(joining_peer, &roster));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_admission_lifecycle_keeps_peer_through_transient_grace() {
|
||||
let peer = endpoint_id();
|
||||
let mut roster = HashSet::new();
|
||||
|
||||
apply_audio_admission_event(&mut roster, peer, AudioAdmissionEvent::RosterPresent);
|
||||
assert!(audio_sender_admitted(peer, &roster));
|
||||
|
||||
apply_audio_admission_event(&mut roster, peer, AudioAdmissionEvent::TransientDropGrace);
|
||||
assert!(audio_sender_admitted(peer, &roster));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_admission_lifecycle_does_not_add_unknown_peer_on_grace_event() {
|
||||
let peer = endpoint_id();
|
||||
let mut roster = HashSet::new();
|
||||
|
||||
apply_audio_admission_event(&mut roster, peer, AudioAdmissionEvent::TransientDropGrace);
|
||||
|
||||
assert!(!audio_sender_admitted(peer, &roster));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_admission_lifecycle_removes_peer_on_leave_or_grace_expiry() {
|
||||
let peer = endpoint_id();
|
||||
let mut roster = HashSet::from([peer]);
|
||||
|
||||
apply_audio_admission_event(&mut roster, peer, AudioAdmissionEvent::Remove);
|
||||
|
||||
assert!(!audio_sender_admitted(peer, &roster));
|
||||
}
|
||||
}
|
||||
|
||||
+11
-1
@@ -194,6 +194,17 @@ pub trait RoomState: Send + Sync {
|
||||
/// Updates our local state (e.g. when user mutes/unmutes) and broadcasts it.
|
||||
async fn update_self_state(&self, self_state: PeerState) -> Result<(), NetError>;
|
||||
|
||||
/// Ask the active gossip topic to connect to retained peer addresses without
|
||||
/// leaving or replacing the subscription. This is a recovery primitive only:
|
||||
/// it does not add peers to the authenticated room roster. A peer becomes
|
||||
/// active only after its normal signed `Announce` is received and verified.
|
||||
async fn rebootstrap_peers(&self, peers: Vec<EndpointAddr>) -> Result<(), NetError>;
|
||||
|
||||
/// Remove a peer from the authenticated live roster before background
|
||||
/// recovery. This only revokes membership; a fresh verified `Announce` is
|
||||
/// required to add the peer again.
|
||||
fn mark_peer_disconnected(&self, peer_id: EndpointId);
|
||||
|
||||
/// Broadcasts a room text-chat message authored by us (our display name is
|
||||
/// taken from the current self-state).
|
||||
async fn send_chat(&self, text: String) -> Result<(), NetError>;
|
||||
@@ -342,4 +353,3 @@ mod tests {
|
||||
assert_eq!(original, deserialized);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+41
-5
@@ -3,11 +3,12 @@
|
||||
//!
|
||||
//! The WAVs are embedded in the binary (`include_bytes!`) so a deployed single
|
||||
//! binary is self-contained — no asset directory to ship alongside it. On first
|
||||
//! use each sound is written once to a temp file, then played fire-and-forget
|
||||
//! via `pw-play` (PipeWire-native; falls back to `paplay`/`aplay`). Playback runs
|
||||
//! on a detached thread that waits on the child, so it never blocks the UI and
|
||||
//! never leaves a zombie. Any failure (no player, no audio) is silent by design —
|
||||
//! a missing chime should never disrupt a call.
|
||||
//! use each sound is written once to a temp file, then played fire-and-forget.
|
||||
//! Linux uses `pw-play` (PipeWire-native; falls back to `paplay`/`aplay`);
|
||||
//! Windows uses PowerShell's `System.Media.SoundPlayer`. Playback runs on a
|
||||
//! detached thread that waits on the child, so it never blocks the UI and never
|
||||
//! leaves a zombie. Any failure (no player, no audio) is silent by design — a
|
||||
//! missing chime should never disrupt a call.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -202,8 +203,14 @@ fn cached_path(sound: Sound) -> Option<PathBuf> {
|
||||
Some(path)
|
||||
}
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
fn escape_powershell_single_quoted(s: &str) -> String {
|
||||
s.replace('\'', "''")
|
||||
}
|
||||
|
||||
/// Try each available player in turn, waiting on the first that starts (which
|
||||
/// reaps the child). Runs on a detached thread, so the wait is harmless.
|
||||
#[cfg(not(windows))]
|
||||
fn spawn_player(path: &Path) {
|
||||
for player in ["pw-play", "paplay", "aplay"] {
|
||||
let started = Command::new(player)
|
||||
@@ -221,6 +228,23 @@ fn spawn_player(path: &Path) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Play through Windows' built-in WAV player. Runs on a detached thread, so
|
||||
/// `PlaySync()` blocking for the sound duration is fine.
|
||||
#[cfg(windows)]
|
||||
fn spawn_player(path: &Path) {
|
||||
let path = escape_powershell_single_quoted(&path.display().to_string());
|
||||
let command = format!("(New-Object System.Media.SoundPlayer '{path}').PlaySync()");
|
||||
let _ = Command::new("powershell")
|
||||
.arg("-NoProfile")
|
||||
.arg("-NonInteractive")
|
||||
.arg("-Command")
|
||||
.arg(command)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -234,6 +258,18 @@ mod tests {
|
||||
assert!(!should_play(false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_powershell_single_quote_escape() {
|
||||
assert_eq!(
|
||||
escape_powershell_single_quoted(r"C:\Users\O'Brien\chime.wav"),
|
||||
r"C:\Users\O''Brien\chime.wav"
|
||||
);
|
||||
assert_eq!(
|
||||
escape_powershell_single_quoted("a'b'c"),
|
||||
"a''b''c"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sound_indices_unique_and_match_all() {
|
||||
// `index()` must be a 0..COUNT bijection in `ALL` order, or the flag
|
||||
|
||||
+40
-21
@@ -110,26 +110,32 @@ pub enum FriendPresence {
|
||||
InRoom { name: String, ticket: String },
|
||||
}
|
||||
|
||||
/// Interpret a peer's reply defensively. Only a `Pong` is a reply (a `Ping` is
|
||||
/// not, so it yields `None`). When the peer reports a room, we **sanitize the
|
||||
/// peer-supplied name** and **only surface it as joinable if the ticket actually
|
||||
/// parses** as a [`crate::network::PeerSpeakTicket`] — a garbage or hostile
|
||||
/// ticket downgrades the friend to plain `Online` rather than offering a dead /
|
||||
/// dangerous Join button. (We still never auto-join; the user clicks.)
|
||||
pub fn interpret_pong(msg: &ControlMsg) -> Option<FriendPresence> {
|
||||
/// Interpret a peer's reply defensively. `from` must be the connection's
|
||||
/// authenticated remote id, not any value carried in the payload. Only a `Pong`
|
||||
/// is a reply (a `Ping` is not, so it yields `None`). When the peer reports a
|
||||
/// room, we **sanitize the peer-supplied name** and **only surface it as joinable
|
||||
/// if the ticket actually parses** as a [`crate::network::PeerSpeakTicket`] and
|
||||
/// points back at the replying friend. A garbage/redirect ticket downgrades the
|
||||
/// friend to plain `Online` rather than offering a dead or attacker-controlled
|
||||
/// Join button. (We still never auto-join; the user clicks.)
|
||||
pub fn interpret_pong(msg: &ControlMsg, from: EndpointId) -> Option<FriendPresence> {
|
||||
match msg {
|
||||
ControlMsg::Ping => None,
|
||||
ControlMsg::Pong { room: None } => Some(FriendPresence::Online),
|
||||
ControlMsg::Pong { room: Some(r) } => {
|
||||
if r.ticket.parse::<crate::network::PeerSpeakTicket>().is_ok() {
|
||||
Some(FriendPresence::InRoom {
|
||||
name: crate::sanitize::sanitize_name(&r.name),
|
||||
ticket: r.ticket.clone(),
|
||||
})
|
||||
} else {
|
||||
let Ok(ticket) = r.ticket.parse::<crate::network::PeerSpeakTicket>() else {
|
||||
// Online, but the advertised room is unusable — don't offer Join.
|
||||
Some(FriendPresence::Online)
|
||||
return Some(FriendPresence::Online);
|
||||
};
|
||||
if ticket.host_addr.id != from {
|
||||
// Online, but the advertised room redirects away from the friend
|
||||
// who authenticated this Pong — don't offer a phishing Join.
|
||||
return Some(FriendPresence::Online);
|
||||
}
|
||||
Some(FriendPresence::InRoom {
|
||||
name: crate::sanitize::sanitize_name(&r.name),
|
||||
ticket: r.ticket.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,21 +212,22 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn interpret_ping_is_not_a_reply() {
|
||||
assert_eq!(interpret_pong(&ControlMsg::Ping), None);
|
||||
assert_eq!(interpret_pong(&ControlMsg::Ping, id()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpret_pong_online_and_inroom() {
|
||||
let friend = id();
|
||||
// No room -> Online.
|
||||
assert_eq!(
|
||||
interpret_pong(&ControlMsg::Pong { room: None }),
|
||||
interpret_pong(&ControlMsg::Pong { room: None }, friend),
|
||||
Some(FriendPresence::Online)
|
||||
);
|
||||
// Valid ticket -> InRoom with a sanitized name.
|
||||
let t = valid_ticket(id());
|
||||
let t = valid_ticket(friend);
|
||||
let got = interpret_pong(&ControlMsg::Pong {
|
||||
room: Some(RoomPresence { name: "HangOut".into(), ticket: t.clone() }),
|
||||
});
|
||||
}, friend);
|
||||
assert_eq!(got, Some(FriendPresence::InRoom { name: "HangOut".into(), ticket: t }));
|
||||
}
|
||||
|
||||
@@ -230,17 +237,29 @@ mod tests {
|
||||
// Online — no dead/hostile Join button is surfaced.
|
||||
let got = interpret_pong(&ControlMsg::Pong {
|
||||
room: Some(RoomPresence { name: "Trap".into(), ticket: "not-a-ticket".into() }),
|
||||
});
|
||||
}, id());
|
||||
assert_eq!(got, Some(FriendPresence::Online));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpret_pong_rejects_ticket_for_a_different_host() {
|
||||
let friend = id();
|
||||
let attacker = id();
|
||||
let t = valid_ticket(attacker);
|
||||
let got = interpret_pong(&ControlMsg::Pong {
|
||||
room: Some(RoomPresence { name: "Redirect".into(), ticket: t }),
|
||||
}, friend);
|
||||
assert_eq!(got, Some(FriendPresence::Online));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpret_pong_sanitizes_a_hostile_room_name() {
|
||||
// Control/bidi characters in a peer-supplied name are stripped.
|
||||
let t = valid_ticket(id());
|
||||
let friend = id();
|
||||
let t = valid_ticket(friend);
|
||||
let got = interpret_pong(&ControlMsg::Pong {
|
||||
room: Some(RoomPresence { name: "Hang\u{202e}Out\u{0007}".into(), ticket: t.clone() }),
|
||||
});
|
||||
}, friend);
|
||||
match got {
|
||||
Some(FriendPresence::InRoom { name, .. }) => {
|
||||
assert!(!name.contains('\u{202e}'), "bidi override must be stripped");
|
||||
|
||||
+18
-16
@@ -31,7 +31,7 @@ use std::time::Duration;
|
||||
|
||||
/// ALPN for the friends presence/control plane. Separate from the audio/gossip
|
||||
/// ALPNs so a control dial never lands on a bare room endpoint and vice versa.
|
||||
pub const FRIENDS_ALPN: &[u8] = b"peerspeak/friends/0";
|
||||
pub const FRIENDS_ALPN: &[u8] = crate::protocol::FRIENDS_ALPN;
|
||||
|
||||
/// Upper bound on a single control message — generous for a Pong carrying a
|
||||
/// member ticket (~300 chars), but rejects a peer trying to make us buffer a
|
||||
@@ -49,16 +49,17 @@ fn decode(bytes: &[u8]) -> Result<ControlMsg> {
|
||||
serde_json::from_slice(bytes).context("failed to decode control message")
|
||||
}
|
||||
|
||||
/// Probe `peer` for presence: send a `Ping`, return their `Pong`. An error means
|
||||
/// no usable reply (offline / unreachable / refused / malformed) — the caller
|
||||
/// treats that as "appears offline". `peer` is usually a bare [`EndpointId`]
|
||||
/// (friends store the stable id); a full [`EndpointAddr`] is also accepted (and
|
||||
/// used by hermetic tests).
|
||||
pub async fn probe(endpoint: &Endpoint, peer: impl Into<EndpointAddr>) -> Result<ControlMsg> {
|
||||
/// Probe `peer` for presence: send a `Ping`, return their authenticated id and
|
||||
/// `Pong`. An error means no usable reply (offline / unreachable / refused /
|
||||
/// malformed) — the caller treats that as "appears offline". `peer` is usually a
|
||||
/// bare [`EndpointId`] (friends store the stable id); a full [`EndpointAddr`] is
|
||||
/// also accepted (and used by hermetic tests).
|
||||
pub async fn probe(endpoint: &Endpoint, peer: impl Into<EndpointAddr>) -> Result<(EndpointId, ControlMsg)> {
|
||||
let conn = tokio::time::timeout(IO_TIMEOUT, endpoint.connect(peer, FRIENDS_ALPN))
|
||||
.await
|
||||
.context("timed out connecting to peer")?
|
||||
.context("failed to connect to peer")?;
|
||||
let from = conn.remote_id();
|
||||
|
||||
let io = async {
|
||||
let (mut send, mut recv) = conn.open_bi().await.context("failed to open control stream")?;
|
||||
@@ -77,7 +78,7 @@ pub async fn probe(endpoint: &Endpoint, peer: impl Into<EndpointAddr>) -> Result
|
||||
.await
|
||||
.context("timed out awaiting pong")?;
|
||||
conn.close(VarInt::from_u32(0), b"done");
|
||||
result
|
||||
result.map(|msg| (from, msg))
|
||||
}
|
||||
|
||||
/// A reply policy: given the *authenticated* remote id, decide whether and how to
|
||||
@@ -110,6 +111,10 @@ async fn handle(incoming: Incoming, handler: Handler) -> Result<()> {
|
||||
async fn exchange(conn: &iroh::endpoint::Connection, handler: &Handler) -> Result<()> {
|
||||
// The authenticated remote id — NOT anything the peer puts in the payload.
|
||||
let from = conn.remote_id();
|
||||
let Some(reply) = handler(from) else {
|
||||
conn.close(VarInt::from_u32(0), b"not authorized");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let io = async {
|
||||
let (mut send, mut recv) = conn.accept_bi().await.context("failed to accept stream")?;
|
||||
@@ -118,13 +123,9 @@ async fn exchange(conn: &iroh::endpoint::Connection, handler: &Handler) -> Resul
|
||||
ControlMsg::Ping => {}
|
||||
other => bail!("expected a ping, got {other:?}"),
|
||||
}
|
||||
// Ask the policy what to send. None -> answer nothing (stranger / invisible):
|
||||
// finish the stream with no bytes so the prober sees an empty (unusable) reply.
|
||||
if let Some(reply) = handler(from) {
|
||||
send.write_all(&encode(&reply)?)
|
||||
.await
|
||||
.context("failed to write pong")?;
|
||||
}
|
||||
send.write_all(&encode(&reply)?)
|
||||
.await
|
||||
.context("failed to write pong")?;
|
||||
send.finish().context("failed to finish reply stream")?;
|
||||
Ok::<_, anyhow::Error>(())
|
||||
};
|
||||
@@ -220,10 +221,11 @@ mod tests {
|
||||
let serve_task = tokio::spawn(async move { serve(server_ep, handler).await });
|
||||
|
||||
// The allowed prober gets a Pong with the room.
|
||||
let pong = tokio::time::timeout(Duration::from_secs(15), probe(&prober, server_addr.clone()))
|
||||
let (from, pong) = tokio::time::timeout(Duration::from_secs(15), probe(&prober, server_addr.clone()))
|
||||
.await
|
||||
.expect("probe timed out")
|
||||
.expect("probe failed");
|
||||
assert_eq!(from, server_addr.id);
|
||||
match pong {
|
||||
ControlMsg::Pong { room: Some(r) } => assert_eq!(r.name, "HangOut"),
|
||||
other => panic!("expected Pong with a room, got {other:?}"),
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Single source of truth for PeerSpeak's on-wire protocol versions and the
|
||||
//! per-plane ALPNs / gossip constants derived from them.
|
||||
//!
|
||||
//! See `VERSIONING.md`. The rule: each transport plane is versioned independently
|
||||
//! (audio rarely changes, gossip changes often), and incompatible peers must fail
|
||||
//! fast — never as a silent decode/signature error. iroh refuses a mismatched
|
||||
//! ALPN at the QUIC handshake, so the audio/friends planes are self-isolating;
|
||||
//! gossip can't use a custom ALPN (it rides iroh-gossip's `GOSSIP_ALPN`), so its
|
||||
//! version is bound into the subscribed topic ([`versioned_topic`]) and the
|
||||
//! signature domain ([`GOSSIP_SIG_DOMAIN`]).
|
||||
//!
|
||||
//! **Never hand-write an ALPN literal elsewhere — derive it here.** Bumping a
|
||||
//! plane's protocol version is a breaking wire change → also bump `Cargo.toml`
|
||||
//! MINOR (see `VERSIONING.md`).
|
||||
|
||||
/// Audio datagram plane version (Opus framing / sequencing). Bump on any audio
|
||||
/// wire change. Mirrored in [`AUDIO_ALPN`].
|
||||
pub const AUDIO_PROTO: u32 = 1;
|
||||
/// Friends/presence plane version (`ControlMsg` ping-pong shape). Bump on any
|
||||
/// change. Mirrored in [`FRIENDS_ALPN`].
|
||||
pub const FRIENDS_PROTO: u32 = 1;
|
||||
/// Gossip plane version (`GossipPayload`/`GossipMessage`/`PeerState`, signing,
|
||||
/// freshness). Bump on any change. Mirrored in [`GOSSIP_SIG_DOMAIN`] and folded
|
||||
/// into [`versioned_topic`].
|
||||
pub const GOSSIP_PROTO: u32 = 1;
|
||||
|
||||
/// ALPN for the audio datagram plane: `peerspeak/audio/<AUDIO_PROTO>`.
|
||||
pub const AUDIO_ALPN: &[u8] = b"peerspeak/audio/1";
|
||||
/// ALPN for the friends/presence plane: `peerspeak/friends/<FRIENDS_PROTO>`.
|
||||
pub const FRIENDS_ALPN: &[u8] = b"peerspeak/friends/1";
|
||||
/// ed25519 gossip signature domain: `peerspeak-gossip-v<GOSSIP_PROTO>`. Carries
|
||||
/// the gossip protocol version into every signed payload — a version mismatch
|
||||
/// fails verification (cryptographic separation between gossip versions).
|
||||
pub const GOSSIP_SIG_DOMAIN: &str = "peerspeak-gossip-v1";
|
||||
|
||||
/// Version-namespace a room topic so peers on different gossip protocol versions
|
||||
/// derive **different subscription topics from the same ticket** and therefore
|
||||
/// never share a swarm — the gossip analog of a versioned ALPN. The room's raw
|
||||
/// `topic_id` (random 32 bytes, carried in the ticket) is the room identity and
|
||||
/// is unchanged; only the *subscribed* topic is namespaced.
|
||||
///
|
||||
/// Deterministic and dependency-free; bijective for a fixed version, so distinct
|
||||
/// rooms stay distinct after namespacing. This transform is for *isolation*, not
|
||||
/// security — cryptographic separation between versions comes from
|
||||
/// [`GOSSIP_SIG_DOMAIN`].
|
||||
pub fn versioned_topic(topic_id: [u8; 32]) -> [u8; 32] {
|
||||
let v = GOSSIP_PROTO.to_le_bytes();
|
||||
let mut out = topic_id;
|
||||
for (i, b) in out.iter_mut().enumerate() {
|
||||
*b ^= v[i % v.len()];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The ALPN/domain strings must stay in lock-step with the integer versions
|
||||
/// so a version bump can't silently forget to update the wire string.
|
||||
#[test]
|
||||
fn alpns_match_their_proto_versions() {
|
||||
assert_eq!(AUDIO_ALPN, format!("peerspeak/audio/{AUDIO_PROTO}").as_bytes());
|
||||
assert_eq!(FRIENDS_ALPN, format!("peerspeak/friends/{FRIENDS_PROTO}").as_bytes());
|
||||
assert_eq!(GOSSIP_SIG_DOMAIN, format!("peerspeak-gossip-v{GOSSIP_PROTO}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versioned_topic_is_deterministic_and_room_distinct() {
|
||||
let a = [9u8; 32];
|
||||
let mut b = a;
|
||||
b[5] = 10;
|
||||
assert_eq!(versioned_topic(a), versioned_topic(a), "deterministic");
|
||||
assert_ne!(versioned_topic(a), versioned_topic(b), "distinct rooms stay distinct");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versioned_topic_actually_namespaces_for_current_version() {
|
||||
// Guards against a no-op transform: GOSSIP_PROTO=1 must change the topic.
|
||||
assert_ne!(versioned_topic([0u8; 32]), [0u8; 32]);
|
||||
}
|
||||
}
|
||||
+77
-2
@@ -25,6 +25,20 @@ use tokio::process::{Child, Command};
|
||||
/// points elsewhere.
|
||||
const PIXELPASS_BIN: &str = "pixelpass";
|
||||
|
||||
#[cfg(windows)]
|
||||
fn pixelpass_path_candidates(dir: &Path) -> [PathBuf; 2] {
|
||||
[dir.join(PIXELPASS_BIN), dir.join("pixelpass.exe")]
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn pixelpass_path_candidates(dir: &Path) -> [PathBuf; 1] {
|
||||
[dir.join(PIXELPASS_BIN)]
|
||||
}
|
||||
|
||||
/// Pixelpass endpoint tickets are normally ~140 chars. Leave headroom for format
|
||||
/// growth, but reject unbounded gossip payloads before the UI offers "Watch".
|
||||
const MAX_TICKET_LEN: usize = 512;
|
||||
|
||||
/// How long to wait for the host to emit its ticket / the viewer to connect
|
||||
/// before giving up and killing the child. Startup is normally sub-second; this
|
||||
/// is only a safety net so a hung pixelpass can't wedge the caller forever.
|
||||
@@ -108,6 +122,19 @@ pub fn viewer_args(ticket: &str) -> Vec<String> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Sanitize a peer-advertised pixelpass ticket at the gossip boundary. Peerspeak
|
||||
/// intentionally does not depend on pixelpass/iroh-tickets, so this validates the
|
||||
/// stable CLI ticket envelope we consume: bounded ASCII endpoint tickets beginning
|
||||
/// with `endpoint`. Invalid input becomes `None`, which removes the Watch button.
|
||||
pub fn sanitize_ticket(ticket: String) -> Option<String> {
|
||||
let ticket = ticket.trim();
|
||||
let valid_len = !ticket.is_empty() && ticket.len() <= MAX_TICKET_LEN;
|
||||
let valid_shape = ticket.starts_with("endpoint")
|
||||
&& ticket.len() > "endpoint".len()
|
||||
&& ticket.bytes().all(|b| b.is_ascii_alphanumeric());
|
||||
(valid_len && valid_shape).then(|| ticket.to_string())
|
||||
}
|
||||
|
||||
/// Resolve the pixelpass binary: an explicit config override (used only if it
|
||||
/// points at an existing file), otherwise the first `pixelpass` found on
|
||||
/// `$PATH`. `None` means it isn't installed — a normal, handled state. An
|
||||
@@ -126,7 +153,7 @@ pub fn pixelpass_path(config_override: Option<&str>) -> Option<PathBuf> {
|
||||
}
|
||||
let path_var = std::env::var_os("PATH")?;
|
||||
std::env::split_paths(&path_var)
|
||||
.map(|dir| dir.join(PIXELPASS_BIN))
|
||||
.flat_map(|dir| pixelpass_path_candidates(&dir))
|
||||
.find(|c| c.is_file())
|
||||
}
|
||||
|
||||
@@ -267,12 +294,29 @@ where
|
||||
tokio::spawn(async move {
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if let Some(ev) = parse_pixelpass_event(&line) {
|
||||
crate::log_msg(&format!("pixelpass {role}: {ev:?}"));
|
||||
crate::log_msg(&format!("pixelpass {role}: {}", event_for_log(&ev)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn event_for_log(ev: &PixelpassEvent) -> String {
|
||||
match ev {
|
||||
PixelpassEvent::Ticket(ticket) => format!("ticket {}", crate::redact_for_log(ticket)),
|
||||
PixelpassEvent::Connected(_) => "connected".to_string(),
|
||||
PixelpassEvent::ViewerJoined { active, max } => {
|
||||
format!("viewer_joined active={active} max={max}")
|
||||
}
|
||||
PixelpassEvent::ViewerLeft { active, max } => {
|
||||
format!("viewer_left active={active} max={max}")
|
||||
}
|
||||
PixelpassEvent::Refused(reason) => format!("viewer_refused reason={reason:?}"),
|
||||
PixelpassEvent::CaptureStarted => "capture_started".to_string(),
|
||||
PixelpassEvent::CaptureStopped => "capture_stopped".to_string(),
|
||||
PixelpassEvent::Other => "other".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the viewer stream URL in a media player. Mirrors pixelpass's own
|
||||
/// low-latency mpv invocation; falls back to vlc. The player is reaped in a
|
||||
/// background task so it doesn't linger as a zombie when its window closes.
|
||||
@@ -340,6 +384,27 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_ticket_accepts_pixelpass_endpoint_ticket_shape() {
|
||||
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm";
|
||||
assert_eq!(sanitize_ticket(format!(" {ticket}\n")), Some(ticket.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_ticket_rejects_oversized_or_garbage_ticket() {
|
||||
assert_eq!(sanitize_ticket("not-a-ticket".into()), None);
|
||||
assert_eq!(sanitize_ticket(format!("endpoint{}", "a".repeat(MAX_TICKET_LEN))), None);
|
||||
assert_eq!(sanitize_ticket("endpointabc-def".into()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_log_redacts_ticket_values() {
|
||||
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm".to_string();
|
||||
let log = event_for_log(&PixelpassEvent::Ticket(ticket.clone()));
|
||||
assert!(log.contains("endpoint"));
|
||||
assert!(!log.contains(&ticket["endpoint".len() + 8..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_ticket() {
|
||||
assert_eq!(
|
||||
@@ -458,4 +523,14 @@ mod tests {
|
||||
// only assert it doesn't return the empty path as a match.
|
||||
assert_ne!(pixelpass_path(Some(" ")).as_deref(), Some(Path::new("")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixelpass_path_candidates_are_platform_specific() {
|
||||
let dir = Path::new("bin");
|
||||
let candidates: Vec<PathBuf> = pixelpass_path_candidates(dir).into_iter().collect();
|
||||
#[cfg(windows)]
|
||||
assert_eq!(candidates, vec![dir.join("pixelpass"), dir.join("pixelpass.exe")]);
|
||||
#[cfg(not(windows))]
|
||||
assert_eq!(candidates, vec![dir.join("pixelpass")]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
//! Phase-0 spike for post-grace gossip recovery.
|
||||
//!
|
||||
//! These tests prove that `GossipSender::join_peers` can restore an existing
|
||||
//! topic subscription after the other peer drops and rejoins without its own
|
||||
//! bootstrap target. The second case disables relays, clears the surviving
|
||||
//! node's lookup, and moves the peer to a fresh endpoint address so only the
|
||||
//! retained full address passed to `rebootstrap_peers` can drive recovery.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use iroh::address_lookup::memory::MemoryLookup;
|
||||
use iroh::endpoint::presets;
|
||||
use iroh::protocol::Router;
|
||||
use iroh::{Endpoint, EndpointAddr, EndpointId, RelayMode, SecretKey};
|
||||
use iroh_gossip::net::Gossip;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use peerspeak::network::gossip::IrohGossipState;
|
||||
use peerspeak::network::{PeerSpeakTicket, PeerState, RoomEvent, RoomState};
|
||||
|
||||
const EVENT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
struct GossipNode {
|
||||
endpoint: Endpoint,
|
||||
lookup: MemoryLookup,
|
||||
room: Arc<IrohGossipState>,
|
||||
_router: Router,
|
||||
}
|
||||
|
||||
async fn spawn_node(secret: SecretKey) -> GossipNode {
|
||||
let lookup = MemoryLookup::new();
|
||||
let endpoint = Endpoint::builder(presets::Minimal)
|
||||
.secret_key(secret.clone())
|
||||
.relay_mode(RelayMode::Disabled)
|
||||
.address_lookup(lookup.clone())
|
||||
.bind()
|
||||
.await
|
||||
.expect("bind gossip endpoint");
|
||||
let gossip = Gossip::builder().spawn(endpoint.clone());
|
||||
let router = Router::builder(endpoint.clone())
|
||||
.accept(iroh_gossip::net::GOSSIP_ALPN, gossip.clone())
|
||||
.spawn();
|
||||
let room = Arc::new(IrohGossipState::new(
|
||||
endpoint.clone(),
|
||||
gossip,
|
||||
lookup.clone(),
|
||||
secret,
|
||||
));
|
||||
|
||||
GossipNode {
|
||||
endpoint,
|
||||
lookup,
|
||||
room,
|
||||
_router: router,
|
||||
}
|
||||
}
|
||||
|
||||
fn state(name: &str, addr: EndpointAddr) -> PeerState {
|
||||
PeerState {
|
||||
name: name.to_string(),
|
||||
is_muted: false,
|
||||
addr,
|
||||
sharing: None,
|
||||
avatar: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn await_joined(rx: &mut mpsc::Receiver<RoomEvent>, peer_id: EndpointId) -> PeerState {
|
||||
let deadline = tokio::time::Instant::now() + EVENT_TIMEOUT;
|
||||
loop {
|
||||
match tokio::time::timeout_at(deadline, rx.recv()).await {
|
||||
Ok(Some(RoomEvent::PeerJoined(id, peer_state))) if id == peer_id => return peer_state,
|
||||
Ok(Some(_)) => continue,
|
||||
Ok(None) => panic!("room event channel closed while waiting for PeerJoined"),
|
||||
Err(_) => panic!("timed out waiting for PeerJoined({peer_id:?})"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn await_joined_all(rx: &mut mpsc::Receiver<RoomEvent>, peer_ids: &[EndpointId]) {
|
||||
let deadline = tokio::time::Instant::now() + EVENT_TIMEOUT;
|
||||
let mut remaining = peer_ids.to_vec();
|
||||
while !remaining.is_empty() {
|
||||
match tokio::time::timeout_at(deadline, rx.recv()).await {
|
||||
Ok(Some(RoomEvent::PeerJoined(id, _))) => remaining.retain(|wanted| *wanted != id),
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => panic!("room event channel closed while waiting for PeerJoined set"),
|
||||
Err(_) => panic!("timed out waiting for PeerJoined set: {remaining:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn await_left(rx: &mut mpsc::Receiver<RoomEvent>, peer_id: EndpointId) {
|
||||
let deadline = tokio::time::Instant::now() + EVENT_TIMEOUT;
|
||||
loop {
|
||||
match tokio::time::timeout_at(deadline, rx.recv()).await {
|
||||
Ok(Some(RoomEvent::PeerLeft(id))) if id == peer_id => return,
|
||||
Ok(Some(_)) => continue,
|
||||
Ok(None) => panic!("room event channel closed while waiting for PeerLeft"),
|
||||
Err(_) => panic!("timed out waiting for PeerLeft({peer_id:?})"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn await_absent(room: &IrohGossipState, peer_id: EndpointId) {
|
||||
let deadline = tokio::time::Instant::now() + EVENT_TIMEOUT;
|
||||
loop {
|
||||
if !room.active_peers().iter().any(|(id, _)| *id == peer_id) {
|
||||
return;
|
||||
}
|
||||
assert!(
|
||||
tokio::time::Instant::now() < deadline,
|
||||
"timed out waiting for peer to leave the roster"
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn ticket(host_addr: EndpointAddr) -> String {
|
||||
PeerSpeakTicket {
|
||||
host_addr,
|
||||
topic_id: rand::random(),
|
||||
name: "rebootstrap-spike".to_string(),
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn establish_room(
|
||||
a: &GossipNode,
|
||||
b: &GossipNode,
|
||||
ticket: &str,
|
||||
events_a: &mut mpsc::Receiver<RoomEvent>,
|
||||
) {
|
||||
// B is the ticket host. Its own bootstrap set is empty; A is the only side
|
||||
// that initially dials, which is also how the recovery setup is controlled.
|
||||
b.room
|
||||
.join(ticket, state("Bob", b.endpoint.addr()), vec![])
|
||||
.await
|
||||
.expect("host joins topic");
|
||||
a.room
|
||||
.join(ticket, state("Alice", a.endpoint.addr()), vec![])
|
||||
.await
|
||||
.expect("client joins topic");
|
||||
let joined = await_joined(events_a, b.endpoint.id()).await;
|
||||
assert_eq!(joined.name, "Bob");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rebootstrap_restores_roster_on_existing_subscription() {
|
||||
let a = spawn_node(SecretKey::generate()).await;
|
||||
let b = spawn_node(SecretKey::generate()).await;
|
||||
let ticket = ticket(b.endpoint.addr());
|
||||
let mut events_a = a.room.subscribe_events().await.expect("subscribe A events");
|
||||
|
||||
establish_room(&a, &b, &ticket, &mut events_a).await;
|
||||
|
||||
// Drop only B's topic subscription. A stays subscribed. B then rejoins as
|
||||
// the ticket host, so compute_bootstrap removes self and B has nobody to dial.
|
||||
b.room.leave().await.expect("B leaves topic");
|
||||
await_absent(&a.room, b.endpoint.id()).await;
|
||||
b.room
|
||||
.join(&ticket, state("Bob recovered", b.endpoint.addr()), vec![])
|
||||
.await
|
||||
.expect("B rejoins without bootstrap peers");
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
assert!(
|
||||
!a.room
|
||||
.active_peers()
|
||||
.iter()
|
||||
.any(|(id, _)| *id == b.endpoint.id()),
|
||||
"B must not recover before A explicitly re-bootstraps it"
|
||||
);
|
||||
|
||||
a.room
|
||||
.rebootstrap_peers(vec![b.endpoint.addr()])
|
||||
.await
|
||||
.expect("targeted gossip re-bootstrap");
|
||||
|
||||
let recovered = await_joined(&mut events_a, b.endpoint.id()).await;
|
||||
assert_eq!(recovered.name, "Bob recovered");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rebootstrap_uses_retained_full_address_with_empty_lookup() {
|
||||
let a = spawn_node(SecretKey::generate()).await;
|
||||
let b_secret = SecretKey::generate();
|
||||
let b = spawn_node(b_secret.clone()).await;
|
||||
let b_id = b.endpoint.id();
|
||||
let old_b_addr = b.endpoint.addr();
|
||||
let ticket = ticket(old_b_addr.clone());
|
||||
let mut events_a = a.room.subscribe_events().await.expect("subscribe A events");
|
||||
|
||||
establish_room(&a, &b, &ticket, &mut events_a).await;
|
||||
b.room.leave().await.expect("old B leaves topic");
|
||||
await_absent(&a.room, b_id).await;
|
||||
|
||||
// Move the same authenticated identity to a newly-bound direct-only endpoint.
|
||||
// The old cached path is now dead; the new full address is the only valid one.
|
||||
b.endpoint.close().await;
|
||||
drop(b);
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
let b_rebound = spawn_node(b_secret).await;
|
||||
let new_b_addr = b_rebound.endpoint.addr();
|
||||
assert_eq!(new_b_addr.id, b_id, "identity must survive the rebind");
|
||||
assert_ne!(
|
||||
new_b_addr, old_b_addr,
|
||||
"rebound peer must have a fresh address"
|
||||
);
|
||||
|
||||
b_rebound
|
||||
.room
|
||||
.join(&ticket, state("Bob rebound", new_b_addr.clone()), vec![])
|
||||
.await
|
||||
.expect("rebound host joins without bootstrap peers");
|
||||
|
||||
// Remove the stale lookup entry. `rebootstrap_peers` must seed the retained
|
||||
// new full address before asking gossip to join the peer by id.
|
||||
a.lookup.remove_endpoint_info(b_id);
|
||||
assert!(
|
||||
a.lookup.get_endpoint_info(b_id).is_none(),
|
||||
"A lookup starts empty for B"
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
assert!(
|
||||
!a.room.active_peers().iter().any(|(id, _)| *id == b_id),
|
||||
"rebound B must not be rediscovered without the retained address"
|
||||
);
|
||||
|
||||
a.room
|
||||
.rebootstrap_peers(vec![new_b_addr])
|
||||
.await
|
||||
.expect("retained-address gossip re-bootstrap");
|
||||
assert!(
|
||||
a.lookup.get_endpoint_info(b_id).is_some(),
|
||||
"re-bootstrap must restore B's address to the lookup"
|
||||
);
|
||||
|
||||
let recovered = await_joined(&mut events_a, b_id).await;
|
||||
assert_eq!(recovered.name, "Bob rebound");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn demoted_peer_requires_a_fresh_signed_announce_to_rejoin() {
|
||||
let a = spawn_node(SecretKey::generate()).await;
|
||||
let b = spawn_node(SecretKey::generate()).await;
|
||||
let ticket = ticket(b.endpoint.addr());
|
||||
let mut events_a = a.room.subscribe_events().await.expect("subscribe A events");
|
||||
|
||||
establish_room(&a, &b, &ticket, &mut events_a).await;
|
||||
a.room.mark_peer_disconnected(b.endpoint.id());
|
||||
assert!(
|
||||
!a.room
|
||||
.active_peers()
|
||||
.iter()
|
||||
.any(|(id, _)| *id == b.endpoint.id()),
|
||||
"demotion must revoke live roster membership"
|
||||
);
|
||||
|
||||
b.room
|
||||
.update_self_state(state("Bob authenticated again", b.endpoint.addr()))
|
||||
.await
|
||||
.expect("broadcast fresh signed announce");
|
||||
|
||||
let recovered = await_joined(&mut events_a, b.endpoint.id()).await;
|
||||
assert_eq!(recovered.name, "Bob authenticated again");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn signed_leave_after_demotion_still_emits_peer_left() {
|
||||
let a = spawn_node(SecretKey::generate()).await;
|
||||
let b = spawn_node(SecretKey::generate()).await;
|
||||
let ticket = ticket(b.endpoint.addr());
|
||||
let mut events_a = a.room.subscribe_events().await.expect("subscribe A events");
|
||||
|
||||
establish_room(&a, &b, &ticket, &mut events_a).await;
|
||||
a.room.mark_peer_disconnected(b.endpoint.id());
|
||||
|
||||
b.room
|
||||
.leave()
|
||||
.await
|
||||
.expect("broadcast signed Leave after demotion");
|
||||
await_left(&mut events_a, b.endpoint.id()).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn targeted_rebootstrap_preserves_healthy_peer_in_three_peer_room() {
|
||||
let a = spawn_node(SecretKey::generate()).await;
|
||||
let b = spawn_node(SecretKey::generate()).await;
|
||||
let c_secret = SecretKey::generate();
|
||||
let c = spawn_node(c_secret.clone()).await;
|
||||
let c_id = c.endpoint.id();
|
||||
let ticket = ticket(c.endpoint.addr());
|
||||
let mut events_a = a.room.subscribe_events().await.expect("subscribe A events");
|
||||
let mut events_b = b.room.subscribe_events().await.expect("subscribe B events");
|
||||
|
||||
c.room
|
||||
.join(&ticket, state("Carol", c.endpoint.addr()), vec![])
|
||||
.await
|
||||
.expect("C hosts topic");
|
||||
b.room
|
||||
.join(&ticket, state("Bob", b.endpoint.addr()), vec![])
|
||||
.await
|
||||
.expect("B joins C");
|
||||
await_joined(&mut events_b, c_id).await;
|
||||
a.room
|
||||
.join(&ticket, state("Alice", a.endpoint.addr()), vec![])
|
||||
.await
|
||||
.expect("A joins C");
|
||||
await_joined_all(&mut events_a, &[b.endpoint.id(), c_id]).await;
|
||||
await_joined(&mut events_b, a.endpoint.id()).await;
|
||||
|
||||
// Remove only C. A and B keep their existing topic subscriptions and remain
|
||||
// mutually present while C is rebound to a fresh address.
|
||||
c.endpoint.close().await;
|
||||
drop(c);
|
||||
a.room.mark_peer_disconnected(c_id);
|
||||
b.room.mark_peer_disconnected(c_id);
|
||||
let c_rebound = spawn_node(c_secret).await;
|
||||
let rebound_addr = c_rebound.endpoint.addr();
|
||||
c_rebound
|
||||
.room
|
||||
.join(
|
||||
&ticket,
|
||||
state("Carol recovered", rebound_addr.clone()),
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
.expect("rebound C rejoins as host without bootstrap");
|
||||
|
||||
a.room
|
||||
.rebootstrap_peers(vec![rebound_addr])
|
||||
.await
|
||||
.expect("A targets only C for recovery");
|
||||
let recovered = await_joined(&mut events_a, c_id).await;
|
||||
assert_eq!(recovered.name, "Carol recovered");
|
||||
await_joined(&mut events_b, c_id).await;
|
||||
|
||||
assert!(
|
||||
a.room
|
||||
.active_peers()
|
||||
.iter()
|
||||
.any(|(id, _)| *id == b.endpoint.id()),
|
||||
"healthy B must remain present at A throughout C recovery"
|
||||
);
|
||||
assert!(
|
||||
b.room
|
||||
.active_peers()
|
||||
.iter()
|
||||
.any(|(id, _)| *id == a.endpoint.id()),
|
||||
"healthy A must remain present at B throughout C recovery"
|
||||
);
|
||||
}
|
||||
@@ -25,8 +25,7 @@ use peerspeak::codec::opus_impl::OpusEncoder;
|
||||
use peerspeak::core::jitter::{FRAME_SAMPLES, JitterBuffer};
|
||||
use peerspeak::network::{ConnEvent, NetworkTransport};
|
||||
use peerspeak::network::iroh_impl::{AudioRouter, IrohTransport};
|
||||
|
||||
const AUDIO_ALPN: &[u8] = b"peerspeak-audio";
|
||||
use peerspeak::protocol::AUDIO_ALPN;
|
||||
|
||||
struct Node {
|
||||
endpoint: Endpoint,
|
||||
@@ -169,6 +168,9 @@ async fn loopback_sequenced_audio_reaches_peer_and_decodes() {
|
||||
b.lookup.add_endpoint_info(a.endpoint.addr());
|
||||
|
||||
let a_id = a.endpoint.id();
|
||||
let b_id = b.endpoint.id();
|
||||
a.transport.admit_audio_sender(b_id);
|
||||
b.transport.admit_audio_sender(a_id);
|
||||
|
||||
// Subscribe to incoming datagrams on B before any are sent.
|
||||
let mut b_rx = b.transport.receive_datagrams().await.expect("subscribe B");
|
||||
|
||||
Reference in New Issue
Block a user