Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47c58047ce | ||
|
|
85b12a26c9 | ||
|
|
e4767be210 | ||
|
|
10ee765ffd | ||
|
|
3034c42f71 | ||
|
|
465c7ba2b0 | ||
|
|
3ec09de87e | ||
|
|
4b8fb92dc5 | ||
|
|
1adf8a97bb | ||
|
|
10707152a3 | ||
|
|
f2e72624f7 | ||
|
|
319d0c5e29 | ||
|
|
d56c2c90b2 | ||
|
|
5086e86bd2 | ||
|
|
54780fa73b | ||
|
|
b1aa751a84 | ||
|
|
9efab491c7 | ||
|
|
f3f399a748 | ||
|
|
1afdccbefe | ||
|
|
7724da73b8 | ||
|
|
92c9d585b8 | ||
|
|
8982df364e |
@@ -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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
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]]
|
[[package]]
|
||||||
name = "android-activity"
|
name = "android-activity"
|
||||||
version = "0.6.1"
|
version = "0.6.1"
|
||||||
@@ -114,12 +136,12 @@ dependencies = [
|
|||||||
"android-properties",
|
"android-properties",
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"cc",
|
"cc",
|
||||||
"jni",
|
"jni 0.22.4",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"ndk",
|
"ndk 0.9.0",
|
||||||
"ndk-context",
|
"ndk-context",
|
||||||
"ndk-sys",
|
"ndk-sys 0.6.0+11769913",
|
||||||
"num_enum",
|
"num_enum",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
@@ -712,6 +734,12 @@ dependencies = [
|
|||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cesu8"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cexpr"
|
name = "cexpr"
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
@@ -1002,6 +1030,26 @@ dependencies = [
|
|||||||
"libm",
|
"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]]
|
[[package]]
|
||||||
name = "cosmic-text"
|
name = "cosmic-text"
|
||||||
version = "0.15.0"
|
version = "0.15.0"
|
||||||
@@ -1026,6 +1074,29 @@ dependencies = [
|
|||||||
"unicode-segmentation",
|
"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]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -1228,6 +1299,12 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dasp_sample"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "data-encoding"
|
name = "data-encoding"
|
||||||
version = "2.11.0"
|
version = "2.11.0"
|
||||||
@@ -2227,7 +2304,7 @@ dependencies = [
|
|||||||
"http",
|
"http",
|
||||||
"idna",
|
"idna",
|
||||||
"ipnet",
|
"ipnet",
|
||||||
"jni",
|
"jni 0.22.4",
|
||||||
"rand 0.10.1",
|
"rand 0.10.1",
|
||||||
"rustls",
|
"rustls",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
@@ -2247,7 +2324,7 @@ dependencies = [
|
|||||||
"data-encoding",
|
"data-encoding",
|
||||||
"idna",
|
"idna",
|
||||||
"ipnet",
|
"ipnet",
|
||||||
"jni",
|
"jni 0.22.4",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"prefix-trie",
|
"prefix-trie",
|
||||||
"rand 0.10.1",
|
"rand 0.10.1",
|
||||||
@@ -2270,7 +2347,7 @@ dependencies = [
|
|||||||
"hickory-proto",
|
"hickory-proto",
|
||||||
"ipconfig",
|
"ipconfig",
|
||||||
"ipnet",
|
"ipnet",
|
||||||
"jni",
|
"jni 0.22.4",
|
||||||
"moka",
|
"moka",
|
||||||
"ndk-context",
|
"ndk-context",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -3102,6 +3179,22 @@ version = "1.0.18"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
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]]
|
[[package]]
|
||||||
name = "jni"
|
name = "jni"
|
||||||
version = "0.22.4"
|
version = "0.22.4"
|
||||||
@@ -3463,6 +3556,15 @@ version = "0.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f"
|
checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mach2"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "malloc_buf"
|
name = "malloc_buf"
|
||||||
version = "0.0.6"
|
version = "0.0.6"
|
||||||
@@ -3596,7 +3698,7 @@ dependencies = [
|
|||||||
"dispatch",
|
"dispatch",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-lite",
|
"futures-lite",
|
||||||
"jni",
|
"jni 0.22.4",
|
||||||
"ndk-context",
|
"ndk-context",
|
||||||
"objc2 0.6.4",
|
"objc2 0.6.4",
|
||||||
"objc2-app-kit 0.3.2",
|
"objc2-app-kit 0.3.2",
|
||||||
@@ -3695,6 +3797,20 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "ndk"
|
name = "ndk"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -3704,7 +3820,7 @@ dependencies = [
|
|||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"jni-sys 0.3.1",
|
"jni-sys 0.3.1",
|
||||||
"log",
|
"log",
|
||||||
"ndk-sys",
|
"ndk-sys 0.6.0+11769913",
|
||||||
"num_enum",
|
"num_enum",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
@@ -3716,6 +3832,15 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
|
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]]
|
[[package]]
|
||||||
name = "ndk-sys"
|
name = "ndk-sys"
|
||||||
version = "0.6.0+11769913"
|
version = "0.6.0+11769913"
|
||||||
@@ -4466,6 +4591,29 @@ dependencies = [
|
|||||||
"objc2-foundation 0.2.2",
|
"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]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
@@ -4594,12 +4742,13 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "peerspeak"
|
name = "peerspeak"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"cpal",
|
||||||
"dirs",
|
"dirs",
|
||||||
"iced",
|
"iced",
|
||||||
"image",
|
"image",
|
||||||
@@ -5436,7 +5585,7 @@ checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"core-foundation 0.10.1",
|
"core-foundation 0.10.1",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
"jni",
|
"jni 0.22.4",
|
||||||
"log",
|
"log",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustls",
|
"rustls",
|
||||||
@@ -5877,7 +6026,7 @@ dependencies = [
|
|||||||
"fastrand",
|
"fastrand",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"memmap2",
|
"memmap2",
|
||||||
"ndk",
|
"ndk 0.9.0",
|
||||||
"objc2 0.6.4",
|
"objc2 0.6.4",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-core-graphics",
|
"objc2-core-graphics",
|
||||||
@@ -7162,7 +7311,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"metal",
|
"metal",
|
||||||
"naga",
|
"naga",
|
||||||
"ndk-sys",
|
"ndk-sys 0.6.0+11769913",
|
||||||
"objc",
|
"objc",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"ordered-float",
|
"ordered-float",
|
||||||
@@ -7247,6 +7396,16 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"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]]
|
[[package]]
|
||||||
name = "windows"
|
name = "windows"
|
||||||
version = "0.58.0"
|
version = "0.58.0"
|
||||||
@@ -7254,7 +7413,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
|
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-core 0.58.0",
|
"windows-core 0.58.0",
|
||||||
"windows-targets",
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7278,6 +7437,16 @@ dependencies = [
|
|||||||
"windows-core 0.62.2",
|
"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]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.58.0"
|
version = "0.58.0"
|
||||||
@@ -7288,7 +7457,7 @@ dependencies = [
|
|||||||
"windows-interface 0.58.0",
|
"windows-interface 0.58.0",
|
||||||
"windows-result 0.2.0",
|
"windows-result 0.2.0",
|
||||||
"windows-strings 0.1.0",
|
"windows-strings 0.1.0",
|
||||||
"windows-targets",
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7386,13 +7555,22 @@ dependencies = [
|
|||||||
"windows-strings 0.5.1",
|
"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]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
|
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-targets",
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7411,7 +7589,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
|
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-result 0.2.0",
|
"windows-result 0.2.0",
|
||||||
"windows-targets",
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7423,13 +7601,22 @@ dependencies = [
|
|||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.52.0"
|
version = "0.52.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-targets",
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7441,20 +7628,35 @@ dependencies = [
|
|||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "windows-targets"
|
name = "windows-targets"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows_aarch64_gnullvm",
|
"windows_aarch64_gnullvm 0.52.6",
|
||||||
"windows_aarch64_msvc",
|
"windows_aarch64_msvc 0.52.6",
|
||||||
"windows_i686_gnu",
|
"windows_i686_gnu 0.52.6",
|
||||||
"windows_i686_gnullvm",
|
"windows_i686_gnullvm",
|
||||||
"windows_i686_msvc",
|
"windows_i686_msvc 0.52.6",
|
||||||
"windows_x86_64_gnu",
|
"windows_x86_64_gnu 0.52.6",
|
||||||
"windows_x86_64_gnullvm",
|
"windows_x86_64_gnullvm 0.52.6",
|
||||||
"windows_x86_64_msvc",
|
"windows_x86_64_msvc 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7466,18 +7668,36 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_gnullvm"
|
name = "windows_aarch64_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "windows_aarch64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "windows_i686_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -7490,24 +7710,48 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "windows_i686_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
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]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "windows_x86_64_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
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]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnullvm"
|
name = "windows_x86_64_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
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]]
|
[[package]]
|
||||||
name = "windows_x86_64_msvc"
|
name = "windows_x86_64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -7536,7 +7780,7 @@ dependencies = [
|
|||||||
"js-sys",
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"memmap2",
|
"memmap2",
|
||||||
"ndk",
|
"ndk 0.9.0",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-app-kit 0.2.2",
|
"objc2-app-kit 0.2.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
|
|||||||
+27
-8
@@ -1,7 +1,10 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "peerspeak"
|
name = "peerspeak"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
edition = "2024"
|
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]
|
[lib]
|
||||||
name = "peerspeak"
|
name = "peerspeak"
|
||||||
@@ -27,17 +30,12 @@ bytes = "1.11.1"
|
|||||||
dirs = "6.0.0"
|
dirs = "6.0.0"
|
||||||
iced = { version = "0.14.0", features = ["canvas", "image"] }
|
iced = { version = "0.14.0", features = ["canvas", "image"] }
|
||||||
# W4 custom avatars: decode/resize an arbitrary user image (png/jpeg only to keep
|
# 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"] }
|
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 = "1.0.0-rc.0"
|
||||||
iroh-gossip = "0.99.0"
|
iroh-gossip = "0.99.0"
|
||||||
opus = "0.3.1"
|
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"
|
rand = "0.10.1"
|
||||||
ringbuf = "0.5.0"
|
ringbuf = "0.5.0"
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
@@ -45,3 +43,24 @@ serde_json = "1.0.150"
|
|||||||
thiserror = "2.0.18"
|
thiserror = "2.0.18"
|
||||||
tokio = { version = "1.52.3", features = ["full"] }
|
tokio = { version = "1.52.3", features = ["full"] }
|
||||||
tokio-stream = "0.1.18"
|
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(unix)'.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
|
||||||
+394
-153
@@ -32,6 +32,78 @@ pub enum Screen {
|
|||||||
Settings,
|
Settings,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SettingsCategory {
|
||||||
|
Audio,
|
||||||
|
Hotkeys,
|
||||||
|
Recording,
|
||||||
|
Profile,
|
||||||
|
Appearance,
|
||||||
|
Network,
|
||||||
|
Notifications,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SettingsCategory {
|
||||||
|
const ALL: [SettingsCategory; 7] = [
|
||||||
|
SettingsCategory::Audio,
|
||||||
|
SettingsCategory::Hotkeys,
|
||||||
|
SettingsCategory::Recording,
|
||||||
|
SettingsCategory::Profile,
|
||||||
|
SettingsCategory::Appearance,
|
||||||
|
SettingsCategory::Network,
|
||||||
|
SettingsCategory::Notifications,
|
||||||
|
];
|
||||||
|
|
||||||
|
fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
SettingsCategory::Audio => "Audio",
|
||||||
|
SettingsCategory::Hotkeys => "Hotkeys",
|
||||||
|
SettingsCategory::Recording => "Recording",
|
||||||
|
SettingsCategory::Profile => "Profile",
|
||||||
|
SettingsCategory::Appearance => "Appearance",
|
||||||
|
SettingsCategory::Network => "Network",
|
||||||
|
SettingsCategory::Notifications => "Notifications",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hint(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
SettingsCategory::Audio => "Devices, mic gate, echo",
|
||||||
|
SettingsCategory::Hotkeys => "Focused keyboard shortcuts",
|
||||||
|
SettingsCategory::Recording => "Mixed and stem capture",
|
||||||
|
SettingsCategory::Profile => "Avatar and identity",
|
||||||
|
SettingsCategory::Appearance => "Layout and theme",
|
||||||
|
SettingsCategory::Network => "Relay and privacy mode",
|
||||||
|
SettingsCategory::Notifications => "Chimes and sounds",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for SettingsCategory {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(self.label())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum HomeLayoutMode {
|
||||||
|
FocusedEmpty,
|
||||||
|
ThreeColumn,
|
||||||
|
Stacked,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn home_layout_mode(width: f32, has_recents: bool, has_friends: bool) -> HomeLayoutMode {
|
||||||
|
if width < 900.0 {
|
||||||
|
HomeLayoutMode::Stacked
|
||||||
|
} else if !has_recents && !has_friends {
|
||||||
|
HomeLayoutMode::FocusedEmpty
|
||||||
|
} else if width >= 1280.0 {
|
||||||
|
HomeLayoutMode::ThreeColumn
|
||||||
|
} else {
|
||||||
|
HomeLayoutMode::Stacked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// One rendered room-chat line. `mine` distinguishes our own (locally echoed)
|
/// One rendered room-chat line. `mine` distinguishes our own (locally echoed)
|
||||||
/// messages from peers' for colouring.
|
/// messages from peers' for colouring.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -175,6 +247,7 @@ pub enum AppMessage {
|
|||||||
EventOccurred(Event),
|
EventOccurred(Event),
|
||||||
NavigateToSettings,
|
NavigateToSettings,
|
||||||
NavigateBack,
|
NavigateBack,
|
||||||
|
SelectSettingsCategory(SettingsCategory),
|
||||||
ToggleNotifications(bool),
|
ToggleNotifications(bool),
|
||||||
ToggleEchoCancellation(bool),
|
ToggleEchoCancellation(bool),
|
||||||
CustomSoundPathChanged(Sound, String),
|
CustomSoundPathChanged(Sound, String),
|
||||||
@@ -298,6 +371,7 @@ pub struct AppState {
|
|||||||
ever_connected: HashSet<EndpointId>,
|
ever_connected: HashSet<EndpointId>,
|
||||||
controller: Arc<CoreController>,
|
controller: Arc<CoreController>,
|
||||||
current_screen: Screen,
|
current_screen: Screen,
|
||||||
|
settings_category: SettingsCategory,
|
||||||
/// Whether we're currently sharing our own screen (confirmed by the core).
|
/// Whether we're currently sharing our own screen (confirmed by the core).
|
||||||
self_sharing: bool,
|
self_sharing: bool,
|
||||||
/// Whether the `pixelpass` binary is available, gating the Share controls.
|
/// Whether the `pixelpass` binary is available, gating the Share controls.
|
||||||
@@ -435,6 +509,7 @@ impl Default for AppState {
|
|||||||
ever_connected: HashSet::new(),
|
ever_connected: HashSet::new(),
|
||||||
controller,
|
controller,
|
||||||
current_screen: Screen::Home,
|
current_screen: Screen::Home,
|
||||||
|
settings_category: SettingsCategory::Audio,
|
||||||
self_sharing: false,
|
self_sharing: false,
|
||||||
pixelpass_available,
|
pixelpass_available,
|
||||||
self_node_id: None,
|
self_node_id: None,
|
||||||
@@ -880,13 +955,15 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
state.friend_presence.insert(id, presence);
|
state.friend_presence.insert(id, presence);
|
||||||
}
|
}
|
||||||
UiEvent::PresenceModeReverted { mode } => {
|
UiEvent::PresenceModeReverted { mode } => {
|
||||||
// The Discoverable time-box elapsed; core dropped us back to
|
// Core corrected the committed presence mode. Mirror + persist so
|
||||||
// `mode` (Normal) and stopped publishing. Mirror + persist so the
|
// the picker reflects the discovery state the endpoint actually has.
|
||||||
// presence picker reflects it, and tell the user why it changed.
|
|
||||||
state.config.presence_mode = mode;
|
state.config.presence_mode = mode;
|
||||||
state.config.save();
|
state.config.save();
|
||||||
state.status_message =
|
state.status_message = if mode == PresenceMode::Normal {
|
||||||
"Discoverable timed out — back to Normal".to_string();
|
"Discoverable timed out — back to Normal".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Presence mode stayed {mode}")
|
||||||
|
};
|
||||||
}
|
}
|
||||||
UiEvent::ShutdownComplete => {
|
UiEvent::ShutdownComplete => {
|
||||||
if state.closing {
|
if state.closing {
|
||||||
@@ -1097,6 +1174,9 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
crate::recents::remove_recent(&mut state.config.recents, &ticket);
|
crate::recents::remove_recent(&mut state.config.recents, &ticket);
|
||||||
state.config.save();
|
state.config.save();
|
||||||
}
|
}
|
||||||
|
AppMessage::SelectSettingsCategory(category) => {
|
||||||
|
state.settings_category = category;
|
||||||
|
}
|
||||||
AppMessage::ToggleNotifications(enabled) => {
|
AppMessage::ToggleNotifications(enabled) => {
|
||||||
state.config.notifications_enabled = enabled;
|
state.config.notifications_enabled = enabled;
|
||||||
state.config.save();
|
state.config.save();
|
||||||
@@ -1270,12 +1350,28 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
// Defence in depth: only ever hand http(s) URLs to the opener. The
|
// Defence in depth: only ever hand http(s) URLs to the opener. The
|
||||||
// link span's href came from `linkify`, which only emits http/https,
|
// link span's href came from `linkify`, which only emits http/https,
|
||||||
// but re-check here so this can't be widened into launching arbitrary
|
// but re-check here so this can't be widened into launching arbitrary
|
||||||
// schemes/args. `xdg-open` receives the URL as a single argv entry
|
// schemes/args. Each opener receives the URL as a single argv entry
|
||||||
// (no shell), so there's no injection surface.
|
// (no shell), so there's no injection surface:
|
||||||
if (url.starts_with("http://") || url.starts_with("https://"))
|
// - Unix: `xdg-open <url>`.
|
||||||
&& let Err(e) = std::process::Command::new("xdg-open").arg(&url).spawn()
|
// - Windows: `rundll32 url.dll,FileProtocolHandler <url>` — opens the
|
||||||
{
|
// default browser without going through `cmd`/`start`, which would
|
||||||
crate::log_msg(&format!("Failed to open URL {url:?}: {e}"));
|
// otherwise re-parse `&` in query strings.
|
||||||
|
if url.starts_with("http://") || url.starts_with("https://") {
|
||||||
|
let spawned = {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
std::process::Command::new("xdg-open").arg(&url).spawn()
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
std::process::Command::new("rundll32")
|
||||||
|
.args(["url.dll,FileProtocolHandler", &url])
|
||||||
|
.spawn()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = spawned {
|
||||||
|
crate::log_msg(&format!("Failed to open URL {url:?}: {e}"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AppMessage::ToggleMicTest(enabled) => {
|
AppMessage::ToggleMicTest(enabled) => {
|
||||||
@@ -1544,7 +1640,7 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
selection: color_blue,
|
selection: color_blue,
|
||||||
};
|
};
|
||||||
|
|
||||||
let logo = text("PEERSPEAK").size(36).color(color_blue);
|
let logo = text("PEERSPEAK").size(38).color(color_blue);
|
||||||
let subtitle = text("NAT-traversing full-mesh voice chat").size(16).color(color_subtext);
|
let subtitle = text("NAT-traversing full-mesh voice chat").size(16).color(color_subtext);
|
||||||
|
|
||||||
let nickname_input = column![
|
let nickname_input = column![
|
||||||
@@ -1608,8 +1704,8 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
.align_x(iced::alignment::Horizontal::Center),
|
.align_x(iced::alignment::Horizontal::Center),
|
||||||
)
|
)
|
||||||
.style(c_style(color_mantle, color_surface, 12.0))
|
.style(c_style(color_mantle, color_surface, 12.0))
|
||||||
.padding(30)
|
.padding(32)
|
||||||
.width(380)
|
.width(420)
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1653,50 +1749,51 @@ fn recents_card(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let now = std::time::SystemTime::now()
|
let empty = state.config.recents.is_empty();
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
let content: Element<'_, AppMessage> = if empty {
|
||||||
.map(|d| d.as_secs())
|
column![
|
||||||
.unwrap_or(0);
|
text("RECENT ROOMS").size(14).color(color_subtext),
|
||||||
let mut rows = column![].spacing(6).width(iced::Length::Fill);
|
text("No recent rooms yet.").size(12).color(color_subtext),
|
||||||
if state.config.recents.is_empty() {
|
]
|
||||||
rows = rows.push(
|
.spacing(4)
|
||||||
text("No recent rooms yet — they'll appear here after you join one.")
|
.into()
|
||||||
.size(12)
|
} else {
|
||||||
.color(color_subtext),
|
let now = std::time::SystemTime::now()
|
||||||
);
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
}
|
.map(|d| d.as_secs())
|
||||||
for r in &state.config.recents {
|
.unwrap_or(0);
|
||||||
let label = {
|
let mut rows = column![].spacing(6).width(iced::Length::Fill);
|
||||||
let n = crate::sanitize::sanitize_name(&r.name);
|
for r in &state.config.recents {
|
||||||
if n.is_empty() { "Untitled room".to_string() } else { n }
|
let label = {
|
||||||
};
|
let n = crate::sanitize::sanitize_name(&r.name);
|
||||||
let when = crate::recents::relative_time(now, r.joined_at);
|
if n.is_empty() { "Untitled room".to_string() } else { n }
|
||||||
let entry = button(
|
};
|
||||||
row![
|
let when = crate::recents::relative_time(now, r.joined_at);
|
||||||
text(label).size(14).color(color_text),
|
let entry = button(
|
||||||
horizontal_space(),
|
row![
|
||||||
text(when).size(11).color(color_subtext),
|
text(label).size(14).color(color_text),
|
||||||
]
|
horizontal_space(),
|
||||||
.align_y(iced::alignment::Vertical::Center),
|
text(when).size(11).color(color_subtext),
|
||||||
)
|
]
|
||||||
.on_press(AppMessage::JoinRecent(r.ticket.clone()))
|
.align_y(iced::alignment::Vertical::Center),
|
||||||
.style(b_style(color_crust, color_surface, color_text, 6.0))
|
)
|
||||||
.padding(8)
|
.on_press(AppMessage::JoinRecent(r.ticket.clone()))
|
||||||
.width(iced::Length::Fill);
|
.style(b_style(color_crust, color_surface, color_text, 6.0))
|
||||||
rows = rows.push(
|
.padding(8)
|
||||||
row![
|
.width(iced::Length::Fill);
|
||||||
entry,
|
rows = rows.push(
|
||||||
button(text("✕").size(12))
|
row![
|
||||||
.on_press(AppMessage::RemoveRecent(r.ticket.clone()))
|
entry,
|
||||||
.style(b_style(color_surface, color_maroon, color_text, 6.0))
|
button(text("✕").size(12))
|
||||||
.padding(8),
|
.on_press(AppMessage::RemoveRecent(r.ticket.clone()))
|
||||||
]
|
.style(b_style(color_surface, color_maroon, color_text, 6.0))
|
||||||
.spacing(6)
|
.padding(8),
|
||||||
.align_y(iced::alignment::Vertical::Center),
|
]
|
||||||
);
|
.spacing(6)
|
||||||
}
|
.align_y(iced::alignment::Vertical::Center),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
container(
|
|
||||||
column![
|
column![
|
||||||
text("RECENT ROOMS").size(18).color(color_text),
|
text("RECENT ROOMS").size(18).color(color_text),
|
||||||
text("Rooms you've been in — click to hop back. Best-effort: only works while someone's still there.")
|
text("Rooms you've been in — click to hop back. Best-effort: only works while someone's still there.")
|
||||||
@@ -1705,11 +1802,14 @@ fn recents_card(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
vertical_space(10.0),
|
vertical_space(10.0),
|
||||||
rows,
|
rows,
|
||||||
]
|
]
|
||||||
.spacing(6),
|
.spacing(6)
|
||||||
)
|
.into()
|
||||||
.style(c_style(color_mantle, color_surface, 12.0))
|
};
|
||||||
.padding(24)
|
|
||||||
.width(380)
|
container(content)
|
||||||
|
.style(c_style(if empty { color_crust } else { color_mantle }, color_surface, 8.0))
|
||||||
|
.padding(if empty { 16 } else { 24 })
|
||||||
|
.width(if empty { 340 } else { 380 })
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1725,6 +1825,7 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
let color_red = pal.red;
|
let color_red = pal.red;
|
||||||
let color_maroon = pal.maroon;
|
let color_maroon = pal.maroon;
|
||||||
let color_green = pal.green;
|
let color_green = pal.green;
|
||||||
|
let has_friends = !state.friends.list().is_empty();
|
||||||
|
|
||||||
let c_style = move |bg: Color, b_color: Color, radius: f32| {
|
let c_style = move |bg: Color, b_color: Color, radius: f32| {
|
||||||
move |_theme: &Theme| container::Style {
|
move |_theme: &Theme| container::Style {
|
||||||
@@ -1763,9 +1864,9 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
|
|
||||||
// The live friends list: status dot, inline rename, short id, remove.
|
// The live friends list: status dot, inline rename, short id, remove.
|
||||||
let mut friend_rows = column![].spacing(6).width(iced::Length::Fill);
|
let mut friend_rows = column![].spacing(6).width(iced::Length::Fill);
|
||||||
if state.friends.list().is_empty() {
|
if !has_friends {
|
||||||
friend_rows = friend_rows.push(
|
friend_rows = friend_rows.push(
|
||||||
text("No friends yet — add one by their node ID below.")
|
text("No friends yet.")
|
||||||
.size(12)
|
.size(12)
|
||||||
.color(color_subtext),
|
.color(color_subtext),
|
||||||
);
|
);
|
||||||
@@ -1868,28 +1969,34 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
]
|
]
|
||||||
.spacing(4)
|
.spacing(4)
|
||||||
.width(iced::Length::Fill);
|
.width(iced::Length::Fill);
|
||||||
|
let intro: Element<'_, AppMessage> = if has_friends {
|
||||||
|
text("Who's online — click Join to hop into a friend's room.")
|
||||||
|
.size(11)
|
||||||
|
.color(color_subtext)
|
||||||
|
.into()
|
||||||
|
} else {
|
||||||
|
column![].into()
|
||||||
|
};
|
||||||
|
|
||||||
container(
|
container(
|
||||||
column![
|
column![
|
||||||
text("FRIENDS").size(18).color(color_text),
|
text("FRIENDS").size(if has_friends { 18 } else { 14 }).color(color_text),
|
||||||
text("Who's online — click Join to hop into a friend's room.")
|
intro,
|
||||||
.size(11)
|
vertical_space(if has_friends { 10.0 } else { 4.0 }),
|
||||||
.color(color_subtext),
|
|
||||||
vertical_space(10.0),
|
|
||||||
readonly_warning,
|
readonly_warning,
|
||||||
friend_rows,
|
friend_rows,
|
||||||
vertical_space(12.0),
|
vertical_space(if has_friends { 12.0 } else { 8.0 }),
|
||||||
text("Add a friend").size(13).color(color_subtext),
|
text("Add a friend").size(13).color(color_subtext),
|
||||||
add_form,
|
add_form,
|
||||||
vertical_space(14.0),
|
vertical_space(if has_friends { 14.0 } else { 10.0 }),
|
||||||
text("Your presence").size(13).color(color_subtext),
|
text("Your presence").size(13).color(color_subtext),
|
||||||
presence_picker,
|
presence_picker,
|
||||||
]
|
]
|
||||||
.spacing(6),
|
.spacing(6),
|
||||||
)
|
)
|
||||||
.style(c_style(color_mantle, color_surface, 12.0))
|
.style(c_style(color_mantle, color_surface, 12.0))
|
||||||
.padding(24)
|
.padding(if has_friends { 24 } else { 18 })
|
||||||
.width(460)
|
.width(if has_friends { 460 } else { 360 })
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1959,19 +2066,23 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let top_bar = row![
|
// The Hotkeys info button is always available (hotkeys are app-wide). The
|
||||||
horizontal_space(),
|
// room-layout button is hidden on the Home screen, leaving only it + Settings.
|
||||||
tooltip(
|
let info_button = tooltip(
|
||||||
button(icon(IconKind::Info, 18.0, color_text))
|
button(icon(IconKind::Info, 18.0, color_text))
|
||||||
.on_press(AppMessage::OpenHotkeyInfo)
|
.on_press(AppMessage::OpenHotkeyInfo)
|
||||||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||||||
.padding(8),
|
.padding(8),
|
||||||
container(text("Hotkeys").size(11).color(color_text))
|
container(text("Hotkeys").size(11).color(color_text))
|
||||||
.padding(8)
|
.padding(8)
|
||||||
.style(c_style(color_crust, color_surface, 6.0)),
|
.style(c_style(color_crust, color_surface, 6.0)),
|
||||||
iced::widget::tooltip::Position::Bottom,
|
iced::widget::tooltip::Position::Bottom,
|
||||||
)
|
)
|
||||||
.gap(8),
|
.gap(8);
|
||||||
|
|
||||||
|
let layout_button: Element<'_, AppMessage> = if state.current_screen == Screen::Home {
|
||||||
|
iced::widget::Space::new().width(0.0).height(0.0).into()
|
||||||
|
} else {
|
||||||
tooltip(
|
tooltip(
|
||||||
button(
|
button(
|
||||||
Canvas::new(LayoutIcon { fg: color_text })
|
Canvas::new(LayoutIcon { fg: color_text })
|
||||||
@@ -1986,7 +2097,14 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
.style(c_style(color_crust, color_surface, 6.0)),
|
.style(c_style(color_crust, color_surface, 6.0)),
|
||||||
iced::widget::tooltip::Position::Bottom,
|
iced::widget::tooltip::Position::Bottom,
|
||||||
)
|
)
|
||||||
.gap(8),
|
.gap(8)
|
||||||
|
.into()
|
||||||
|
};
|
||||||
|
|
||||||
|
let top_bar = row![
|
||||||
|
horizontal_space(),
|
||||||
|
info_button,
|
||||||
|
layout_button,
|
||||||
button(
|
button(
|
||||||
row![
|
row![
|
||||||
icon(IconKind::Settings, 15.0, color_text),
|
icon(IconKind::Settings, 15.0, color_text),
|
||||||
@@ -2404,9 +2522,8 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
|
|
||||||
// Presence + Friends moved to the home screen (see `friends_panel`).
|
// Presence + Friends moved to the home screen (see `friends_panel`).
|
||||||
|
|
||||||
let settings_content = scrollable(
|
let settings_body: Element<'_, AppMessage> = match state.settings_category {
|
||||||
column![
|
SettingsCategory::Audio => column![
|
||||||
// --- Audio Devices ---
|
|
||||||
section_header("Audio Devices"),
|
section_header("Audio Devices"),
|
||||||
row![
|
row![
|
||||||
column![
|
column![
|
||||||
@@ -2435,8 +2552,6 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
].spacing(8).width(iced::Length::Fill),
|
].spacing(8).width(iced::Length::Fill),
|
||||||
].spacing(20).align_y(iced::alignment::Vertical::Top).width(iced::Length::Fill),
|
].spacing(20).align_y(iced::alignment::Vertical::Top).width(iced::Length::Fill),
|
||||||
vertical_space(section_gap),
|
vertical_space(section_gap),
|
||||||
|
|
||||||
// --- Microphone ---
|
|
||||||
section_header("Microphone"),
|
section_header("Microphone"),
|
||||||
column![
|
column![
|
||||||
mic_meter,
|
mic_meter,
|
||||||
@@ -2447,14 +2562,18 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
.on_toggle(AppMessage::ToggleEchoCancellation),
|
.on_toggle(AppMessage::ToggleEchoCancellation),
|
||||||
text("Cancels speaker echo + suppresses noise (PipeWire). Takes effect on your next room join.").size(11).color(color_subtext),
|
text("Cancels speaker echo + suppresses noise (PipeWire). Takes effect on your next room join.").size(11).color(color_subtext),
|
||||||
].spacing(8).width(iced::Length::Fill),
|
].spacing(8).width(iced::Length::Fill),
|
||||||
vertical_space(section_gap),
|
]
|
||||||
|
.spacing(10)
|
||||||
// --- Hotkeys ---
|
.width(iced::Length::Fill)
|
||||||
|
.into(),
|
||||||
|
SettingsCategory::Hotkeys => column![
|
||||||
section_header("Hotkeys"),
|
section_header("Hotkeys"),
|
||||||
hotkey_section,
|
hotkey_section,
|
||||||
vertical_space(section_gap),
|
]
|
||||||
|
.spacing(10)
|
||||||
// --- Recording ---
|
.width(iced::Length::Fill)
|
||||||
|
.into(),
|
||||||
|
SettingsCategory::Recording => column![
|
||||||
section_header("Recording"),
|
section_header("Recording"),
|
||||||
column![
|
column![
|
||||||
mode_radio(RecordingMode::Mixed, "Mixed (single file)"),
|
mode_radio(RecordingMode::Mixed, "Mixed (single file)"),
|
||||||
@@ -2463,22 +2582,21 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
vertical_space(2.0),
|
vertical_space(2.0),
|
||||||
text("Hover an option for what it does. Saved to ~/peerspeak-recordings/ — Multitrack/Both as a timestamped folder of tracks, Mixed as a single file. Applies to your next recording.").size(11).color(color_subtext),
|
text("Hover an option for what it does. Saved to ~/peerspeak-recordings/ — Multitrack/Both as a timestamped folder of tracks, Mixed as a single file. Applies to your next recording.").size(11).color(color_subtext),
|
||||||
].spacing(8).width(iced::Length::Fill),
|
].spacing(8).width(iced::Length::Fill),
|
||||||
|
]
|
||||||
|
.spacing(10)
|
||||||
|
.width(iced::Length::Fill)
|
||||||
|
.into(),
|
||||||
|
SettingsCategory::Profile => column![
|
||||||
|
section_header("Avatar"),
|
||||||
|
avatar_section,
|
||||||
vertical_space(section_gap),
|
vertical_space(section_gap),
|
||||||
|
section_header("Identity"),
|
||||||
// --- Network & Privacy ---
|
identity_section,
|
||||||
section_header("Network & Privacy"),
|
]
|
||||||
column![
|
.spacing(10)
|
||||||
pick_list(
|
.width(iced::Length::Fill)
|
||||||
&NetworkMode::ALL[..],
|
.into(),
|
||||||
Some(state.config.network_mode),
|
SettingsCategory::Appearance => column![
|
||||||
AppMessage::NetworkModeSelected,
|
|
||||||
).width(iced::Length::Fill),
|
|
||||||
text(network_mode_hint(state.config.network_mode)).size(11).color(color_subtext),
|
|
||||||
text("Takes effect on your next room join.").size(11).color(color_subtext),
|
|
||||||
].spacing(4).width(iced::Length::Fill),
|
|
||||||
vertical_space(section_gap),
|
|
||||||
|
|
||||||
// --- Room Layout ---
|
|
||||||
section_header("Room Layout"),
|
section_header("Room Layout"),
|
||||||
column![
|
column![
|
||||||
row![
|
row![
|
||||||
@@ -2489,25 +2607,28 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
text("How the in-call room is arranged. Applies live.").size(11).color(color_subtext),
|
text("How the in-call room is arranged. Applies live.").size(11).color(color_subtext),
|
||||||
].spacing(8).width(iced::Length::Fill),
|
].spacing(8).width(iced::Length::Fill),
|
||||||
vertical_space(section_gap),
|
vertical_space(section_gap),
|
||||||
|
|
||||||
// --- Theme ---
|
|
||||||
section_header("Theme"),
|
section_header("Theme"),
|
||||||
theme_section,
|
theme_section,
|
||||||
vertical_space(section_gap),
|
]
|
||||||
|
.spacing(10)
|
||||||
// --- Avatar ---
|
.width(iced::Length::Fill)
|
||||||
section_header("Avatar"),
|
.into(),
|
||||||
avatar_section,
|
SettingsCategory::Network => column![
|
||||||
vertical_space(section_gap),
|
section_header("Network & Privacy"),
|
||||||
|
column![
|
||||||
// --- Identity ---
|
pick_list(
|
||||||
section_header("Identity"),
|
&NetworkMode::ALL[..],
|
||||||
identity_section,
|
Some(state.config.network_mode),
|
||||||
vertical_space(section_gap),
|
AppMessage::NetworkModeSelected,
|
||||||
|
).width(iced::Length::Fill),
|
||||||
// (Presence + Friends now live on the home screen.)
|
text(network_mode_hint(state.config.network_mode)).size(11).color(color_subtext),
|
||||||
|
text("Takes effect on your next room join.").size(11).color(color_subtext),
|
||||||
// --- Notifications & Sounds ---
|
].spacing(4).width(iced::Length::Fill),
|
||||||
|
]
|
||||||
|
.spacing(10)
|
||||||
|
.width(iced::Length::Fill)
|
||||||
|
.into(),
|
||||||
|
SettingsCategory::Notifications => column![
|
||||||
section_header("Notifications & Sounds"),
|
section_header("Notifications & Sounds"),
|
||||||
column![
|
column![
|
||||||
checkbox(state.config.notifications_enabled)
|
checkbox(state.config.notifications_enabled)
|
||||||
@@ -2535,9 +2656,92 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
]
|
]
|
||||||
.spacing(10)
|
.spacing(10)
|
||||||
.width(iced::Length::Fill)
|
.width(iced::Length::Fill)
|
||||||
)
|
.into(),
|
||||||
.width(iced::Length::Fill)
|
};
|
||||||
.height(iced::Length::Fill);
|
|
||||||
|
let category_button = |category: SettingsCategory| -> Element<'_, AppMessage> {
|
||||||
|
let selected = state.settings_category == category;
|
||||||
|
let label_color = if selected { color_blue } else { color_text };
|
||||||
|
let border_color = if selected { color_blue } else { Color::TRANSPARENT };
|
||||||
|
let bg = if selected { color_surface } else { Color::TRANSPARENT };
|
||||||
|
button(
|
||||||
|
container(
|
||||||
|
column![
|
||||||
|
text(category.label()).size(14).color(label_color),
|
||||||
|
text(category.hint()).size(11).color(color_subtext),
|
||||||
|
]
|
||||||
|
.spacing(2)
|
||||||
|
.width(iced::Length::Fill),
|
||||||
|
)
|
||||||
|
.width(iced::Length::Fill),
|
||||||
|
)
|
||||||
|
.on_press(AppMessage::SelectSettingsCategory(category))
|
||||||
|
.style(move |_theme: &Theme, status: button::Status| {
|
||||||
|
let active_bg = match status {
|
||||||
|
button::Status::Hovered if selected => color_surface,
|
||||||
|
button::Status::Hovered => color_crust,
|
||||||
|
_ => bg,
|
||||||
|
};
|
||||||
|
button::Style {
|
||||||
|
background: Some(Background::Color(active_bg)),
|
||||||
|
text_color: label_color,
|
||||||
|
border: Border {
|
||||||
|
color: border_color,
|
||||||
|
width: if selected { 1.0 } else { 0.0 },
|
||||||
|
radius: 8.0.into(),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.padding(10)
|
||||||
|
.width(iced::Length::Fill)
|
||||||
|
.into()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut settings_nav = column![
|
||||||
|
text("SETTINGS").size(11).color(color_subtext),
|
||||||
|
]
|
||||||
|
.spacing(8)
|
||||||
|
.width(iced::Length::Fill);
|
||||||
|
for category in SettingsCategory::ALL {
|
||||||
|
settings_nav = settings_nav.push(category_button(category));
|
||||||
|
}
|
||||||
|
let settings_nav = container(settings_nav)
|
||||||
|
.padding(12)
|
||||||
|
.width(iced::Length::Fixed(220.0))
|
||||||
|
.height(iced::Length::Fill)
|
||||||
|
.style(c_style(color_crust, color_surface, 8.0));
|
||||||
|
|
||||||
|
let settings_content: Element<'_, AppMessage> = if state.window_size.width < 820.0 {
|
||||||
|
scrollable(
|
||||||
|
column![
|
||||||
|
text("Category").size(12).color(color_subtext),
|
||||||
|
pick_list(
|
||||||
|
&SettingsCategory::ALL[..],
|
||||||
|
Some(state.settings_category),
|
||||||
|
AppMessage::SelectSettingsCategory,
|
||||||
|
).width(iced::Length::Fill),
|
||||||
|
vertical_space(10.0),
|
||||||
|
settings_body,
|
||||||
|
]
|
||||||
|
.spacing(8)
|
||||||
|
.width(iced::Length::Fill),
|
||||||
|
)
|
||||||
|
.width(iced::Length::Fill)
|
||||||
|
.height(iced::Length::Fill)
|
||||||
|
.into()
|
||||||
|
} else {
|
||||||
|
row![
|
||||||
|
settings_nav,
|
||||||
|
scrollable(settings_body)
|
||||||
|
.width(iced::Length::Fill)
|
||||||
|
.height(iced::Length::Fill),
|
||||||
|
]
|
||||||
|
.spacing(16)
|
||||||
|
.width(iced::Length::Fill)
|
||||||
|
.height(iced::Length::Fill)
|
||||||
|
.into()
|
||||||
|
};
|
||||||
|
|
||||||
// Sticky header bar: stays fixed above the scrollable content so the Back
|
// Sticky header bar: stays fixed above the scrollable content so the Back
|
||||||
// button is always reachable. The "Settings" title is centered by flanking
|
// button is always reachable. The "Settings" title is centered by flanking
|
||||||
@@ -2597,29 +2801,44 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
|
|
||||||
if state.current_screen == Screen::Home {
|
if state.current_screen == Screen::Home {
|
||||||
// --- HOME SCREEN ---
|
// --- HOME SCREEN ---
|
||||||
// Two cards: Connect (left) + the live Friends list (right). They sit
|
// Keep Create/Join dominant on a fresh install. Once Recents or Friends
|
||||||
// side-by-side when the window is wide enough, and stack vertically on a
|
// has real content, the wider three-card layout returns.
|
||||||
// narrow window so the Friends card never gets crushed — below ~860px the
|
let has_recents = !state.config.recents.is_empty();
|
||||||
// fixed-width Connect card would otherwise squeeze it until its node-ID
|
let has_friends = !state.friends.list().is_empty();
|
||||||
// field and remove button clip away. `responsive` measures the available
|
|
||||||
// width each layout pass and picks the orientation accordingly.
|
|
||||||
// Three cards: Recents | Connect | Friends, side-by-side when there's room.
|
|
||||||
// Three 380–460px cards need ~1280px to fit in a row, so below that the
|
|
||||||
// `responsive` measure stacks them in a column (Connect first — the primary
|
|
||||||
// action) rather than letting the row clip. Recents always shows (empty-
|
|
||||||
// state hint when no history) for parity with the Friends card.
|
|
||||||
let body = responsive(move |size| {
|
let body = responsive(move |size| {
|
||||||
let cards: Element<AppMessage> = if size.width < 1280.0 {
|
let cards: Element<AppMessage> =
|
||||||
column![connect_card(state), recents_card(state), friends_panel(state)]
|
match home_layout_mode(size.width, has_recents, has_friends) {
|
||||||
.spacing(20)
|
HomeLayoutMode::FocusedEmpty => row![
|
||||||
.align_x(iced::alignment::Horizontal::Center)
|
connect_card(state),
|
||||||
.into()
|
column![friends_panel(state), recents_card(state)]
|
||||||
} else {
|
.spacing(16)
|
||||||
row![recents_card(state), connect_card(state), friends_panel(state)]
|
.width(iced::Length::Fixed(360.0)),
|
||||||
|
]
|
||||||
|
.spacing(22)
|
||||||
|
.align_y(iced::alignment::Vertical::Top)
|
||||||
|
.into(),
|
||||||
|
HomeLayoutMode::ThreeColumn => row![
|
||||||
|
recents_card(state),
|
||||||
|
connect_card(state),
|
||||||
|
friends_panel(state),
|
||||||
|
]
|
||||||
.spacing(20)
|
.spacing(20)
|
||||||
.align_y(iced::alignment::Vertical::Top)
|
.align_y(iced::alignment::Vertical::Top)
|
||||||
.into()
|
.into(),
|
||||||
};
|
HomeLayoutMode::Stacked => {
|
||||||
|
let mut stack = column![connect_card(state)]
|
||||||
|
.spacing(20)
|
||||||
|
.align_x(iced::alignment::Horizontal::Center);
|
||||||
|
if has_recents {
|
||||||
|
stack = stack.push(recents_card(state));
|
||||||
|
}
|
||||||
|
stack = stack.push(friends_panel(state));
|
||||||
|
if !has_recents {
|
||||||
|
stack = stack.push(recents_card(state));
|
||||||
|
}
|
||||||
|
stack.into()
|
||||||
|
}
|
||||||
|
};
|
||||||
scrollable(container(cards).center_x(iced::Length::Fill))
|
scrollable(container(cards).center_x(iced::Length::Fill))
|
||||||
.width(iced::Length::Fill)
|
.width(iced::Length::Fill)
|
||||||
.into()
|
.into()
|
||||||
@@ -4517,6 +4736,28 @@ mod tests {
|
|||||||
assert_eq!(format_duration(3661), "1:01:01");
|
assert_eq!(format_duration(3661), "1:01:01");
|
||||||
assert_eq!(format_duration(3725), "1:02:05");
|
assert_eq!(format_duration(3725), "1:02:05");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_categories_are_stable_and_grouped_for_navigation() {
|
||||||
|
use super::SettingsCategory;
|
||||||
|
let labels: Vec<_> = SettingsCategory::ALL.iter().map(|c| c.label()).collect();
|
||||||
|
assert_eq!(
|
||||||
|
labels,
|
||||||
|
vec!["Audio", "Hotkeys", "Recording", "Profile", "Appearance", "Network", "Notifications"]
|
||||||
|
);
|
||||||
|
assert_eq!(SettingsCategory::Audio.hint(), "Devices, mic gate, echo");
|
||||||
|
assert_eq!(SettingsCategory::Profile.hint(), "Avatar and identity");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn home_layout_prioritizes_connect_on_empty_home() {
|
||||||
|
use super::{home_layout_mode, HomeLayoutMode};
|
||||||
|
assert_eq!(home_layout_mode(1280.0, false, false), HomeLayoutMode::FocusedEmpty);
|
||||||
|
assert_eq!(home_layout_mode(760.0, false, false), HomeLayoutMode::Stacked);
|
||||||
|
assert_eq!(home_layout_mode(1280.0, true, false), HomeLayoutMode::ThreeColumn);
|
||||||
|
assert_eq!(home_layout_mode(1100.0, true, true), HomeLayoutMode::Stacked);
|
||||||
|
}
|
||||||
|
|
||||||
use super::{clamp_chat_height, clamp_participants_width, CHAT_MIN_H, PARTICIPANTS_MIN_W};
|
use super::{clamp_chat_height, clamp_participants_width, CHAT_MIN_H, PARTICIPANTS_MIN_W};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -0,0 +1,602 @@
|
|||||||
|
//! Windows audio backend — cpal / WASAPI (Phase 1).
|
||||||
|
//!
|
||||||
|
//! Implements [`AudioBackend`] on top of [`cpal`], which wraps WASAPI on Windows.
|
||||||
|
//! It is the Windows counterpart to `pipewire_impl.rs` and deliberately preserves
|
||||||
|
//! the exact same contract so the rest of the app (mixer, encoder, jitter buffer)
|
||||||
|
//! is unchanged:
|
||||||
|
//!
|
||||||
|
//! - **Capture**: mono, 48 kHz, S16 PCM, emitted as `Vec<i16>` frames of
|
||||||
|
//! [`CAPTURE_FRAME`] (960 = 20 ms) samples — matching the encoder/jitter frame.
|
||||||
|
//! - **Playback**: stereo interleaved ([`PLAYBACK_CHANNELS`]) S16 PCM at 48 kHz,
|
||||||
|
//! drained from a ring buffer that is paced to the device's hardware clock via
|
||||||
|
//! `ring_fill` exactly as the PipeWire backend does.
|
||||||
|
//!
|
||||||
|
//! ## Threading and the `!Send` stream
|
||||||
|
//!
|
||||||
|
//! `cpal::Stream` is `!Send` (some backends require it to be created and dropped
|
||||||
|
//! on the same thread), but [`AudioBackend`] is `Send + Sync` and the backend is
|
||||||
|
//! shared through an `Arc`. So the stream never lives in the struct: each of
|
||||||
|
//! `start_capture`/`start_playback` spawns one owning thread that builds the
|
||||||
|
//! stream, plays it, and keeps it alive until the per-worker `running` flag flips
|
||||||
|
//! (set by `stop`). The struct holds only `Send` handles (the flag + the join
|
||||||
|
//! handle). The stream's RT callback does the actual audio work; the owning
|
||||||
|
//! thread additionally feeds the playback ring from the network mixer.
|
||||||
|
//!
|
||||||
|
//! ## Sample rate
|
||||||
|
//!
|
||||||
|
//! The whole pipeline assumes 48 kHz (Opus + the 960-sample frame). Phase 1 only
|
||||||
|
//! selects a native-48 kHz device config; if the device can't do 48 kHz we return
|
||||||
|
//! a clear error rather than silently producing pitch-shifted audio. Arbitrary
|
||||||
|
//! sample-rate support (resampling) is a Phase 1.1 follow-up.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||||
|
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::thread::{self, JoinHandle};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||||
|
use cpal::{Device, FromSample, Sample, SampleFormat, SampleRate, SizedSample, Stream, StreamConfig};
|
||||||
|
use ringbuf::{
|
||||||
|
traits::{Consumer, Producer, Split},
|
||||||
|
HeapRb,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{AudioBackend, AudioError, PLAYBACK_CHANNELS, PLAYBACK_TARGET_SAMPLES};
|
||||||
|
|
||||||
|
/// The one sample rate the pipeline supports (Opus + the 20 ms frame).
|
||||||
|
const SAMPLE_RATE: u32 = 48_000;
|
||||||
|
/// Mono capture frame: 960 samples = 20 ms @ 48 kHz. Matches the PipeWire backend
|
||||||
|
/// and `core::jitter::FRAME_SAMPLES`.
|
||||||
|
const CAPTURE_FRAME: usize = 960;
|
||||||
|
/// Playback ring capacity in interleaved samples: 200 ms of stereo @ 48 kHz.
|
||||||
|
/// Comfortably above [`PLAYBACK_TARGET_SAMPLES`] so the clock-paced producer has
|
||||||
|
/// headroom and never has to drop frames in steady state.
|
||||||
|
const RING_CAPACITY: usize = 9600 * PLAYBACK_CHANNELS;
|
||||||
|
/// How often a blocked playback worker re-checks its `running` flag, bounding how
|
||||||
|
/// long `stop()` can take to join it (mirrors the PipeWire backend's `WORKER_POLL`).
|
||||||
|
const WORKER_POLL: Duration = Duration::from_millis(100);
|
||||||
|
|
||||||
|
/// Windows audio backend. See module docs.
|
||||||
|
pub struct CpalBackend {
|
||||||
|
capture: Mutex<Option<StreamWorker>>,
|
||||||
|
playback: Mutex<Option<StreamWorker>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A spawned owning thread plus the flag that tells it to drop its stream and exit.
|
||||||
|
struct StreamWorker {
|
||||||
|
running: Arc<AtomicBool>,
|
||||||
|
thread: JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CpalBackend {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
capture: Mutex::new(None),
|
||||||
|
playback: Mutex::new(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CpalBackend {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioBackend for CpalBackend {
|
||||||
|
fn start_capture(
|
||||||
|
&self,
|
||||||
|
tx: Sender<Vec<i16>>,
|
||||||
|
target_node: Option<String>,
|
||||||
|
) -> Result<(), AudioError> {
|
||||||
|
let mut guard = self.capture.lock().unwrap();
|
||||||
|
if guard.is_some() {
|
||||||
|
return Err(AudioError::Stream("Capture already started".to_string()));
|
||||||
|
}
|
||||||
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
|
let running_thread = running.clone();
|
||||||
|
let thread = thread::Builder::new()
|
||||||
|
.name("peerspeak-cpal-capture".to_string())
|
||||||
|
.spawn(move || {
|
||||||
|
if let Err(e) = run_capture(tx, target_node, running_thread) {
|
||||||
|
crate::log_msg(&format!("cpal capture error: {e}"));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map_err(|e| AudioError::Init(e.to_string()))?;
|
||||||
|
*guard = Some(StreamWorker { running, thread });
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_playback(
|
||||||
|
&self,
|
||||||
|
rx: Receiver<Vec<i16>>,
|
||||||
|
target_node: Option<String>,
|
||||||
|
ring_fill: Arc<AtomicUsize>,
|
||||||
|
) -> Result<(), AudioError> {
|
||||||
|
let mut guard = self.playback.lock().unwrap();
|
||||||
|
if guard.is_some() {
|
||||||
|
return Err(AudioError::Stream("Playback already started".to_string()));
|
||||||
|
}
|
||||||
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
|
let running_thread = running.clone();
|
||||||
|
let thread = thread::Builder::new()
|
||||||
|
.name("peerspeak-cpal-playback".to_string())
|
||||||
|
.spawn(move || {
|
||||||
|
if let Err(e) = run_playback(rx, target_node, ring_fill, running_thread) {
|
||||||
|
crate::log_msg(&format!("cpal playback error: {e}"));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map_err(|e| AudioError::Init(e.to_string()))?;
|
||||||
|
*guard = Some(StreamWorker { running, thread });
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop(&self) -> Result<(), AudioError> {
|
||||||
|
for slot in [&self.capture, &self.playback] {
|
||||||
|
if let Some(worker) = slot.lock().unwrap().take() {
|
||||||
|
worker.running.store(false, Ordering::Relaxed);
|
||||||
|
let _ = worker.thread.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Device / config selection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Resolve a device (by `target` name, else the system default) and a stream
|
||||||
|
/// config running natively at [`SAMPLE_RATE`].
|
||||||
|
///
|
||||||
|
/// For output we require [`PLAYBACK_CHANNELS`] (stereo) so the interleaved ring
|
||||||
|
/// maps 1:1 to the device buffer; for input we prefer mono but accept any channel
|
||||||
|
/// count and downmix. A device with no 48 kHz config is a hard error (no
|
||||||
|
/// resampling yet — see module docs).
|
||||||
|
fn resolve(
|
||||||
|
output: bool,
|
||||||
|
target: Option<String>,
|
||||||
|
) -> Result<(Device, StreamConfig, SampleFormat), AudioError> {
|
||||||
|
let host = cpal::default_host();
|
||||||
|
|
||||||
|
let default = || {
|
||||||
|
if output {
|
||||||
|
host.default_output_device()
|
||||||
|
} else {
|
||||||
|
host.default_input_device()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let device = match target {
|
||||||
|
Some(name) => find_device_by_name(&host, output, &name).or_else(default),
|
||||||
|
None => default(),
|
||||||
|
}
|
||||||
|
.ok_or_else(|| AudioError::Device("no audio device available".to_string()))?;
|
||||||
|
|
||||||
|
let supported = choose_config(&device, output)?;
|
||||||
|
let sample_format = supported.sample_format();
|
||||||
|
let config = supported.config();
|
||||||
|
Ok((device, config, sample_format))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_device_by_name(host: &cpal::Host, output: bool, name: &str) -> Option<Device> {
|
||||||
|
let devices = if output {
|
||||||
|
host.output_devices().ok()?
|
||||||
|
} else {
|
||||||
|
host.input_devices().ok()?
|
||||||
|
};
|
||||||
|
devices.into_iter().find(|d| d.name().is_ok_and(|n| n == name))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pick a supported config at exactly [`SAMPLE_RATE`]. Output must be stereo;
|
||||||
|
/// input prefers mono, then any channel count (downmixed later).
|
||||||
|
fn choose_config(
|
||||||
|
device: &Device,
|
||||||
|
output: bool,
|
||||||
|
) -> Result<cpal::SupportedStreamConfig, AudioError> {
|
||||||
|
let ranges: Vec<cpal::SupportedStreamConfigRange> = if output {
|
||||||
|
device
|
||||||
|
.supported_output_configs()
|
||||||
|
.map_err(|e| AudioError::Device(e.to_string()))?
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
device
|
||||||
|
.supported_input_configs()
|
||||||
|
.map_err(|e| AudioError::Device(e.to_string()))?
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
// A range covers a sample-rate span and a fixed channel count.
|
||||||
|
let supports_48k = |r: &cpal::SupportedStreamConfigRange| {
|
||||||
|
r.min_sample_rate().0 <= SAMPLE_RATE && SAMPLE_RATE <= r.max_sample_rate().0
|
||||||
|
};
|
||||||
|
let pick = |channels: Option<u16>| {
|
||||||
|
ranges
|
||||||
|
.iter()
|
||||||
|
.find(|r| supports_48k(r) && channels.is_none_or(|c| r.channels() == c))
|
||||||
|
.cloned()
|
||||||
|
};
|
||||||
|
|
||||||
|
let chosen = if output {
|
||||||
|
pick(Some(PLAYBACK_CHANNELS as u16))
|
||||||
|
} else {
|
||||||
|
pick(Some(1)).or_else(|| pick(None))
|
||||||
|
};
|
||||||
|
|
||||||
|
chosen
|
||||||
|
.map(|r| r.with_sample_rate(SampleRate(SAMPLE_RATE)))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AudioError::Device(format!(
|
||||||
|
"device '{}' has no {SAMPLE_RATE} Hz {} config; resampling not yet implemented (Phase 1.1)",
|
||||||
|
device.name().unwrap_or_else(|_| "<unknown>".to_string()),
|
||||||
|
if output { "stereo output" } else { "input" },
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Capture
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn run_capture(
|
||||||
|
tx: Sender<Vec<i16>>,
|
||||||
|
target: Option<String>,
|
||||||
|
running: Arc<AtomicBool>,
|
||||||
|
) -> Result<(), AudioError> {
|
||||||
|
let (device, config, sample_format) = resolve(false, target)?;
|
||||||
|
let channels = config.channels as usize;
|
||||||
|
|
||||||
|
let stream = match sample_format {
|
||||||
|
SampleFormat::F32 => build_input::<f32>(&device, &config, tx, channels),
|
||||||
|
SampleFormat::I16 => build_input::<i16>(&device, &config, tx, channels),
|
||||||
|
SampleFormat::U16 => build_input::<u16>(&device, &config, tx, channels),
|
||||||
|
other => Err(AudioError::Stream(format!(
|
||||||
|
"unsupported capture sample format: {other:?}"
|
||||||
|
))),
|
||||||
|
}?;
|
||||||
|
|
||||||
|
stream.play().map_err(|e| AudioError::Stream(e.to_string()))?;
|
||||||
|
|
||||||
|
// The RT callback does the work; this thread just keeps `stream` alive until
|
||||||
|
// `stop()` flips the flag, at which point the stream is dropped (= stopped).
|
||||||
|
while running.load(Ordering::Relaxed) {
|
||||||
|
thread::sleep(WORKER_POLL);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_input<T>(
|
||||||
|
device: &Device,
|
||||||
|
config: &StreamConfig,
|
||||||
|
tx: Sender<Vec<i16>>,
|
||||||
|
channels: usize,
|
||||||
|
) -> Result<Stream, AudioError>
|
||||||
|
where
|
||||||
|
T: SizedSample + Send + 'static,
|
||||||
|
i16: FromSample<T>,
|
||||||
|
{
|
||||||
|
let mut acc = FrameAccumulator::new(CAPTURE_FRAME);
|
||||||
|
let err_fn = |e| crate::log_msg(&format!("cpal capture stream error: {e}"));
|
||||||
|
device
|
||||||
|
.build_input_stream::<T, _, _>(
|
||||||
|
config,
|
||||||
|
move |data: &[T], _| {
|
||||||
|
for frame in data.chunks_exact(channels) {
|
||||||
|
let mono = downmix_to_mono(frame);
|
||||||
|
if let Some(full) = acc.push(mono) {
|
||||||
|
// Consumer gone (call ended) → stop feeding; the owning
|
||||||
|
// thread will drop the stream on `stop()`.
|
||||||
|
if tx.send(full).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
err_fn,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(|e| AudioError::Stream(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Average a device frame's channels down to a single mono i16. For a 1-channel
|
||||||
|
/// device this is just the converted sample.
|
||||||
|
fn downmix_to_mono<T>(frame: &[T]) -> i16
|
||||||
|
where
|
||||||
|
T: Copy,
|
||||||
|
i16: FromSample<T>,
|
||||||
|
{
|
||||||
|
if frame.is_empty() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let sum: i32 = frame.iter().map(|&s| i16::from_sample(s) as i32).sum();
|
||||||
|
(sum / frame.len() as i32) as i16
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accumulates mono samples into fixed-size [`CAPTURE_FRAME`] frames. Pulled out
|
||||||
|
/// of the RT callback so the framing is unit-testable.
|
||||||
|
struct FrameAccumulator {
|
||||||
|
buf: Vec<i16>,
|
||||||
|
frame_len: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FrameAccumulator {
|
||||||
|
fn new(frame_len: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
buf: Vec::with_capacity(frame_len),
|
||||||
|
frame_len,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push one sample; returns a completed frame when the buffer fills.
|
||||||
|
fn push(&mut self, sample: i16) -> Option<Vec<i16>> {
|
||||||
|
self.buf.push(sample);
|
||||||
|
if self.buf.len() == self.frame_len {
|
||||||
|
Some(std::mem::replace(
|
||||||
|
&mut self.buf,
|
||||||
|
Vec::with_capacity(self.frame_len),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Playback
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn run_playback(
|
||||||
|
rx: Receiver<Vec<i16>>,
|
||||||
|
target: Option<String>,
|
||||||
|
ring_fill: Arc<AtomicUsize>,
|
||||||
|
running: Arc<AtomicBool>,
|
||||||
|
) -> Result<(), AudioError> {
|
||||||
|
let (device, config, sample_format) = resolve(true, target)?;
|
||||||
|
|
||||||
|
let rb = HeapRb::<i16>::new(RING_CAPACITY);
|
||||||
|
let (mut producer, consumer) = rb.split();
|
||||||
|
|
||||||
|
// Prefill to the steady-state depth so playout starts at target. `ring_fill`
|
||||||
|
// is an EXACT occupancy counter maintained by deltas (worker fetch_add on
|
||||||
|
// push, RT callback fetch_sub on pop) — not ringbuf's cached `occupied_len`,
|
||||||
|
// which is stale across the split halves and would lie high and starve the
|
||||||
|
// ring. See pipewire_impl.rs for the full rationale.
|
||||||
|
for _ in 0..PLAYBACK_TARGET_SAMPLES {
|
||||||
|
let _ = producer.try_push(0);
|
||||||
|
}
|
||||||
|
ring_fill.store(PLAYBACK_TARGET_SAMPLES, Ordering::Relaxed);
|
||||||
|
|
||||||
|
// Diagnostics (mirrors the PipeWire backend's playout-health line).
|
||||||
|
let underrun = Arc::new(AtomicU64::new(0));
|
||||||
|
let dropped = Arc::new(AtomicU64::new(0));
|
||||||
|
|
||||||
|
let stream = match sample_format {
|
||||||
|
SampleFormat::F32 => {
|
||||||
|
build_output::<f32, _>(&device, &config, consumer, ring_fill.clone(), underrun.clone())
|
||||||
|
}
|
||||||
|
SampleFormat::I16 => {
|
||||||
|
build_output::<i16, _>(&device, &config, consumer, ring_fill.clone(), underrun.clone())
|
||||||
|
}
|
||||||
|
SampleFormat::U16 => {
|
||||||
|
build_output::<u16, _>(&device, &config, consumer, ring_fill.clone(), underrun.clone())
|
||||||
|
}
|
||||||
|
other => Err(AudioError::Stream(format!(
|
||||||
|
"unsupported playback sample format: {other:?}"
|
||||||
|
))),
|
||||||
|
}?;
|
||||||
|
|
||||||
|
stream.play().map_err(|e| AudioError::Stream(e.to_string()))?;
|
||||||
|
|
||||||
|
let logger = spawn_health_logger(
|
||||||
|
running.clone(),
|
||||||
|
ring_fill.clone(),
|
||||||
|
underrun.clone(),
|
||||||
|
dropped.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Feed the ring from the network mixer until `stop()` flips `running` or the
|
||||||
|
// sender disconnects (call ended). Clock-paced production keeps the ring near
|
||||||
|
// target, so the drop path below should never fire in steady state.
|
||||||
|
drain_loop(&rx, &running, |frame| {
|
||||||
|
if ring_fill.load(Ordering::Relaxed) + frame.len() > RING_CAPACITY {
|
||||||
|
dropped.fetch_add(1, Ordering::Relaxed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for &sample in &frame {
|
||||||
|
let _ = producer.try_push(sample);
|
||||||
|
}
|
||||||
|
ring_fill.fetch_add(frame.len(), Ordering::Relaxed);
|
||||||
|
});
|
||||||
|
|
||||||
|
// We're shutting down (either stop() or disconnect). Ensure the logger sees it
|
||||||
|
// even on the disconnect path, then drop the stream.
|
||||||
|
running.store(false, Ordering::Relaxed);
|
||||||
|
let _ = logger.join();
|
||||||
|
drop(stream);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_output<T, C>(
|
||||||
|
device: &Device,
|
||||||
|
config: &StreamConfig,
|
||||||
|
mut consumer: C,
|
||||||
|
ring_fill: Arc<AtomicUsize>,
|
||||||
|
underrun: Arc<AtomicU64>,
|
||||||
|
) -> Result<Stream, AudioError>
|
||||||
|
where
|
||||||
|
T: SizedSample + FromSample<i16> + Send + 'static,
|
||||||
|
C: Consumer<Item = i16> + Send + 'static,
|
||||||
|
{
|
||||||
|
let err_fn = |e| crate::log_msg(&format!("cpal playback stream error: {e}"));
|
||||||
|
device
|
||||||
|
.build_output_stream::<T, _, _>(
|
||||||
|
config,
|
||||||
|
move |data: &mut [T], _| {
|
||||||
|
let (popped, starved) = fill_output(&mut consumer, data);
|
||||||
|
if starved > 0 {
|
||||||
|
underrun.fetch_add(starved, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
if popped > 0 {
|
||||||
|
// Decrement the exact occupancy by what we actually pulled
|
||||||
|
// (underruns removed nothing) so the mixer paces against the
|
||||||
|
// true ring depth.
|
||||||
|
ring_fill.fetch_sub(popped, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
err_fn,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(|e| AudioError::Stream(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drain the ring into the device buffer, substituting silence on underrun.
|
||||||
|
/// Returns `(samples_popped, samples_starved)`. RT-safe (wait-free `try_pop`).
|
||||||
|
fn fill_output<T, C>(consumer: &mut C, out: &mut [T]) -> (usize, u64)
|
||||||
|
where
|
||||||
|
T: Sample + FromSample<i16>,
|
||||||
|
C: Consumer<Item = i16>,
|
||||||
|
{
|
||||||
|
let mut popped = 0usize;
|
||||||
|
let mut starved = 0u64;
|
||||||
|
for slot in out.iter_mut() {
|
||||||
|
match consumer.try_pop() {
|
||||||
|
Some(v) => {
|
||||||
|
*slot = T::from_sample(v);
|
||||||
|
popped += 1;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
*slot = T::from_sample(0i16);
|
||||||
|
starved += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(popped, starved)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Once-per-second playout-health line (mirrors the PipeWire backend). Quiet
|
||||||
|
/// unless a second actually glitched, or `PEERSPEAK_AUDIO_VERBOSE` is set.
|
||||||
|
fn spawn_health_logger(
|
||||||
|
running: Arc<AtomicBool>,
|
||||||
|
ring_fill: Arc<AtomicUsize>,
|
||||||
|
underrun: Arc<AtomicU64>,
|
||||||
|
dropped: Arc<AtomicU64>,
|
||||||
|
) -> JoinHandle<()> {
|
||||||
|
let verbose = std::env::var_os("PEERSPEAK_AUDIO_VERBOSE").is_some();
|
||||||
|
thread::spawn(move || {
|
||||||
|
let (mut last_u, mut last_d) = (0u64, 0u64);
|
||||||
|
while running.load(Ordering::Relaxed) {
|
||||||
|
thread::sleep(Duration::from_secs(1));
|
||||||
|
let u = underrun.load(Ordering::Relaxed);
|
||||||
|
let d = dropped.load(Ordering::Relaxed);
|
||||||
|
let fill = ring_fill.load(Ordering::Relaxed);
|
||||||
|
let (du, dd) = (u - last_u, d - last_d);
|
||||||
|
last_u = u;
|
||||||
|
last_d = d;
|
||||||
|
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})",
|
||||||
|
fill / (48 * PLAYBACK_CHANNELS),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pump frames from `rx` to `on_frame` until `running` goes false or the sender
|
||||||
|
/// disconnects. The timed receive re-checks `running` at least every
|
||||||
|
/// [`WORKER_POLL`], so `stop()` can join the worker promptly instead of hanging
|
||||||
|
/// on a parked blocking `recv()` (same A7 fix as the PipeWire backend). Pure
|
||||||
|
/// w.r.t. its inputs, so it's unit-testable.
|
||||||
|
fn drain_loop(rx: &Receiver<Vec<i16>>, running: &AtomicBool, mut on_frame: impl FnMut(Vec<i16>)) {
|
||||||
|
while running.load(Ordering::Relaxed) {
|
||||||
|
match rx.recv_timeout(WORKER_POLL) {
|
||||||
|
Ok(frame) => on_frame(frame),
|
||||||
|
Err(RecvTimeoutError::Timeout) => continue,
|
||||||
|
Err(RecvTimeoutError::Disconnected) => return,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn downmix_averages_channels() {
|
||||||
|
assert_eq!(downmix_to_mono::<i16>(&[100, 100]), 100);
|
||||||
|
assert_eq!(downmix_to_mono::<i16>(&[100, -100]), 0);
|
||||||
|
assert_eq!(downmix_to_mono::<i16>(&[50]), 50);
|
||||||
|
assert_eq!(downmix_to_mono::<i16>(&[]), 0);
|
||||||
|
// 4-channel average rounds toward zero (integer division).
|
||||||
|
assert_eq!(downmix_to_mono::<i16>(&[10, 20, 30, 41]), 25);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frame_accumulator_emits_full_frames() {
|
||||||
|
let mut acc = FrameAccumulator::new(3);
|
||||||
|
assert_eq!(acc.push(1), None);
|
||||||
|
assert_eq!(acc.push(2), None);
|
||||||
|
assert_eq!(acc.push(3), Some(vec![1, 2, 3]));
|
||||||
|
// Resets for the next frame.
|
||||||
|
assert_eq!(acc.push(4), None);
|
||||||
|
assert_eq!(acc.push(5), None);
|
||||||
|
assert_eq!(acc.push(6), Some(vec![4, 5, 6]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fill_output_pops_then_substitutes_silence() {
|
||||||
|
let rb = HeapRb::<i16>::new(8);
|
||||||
|
let (mut prod, mut cons) = rb.split();
|
||||||
|
for v in [1, 2, 3] {
|
||||||
|
prod.try_push(v).unwrap();
|
||||||
|
}
|
||||||
|
let mut out = [0i16; 5];
|
||||||
|
let (popped, starved) = fill_output(&mut cons, &mut out);
|
||||||
|
assert_eq!(popped, 3);
|
||||||
|
assert_eq!(starved, 2);
|
||||||
|
assert_eq!(out, [1, 2, 3, 0, 0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drain_loop_exits_when_running_flips_even_with_sender_alive() {
|
||||||
|
let (tx, rx) = mpsc::channel::<Vec<i16>>();
|
||||||
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
|
let r2 = running.clone();
|
||||||
|
let h = thread::spawn(move || drain_loop(&rx, &r2, |_| {}));
|
||||||
|
thread::sleep(Duration::from_millis(50));
|
||||||
|
running.store(false, Ordering::Relaxed);
|
||||||
|
thread::sleep(WORKER_POLL + Duration::from_millis(150));
|
||||||
|
assert!(
|
||||||
|
h.is_finished(),
|
||||||
|
"drain_loop must exit after running=false even while the sender is alive"
|
||||||
|
);
|
||||||
|
drop(tx);
|
||||||
|
h.join().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drain_loop_returns_on_disconnect() {
|
||||||
|
let (tx, rx) = mpsc::channel::<Vec<i16>>();
|
||||||
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
|
drop(tx);
|
||||||
|
drain_loop(&rx, &running, |_| panic!("no frame should arrive"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drain_loop_delivers_frames() {
|
||||||
|
let (tx, rx) = mpsc::channel::<Vec<i16>>();
|
||||||
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
|
let r2 = running.clone();
|
||||||
|
let got = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let g2 = got.clone();
|
||||||
|
let h = thread::spawn(move || drain_loop(&rx, &r2, |f| g2.lock().unwrap().push(f)));
|
||||||
|
tx.send(vec![1, 2, 3]).unwrap();
|
||||||
|
tx.send(vec![4, 5]).unwrap();
|
||||||
|
thread::sleep(Duration::from_millis(50));
|
||||||
|
running.store(false, Ordering::Relaxed);
|
||||||
|
drop(tx);
|
||||||
|
h.join().unwrap();
|
||||||
|
assert_eq!(*got.lock().unwrap(), vec![vec![1, 2, 3], vec![4, 5]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,24 @@ pub mod gate;
|
|||||||
pub mod limiter;
|
pub mod limiter;
|
||||||
pub mod multitrack;
|
pub mod multitrack;
|
||||||
pub mod pan;
|
pub mod pan;
|
||||||
|
#[cfg(unix)]
|
||||||
pub mod pipewire_impl;
|
pub mod pipewire_impl;
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub mod cpal_impl;
|
||||||
pub mod pw_cli;
|
pub mod pw_cli;
|
||||||
pub mod recorder;
|
pub mod recorder;
|
||||||
|
|
||||||
|
/// 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/Unix → PipeWire ([`pipewire_impl::PipeWireBackend`]).
|
||||||
|
/// - Windows → cpal/WASAPI ([`cpal_impl::CpalBackend`]); a no-op stub until the
|
||||||
|
/// Phase 1 capture/playback implementation lands.
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub type PlatformAudioBackend = pipewire_impl::PipeWireBackend;
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub type PlatformAudioBackend = cpal_impl::CpalBackend;
|
||||||
|
|||||||
+111
-95
@@ -17,105 +17,121 @@
|
|||||||
//!
|
//!
|
||||||
//! Run: cargo run --bin audio_probe -- [freq_hz] [seconds] [target_node]
|
//! Run: cargo run --bin audio_probe -- [freq_hz] [seconds] [target_node]
|
||||||
//! e.g. cargo run --release --bin audio_probe -- 440 30
|
//! e.g. cargo run --release --bin audio_probe -- 440 30
|
||||||
|
//!
|
||||||
|
//! This probe exercises the PipeWire backend directly, so it is a Unix-only tool.
|
||||||
|
//! On non-Unix targets `main` is a stub that explains the limitation.
|
||||||
|
|
||||||
use std::io::{BufRead, BufReader, Seek, SeekFrom};
|
#[cfg(unix)]
|
||||||
use std::sync::Arc;
|
fn main() {
|
||||||
use std::sync::atomic::AtomicUsize;
|
unix_probe::run();
|
||||||
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]
|
|
||||||
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 * 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:`
|
#[cfg(not(unix))]
|
||||||
/// reports) to stdout once they appear.
|
fn main() {
|
||||||
fn spawn_log_tailer() {
|
eprintln!("audio_probe is only supported on Unix builds (it drives the PipeWire backend directly).");
|
||||||
let path = peerspeak::log_file_path();
|
}
|
||||||
std::thread::spawn(move || {
|
|
||||||
// Wait for the file to exist (first log_msg creates it).
|
#[cfg(unix)]
|
||||||
let file = loop {
|
mod unix_probe {
|
||||||
if let Ok(f) = std::fs::File::open(&path) {
|
use std::io::{BufRead, BufReader, Seek, SeekFrom};
|
||||||
break f;
|
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 frame = Vec::with_capacity(FRAME_SAMPLES * peerspeak::audio::PLAYBACK_CHANNELS);
|
||||||
};
|
for _ in 0..FRAME_SAMPLES {
|
||||||
let mut reader = BufReader::new(file);
|
let t = n as f32 / SAMPLE_RATE;
|
||||||
let _ = reader.seek(SeekFrom::End(0));
|
// 0.25 amplitude: clearly audible but not harsh.
|
||||||
loop {
|
let sample = (0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16;
|
||||||
let mut line = String::new();
|
// Stereo playback bus: duplicate the probe tone to L/R.
|
||||||
match reader.read_line(&mut line) {
|
frame.push(sample);
|
||||||
Ok(0) => std::thread::sleep(Duration::from_millis(150)),
|
frame.push(sample);
|
||||||
Ok(_) => {
|
n += 1;
|
||||||
if line.contains("playout-health:") {
|
}
|
||||||
print!("{line}");
|
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)),
|
||||||
}
|
}
|
||||||
Err(_) => std::thread::sleep(Duration::from_millis(150)),
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,11 +124,10 @@ pub enum UiEvent {
|
|||||||
/// joinable gathering (with a one-click ticket). Emitted by the outbound ping
|
/// joinable gathering (with a one-click ticket). Emitted by the outbound ping
|
||||||
/// scheduler; absence of a recent event = treat as offline.
|
/// scheduler; absence of a recent event = treat as offline.
|
||||||
FriendPresence { id: EndpointId, presence: FriendPresence },
|
FriendPresence { id: EndpointId, presence: FriendPresence },
|
||||||
/// The Discoverable time-box elapsed (W7 P6): the core auto-reverted our presence
|
/// Core corrected the committed presence posture. Usually the Discoverable
|
||||||
/// posture to the carried `mode` (always `Normal`) and stopped publishing. The
|
/// time-box elapsed and the core auto-reverted to `Normal`; on discovery apply
|
||||||
/// GUI must mirror + persist this so its presence picker stops showing
|
/// failure, this carries the previous truthful mode. The GUI must mirror +
|
||||||
/// Discoverable. Distinct from a user-driven change so the GUI knows to update
|
/// persist this so its presence picker matches the endpoint's discovery state.
|
||||||
/// without having issued the command itself.
|
|
||||||
PresenceModeReverted { mode: PresenceMode },
|
PresenceModeReverted { mode: PresenceMode },
|
||||||
/// Core finished orderly app shutdown and the GUI can exit.
|
/// Core finished orderly app shutdown and the GUI can exit.
|
||||||
ShutdownComplete,
|
ShutdownComplete,
|
||||||
|
|||||||
+211
-50
@@ -1,7 +1,7 @@
|
|||||||
pub mod messages;
|
pub mod messages;
|
||||||
pub mod jitter;
|
pub mod jitter;
|
||||||
|
|
||||||
use crate::audio::{AudioBackend, pipewire_impl::PipeWireBackend};
|
use crate::audio::{AudioBackend, PlatformAudioBackend};
|
||||||
use crate::audio::eq::{Eq, EqSettings};
|
use crate::audio::eq::{Eq, EqSettings};
|
||||||
use crate::codec::{AudioEncoder, opus_impl::OpusEncoder};
|
use crate::codec::{AudioEncoder, opus_impl::OpusEncoder};
|
||||||
use crate::core::jitter::{JitterBuffer, FRAME_SAMPLES};
|
use crate::core::jitter::{JitterBuffer, FRAME_SAMPLES};
|
||||||
@@ -13,6 +13,7 @@ use crate::network::{
|
|||||||
use crate::core::messages::{CoreCommand, UiEvent};
|
use crate::core::messages::{CoreCommand, UiEvent};
|
||||||
|
|
||||||
use crate::config::{NetworkMode, RecordingMode};
|
use crate::config::{NetworkMode, RecordingMode};
|
||||||
|
use crate::presence::PresenceMode;
|
||||||
use crate::audio::multitrack::MultitrackRecorder;
|
use crate::audio::multitrack::MultitrackRecorder;
|
||||||
use iroh::{Endpoint, EndpointAddr, EndpointId, RelayMode, SecretKey, endpoint::presets, protocol::Router};
|
use iroh::{Endpoint, EndpointAddr, EndpointId, RelayMode, SecretKey, endpoint::presets, protocol::Router};
|
||||||
use iroh_gossip::net::Gossip;
|
use iroh_gossip::net::Gossip;
|
||||||
@@ -64,6 +65,32 @@ impl CoreController {
|
|||||||
/// clears from the room promptly.
|
/// clears from the room promptly.
|
||||||
const RECONNECT_GRACE: Duration = Duration::from_secs(45);
|
const RECONNECT_GRACE: Duration = Duration::from_secs(45);
|
||||||
|
|
||||||
|
/// Opus frames sent by our encoder are one 20 ms mono frame, normally far below
|
||||||
|
/// this. 4000 bytes still leaves room for large valid Opus packets (well above a
|
||||||
|
/// 48 kHz / 60 ms frame) while bounding malicious datagram copy/decode churn.
|
||||||
|
const MAX_OPUS_PAYLOAD: usize = 4000;
|
||||||
|
|
||||||
|
/// If the Discoverable time-box tries to revert but discovery service reconfiguration
|
||||||
|
/// fails, retry soon while keeping the UI in the still-possible publishing state.
|
||||||
|
const DISCOVERY_REVERT_RETRY: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
|
fn audio_datagram_len_ok(len: usize) -> bool {
|
||||||
|
(4..=4 + MAX_OPUS_PAYLOAD).contains(&len)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn arm_discovery_retry(
|
||||||
|
discovery_deadline: &mut Option<tokio::time::Instant>,
|
||||||
|
now: tokio::time::Instant,
|
||||||
|
) {
|
||||||
|
let retry_deadline = now + DISCOVERY_REVERT_RETRY;
|
||||||
|
if discovery_deadline
|
||||||
|
.map(|current| current > retry_deadline)
|
||||||
|
.unwrap_or(true)
|
||||||
|
{
|
||||||
|
*discovery_deadline = Some(retry_deadline);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Per-peer reconnect grace timers (see [`RECONNECT_GRACE`]). Shared between the
|
/// Per-peer reconnect grace timers (see [`RECONNECT_GRACE`]). Shared between the
|
||||||
/// room-event task (which arms one on a transient drop and cancels it on a
|
/// room-event task (which arms one on a transient drop and cancels it on a
|
||||||
/// gossip rejoin) and the conn-event task (which cancels it when the audio link
|
/// gossip rejoin) and the conn-event task (which cancels it when the audio link
|
||||||
@@ -110,6 +137,7 @@ fn arm_grace_timer(
|
|||||||
let handle = tokio::spawn(async move {
|
let handle = tokio::spawn(async move {
|
||||||
tokio::time::sleep(grace).await;
|
tokio::time::sleep(grace).await;
|
||||||
crate::log_msg(&format!("Reconnect grace expired; evicting peer {:?}", peer_id));
|
crate::log_msg(&format!("Reconnect grace expired; evicting peer {:?}", peer_id));
|
||||||
|
transport_evict.remove_audio_sender(peer_id);
|
||||||
transport_evict.disconnect_peer(peer_id).await;
|
transport_evict.disconnect_peer(peer_id).await;
|
||||||
jitter_evict.lock().await.remove(&peer_id);
|
jitter_evict.lock().await.remove(&peer_id);
|
||||||
// Scrub our internal state *before* announcing the eviction, so anything
|
// Scrub our internal state *before* announcing the eviction, so anything
|
||||||
@@ -209,7 +237,7 @@ fn run_mic_monitor(
|
|||||||
/// Stops a standalone mic monitor if one is running. MUST NOT be called while a
|
/// Stops a standalone mic monitor if one is running. MUST NOT be called while a
|
||||||
/// room session is active — `backend.stop()` would also tear down the call's
|
/// room session is active — `backend.stop()` would also tear down the call's
|
||||||
/// capture/playback. Monitor and session are mutually exclusive by construction.
|
/// capture/playback. Monitor and session are mutually exclusive by construction.
|
||||||
fn stop_mic_monitor(backend: &PipeWireBackend, monitor: Option<MicMonitor>) {
|
fn stop_mic_monitor(backend: &PlatformAudioBackend, monitor: Option<MicMonitor>) {
|
||||||
if let Some(m) = monitor {
|
if let Some(m) = monitor {
|
||||||
let _ = backend.stop();
|
let _ = backend.stop();
|
||||||
let _ = m.thread.join();
|
let _ = m.thread.join();
|
||||||
@@ -343,6 +371,7 @@ impl ConnEventHandler {
|
|||||||
// until the grace timer or the slow gossip Leave.
|
// until the grace timer or the slow gossip Leave.
|
||||||
cancel_grace_timer(&self.grace_timers, &id);
|
cancel_grace_timer(&self.grace_timers, &id);
|
||||||
self.seen_connected.lock().unwrap().remove(&id);
|
self.seen_connected.lock().unwrap().remove(&id);
|
||||||
|
self.transport.remove_audio_sender(id);
|
||||||
self.transport.disconnect_peer(id).await;
|
self.transport.disconnect_peer(id).await;
|
||||||
self.jitter.lock().await.remove(&id);
|
self.jitter.lock().await.remove(&id);
|
||||||
let _ = self.ui_tx.send(UiEvent::PeerLeft { id }).await;
|
let _ = self.ui_tx.send(UiEvent::PeerLeft { id }).await;
|
||||||
@@ -371,7 +400,7 @@ struct ActiveSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ActiveSession {
|
impl ActiveSession {
|
||||||
async fn shutdown(mut self, audio_backend: Arc<PipeWireBackend>) {
|
async fn shutdown(mut self, audio_backend: Arc<PlatformAudioBackend>) {
|
||||||
crate::log_msg("ActiveSession::shutdown started");
|
crate::log_msg("ActiveSession::shutdown started");
|
||||||
// Tear down any screen-share children first so the host stops streaming
|
// Tear down any screen-share children first so the host stops streaming
|
||||||
// promptly (kill_on_drop is the backstop, but kill explicitly so viewers
|
// promptly (kill_on_drop is the backstop, but kill explicitly so viewers
|
||||||
@@ -457,12 +486,11 @@ impl NetStack {
|
|||||||
/// `DnsAddressLookup`, mirroring the `N0` preset) is added when `plan.resolver`; the
|
/// `DnsAddressLookup`, mirroring the `N0` preset) is added when `plan.resolver`; the
|
||||||
/// n0 DNS *publisher* (`PkarrPublisher`) when `plan.publisher`.
|
/// n0 DNS *publisher* (`PkarrPublisher`) when `plan.publisher`.
|
||||||
///
|
///
|
||||||
/// Idempotent and reversible: it clears the whole service set and reinstalls exactly
|
/// Idempotent and reversible: it builds the replacement services first, then clears
|
||||||
/// what the plan wants, so flipping `publisher` off simply drops the publisher (its
|
/// the service set and reinstalls exactly what the plan wants. Flipping `publisher`
|
||||||
/// republish task ends when the last clone is dropped, and the already-published
|
/// off drops the publisher (its republish task ends when the last clone is dropped,
|
||||||
/// record TTL-expires within ~30s) without an endpoint rebuild and without disturbing
|
/// and the already-published record TTL-expires within ~30s) without an endpoint
|
||||||
/// resolution. The brief clear→re-add window is a few synchronous calls; presence
|
/// rebuild and without disturbing resolution.
|
||||||
/// toggles are rare, so a concurrent dial racing it is not a practical concern.
|
|
||||||
fn apply_discovery(
|
fn apply_discovery(
|
||||||
endpoint: &Endpoint,
|
endpoint: &Endpoint,
|
||||||
memory_lookup: &iroh::address_lookup::memory::MemoryLookup,
|
memory_lookup: &iroh::address_lookup::memory::MemoryLookup,
|
||||||
@@ -473,16 +501,34 @@ fn apply_discovery(
|
|||||||
pkarr::{PkarrPublisher, PkarrResolver},
|
pkarr::{PkarrPublisher, PkarrResolver},
|
||||||
};
|
};
|
||||||
let services = endpoint.address_lookup()?;
|
let services = endpoint.address_lookup()?;
|
||||||
|
let pkarr_resolver = if plan.resolver {
|
||||||
|
Some(PkarrResolver::n0_dns().into_address_lookup(endpoint)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let dns_resolver = if plan.resolver {
|
||||||
|
Some(DnsAddressLookup::n0_dns().into_address_lookup(endpoint)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let publisher = if plan.publisher {
|
||||||
|
Some(PkarrPublisher::n0_dns().into_address_lookup(endpoint)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
services.clear();
|
services.clear();
|
||||||
// Always keep the local, server-free lookup (this is what ticket/gossip dialing
|
// Always keep the local, server-free lookup (this is what ticket/gossip dialing
|
||||||
// depends on — it must survive every posture, including DirectOnly).
|
// depends on — it must survive every posture, including DirectOnly).
|
||||||
services.add(memory_lookup.clone());
|
services.add(memory_lookup.clone());
|
||||||
if plan.resolver {
|
if let Some(pkarr_resolver) = pkarr_resolver {
|
||||||
services.add(PkarrResolver::n0_dns().into_address_lookup(endpoint)?);
|
services.add(pkarr_resolver);
|
||||||
services.add(DnsAddressLookup::n0_dns().into_address_lookup(endpoint)?);
|
|
||||||
}
|
}
|
||||||
if plan.publisher {
|
if let Some(dns_resolver) = dns_resolver {
|
||||||
services.add(PkarrPublisher::n0_dns().into_address_lookup(endpoint)?);
|
services.add(dns_resolver);
|
||||||
|
}
|
||||||
|
if let Some(publisher) = publisher {
|
||||||
|
services.add(publisher);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -538,7 +584,7 @@ async fn build_net_stack(
|
|||||||
// report) is injected via `friends_handler`.
|
// report) is injected via `friends_handler`.
|
||||||
let router = Router::builder(endpoint.clone())
|
let router = Router::builder(endpoint.clone())
|
||||||
.accept(iroh_gossip::net::GOSSIP_ALPN, gossip.clone())
|
.accept(iroh_gossip::net::GOSSIP_ALPN, gossip.clone())
|
||||||
.accept(b"peerspeak-audio", audio_router.clone())
|
.accept(crate::protocol::AUDIO_ALPN, audio_router.clone())
|
||||||
.accept(
|
.accept(
|
||||||
crate::presence_net::FRIENDS_ALPN,
|
crate::presence_net::FRIENDS_ALPN,
|
||||||
crate::presence_net::FriendsProtocol::new(friends_handler),
|
crate::presence_net::FriendsProtocol::new(friends_handler),
|
||||||
@@ -634,7 +680,7 @@ async fn probe_friends_once(
|
|||||||
let ep = endpoint.clone();
|
let ep = endpoint.clone();
|
||||||
set.spawn(async move {
|
set.spawn(async move {
|
||||||
match crate::presence_net::probe(&ep, addr).await {
|
match crate::presence_net::probe(&ep, addr).await {
|
||||||
Ok(reply) => crate::presence::interpret_pong(&reply).map(|p| (id, p)),
|
Ok((from, reply)) => crate::presence::interpret_pong(&reply, from).map(|p| (id, p)),
|
||||||
Err(_) => None,
|
Err(_) => None,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -685,7 +731,7 @@ async fn run_core_loop(
|
|||||||
let known_peers: Arc<std::sync::Mutex<HashMap<String, HashMap<EndpointId, EndpointAddr>>>> =
|
let known_peers: Arc<std::sync::Mutex<HashMap<String, HashMap<EndpointId, EndpointAddr>>>> =
|
||||||
Arc::new(std::sync::Mutex::new(HashMap::new()));
|
Arc::new(std::sync::Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
let audio_backend = Arc::new(PipeWireBackend::new());
|
let audio_backend = Arc::new(PlatformAudioBackend::new());
|
||||||
|
|
||||||
let is_muted = Arc::new(AtomicBool::new(false));
|
let is_muted = Arc::new(AtomicBool::new(false));
|
||||||
let is_deafened = Arc::new(AtomicBool::new(false));
|
let is_deafened = Arc::new(AtomicBool::new(false));
|
||||||
@@ -842,22 +888,64 @@ async fn run_core_loop(
|
|||||||
// W7 P6 time-box: Discoverable auto-reverts to Normal after DISCOVERY_TIMEBOX
|
// W7 P6 time-box: Discoverable auto-reverts to Normal after DISCOVERY_TIMEBOX
|
||||||
// so a publish beacon never stands indefinitely. The branch is disabled
|
// so a publish beacon never stands indefinitely. The branch is disabled
|
||||||
// (`if` guard) unless a deadline is armed; `unwrap_or_else` is unreachable
|
// (`if` guard) unless a deadline is armed; `unwrap_or_else` is unreachable
|
||||||
// belt-and-braces. On fire: stop publishing, drop to Normal, tell the GUI.
|
// belt-and-braces. On fire: stop publishing first, then commit Normal only
|
||||||
|
// if the endpoint's discovery services accepted the non-publishing plan.
|
||||||
_ = tokio::time::sleep_until(
|
_ = tokio::time::sleep_until(
|
||||||
discovery_deadline.unwrap_or_else(tokio::time::Instant::now),
|
discovery_deadline.unwrap_or_else(tokio::time::Instant::now),
|
||||||
), if discovery_deadline.is_some() => {
|
), if discovery_deadline.is_some() => {
|
||||||
discovery_deadline = None;
|
let previous_mode = *presence_mode.lock().unwrap();
|
||||||
*presence_mode.lock().unwrap() = crate::presence::PresenceMode::Normal;
|
if previous_mode != PresenceMode::Discoverable {
|
||||||
let plan = crate::discovery::lookup_plan(network_mode, false);
|
discovery_deadline = None;
|
||||||
if let Err(e) = apply_discovery(&net.endpoint, &net.memory_lookup, plan) {
|
continue;
|
||||||
crate::log_msg(&format!("discovery: time-box revert failed: {e:#}"));
|
}
|
||||||
|
|
||||||
|
let requested_mode = PresenceMode::Normal;
|
||||||
|
let now = tokio::time::Instant::now();
|
||||||
|
let plan = crate::discovery::lookup_plan(
|
||||||
|
network_mode,
|
||||||
|
requested_mode.publishes_to_discovery(),
|
||||||
|
);
|
||||||
|
let apply_result = apply_discovery(&net.endpoint, &net.memory_lookup, plan);
|
||||||
|
let (committed_mode, transition_error) =
|
||||||
|
crate::discovery::resolve_presence_transition(
|
||||||
|
previous_mode,
|
||||||
|
requested_mode,
|
||||||
|
apply_result.is_ok(),
|
||||||
|
);
|
||||||
|
*presence_mode.lock().unwrap() = committed_mode;
|
||||||
|
discovery_deadline = if committed_mode == PresenceMode::Discoverable {
|
||||||
|
Some(now + DISCOVERY_REVERT_RETRY)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
match apply_result {
|
||||||
|
Ok(()) => {
|
||||||
|
crate::log_msg(
|
||||||
|
"discovery: Discoverable time-box elapsed → reverting to Normal",
|
||||||
|
);
|
||||||
|
let _ = ui_tx
|
||||||
|
.send(UiEvent::PresenceModeReverted {
|
||||||
|
mode: PresenceMode::Normal,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
crate::log_msg(&format!("discovery: time-box revert failed: {e:#}"));
|
||||||
|
if committed_mode != requested_mode {
|
||||||
|
let _ = ui_tx
|
||||||
|
.send(UiEvent::PresenceModeReverted {
|
||||||
|
mode: committed_mode,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
if let Some(message) = transition_error {
|
||||||
|
let _ = ui_tx
|
||||||
|
.send(UiEvent::Error(format!("{message} ({e:#})")))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
crate::log_msg("discovery: Discoverable time-box elapsed → reverting to Normal");
|
|
||||||
let _ = ui_tx
|
|
||||||
.send(UiEvent::PresenceModeReverted {
|
|
||||||
mode: crate::presence::PresenceMode::Normal,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -923,7 +1011,12 @@ async fn run_core_loop(
|
|||||||
let ticket_str = if ticket.trim().is_empty() || ticket == "create" {
|
let ticket_str = if ticket.trim().is_empty() || ticket == "create" {
|
||||||
let topic_id: [u8; 32] = rand::random();
|
let topic_id: [u8; 32] = rand::random();
|
||||||
let host_addr = endpoint.addr();
|
let host_addr = endpoint.addr();
|
||||||
crate::log_msg(&format!("Creating room. host_addr={:?}, topic_id={:?}", host_addr, topic_id));
|
crate::log_msg(&format!(
|
||||||
|
"Creating room. host_id={}, host_addrs={}, topic={}",
|
||||||
|
crate::short_id(&host_addr.id.to_string()),
|
||||||
|
host_addr.addrs.len(),
|
||||||
|
crate::short_bytes_hex(&topic_id)
|
||||||
|
));
|
||||||
// The creator's chosen cosmetic label rides in the ticket so
|
// The creator's chosen cosmetic label rides in the ticket so
|
||||||
// every joiner inherits it; sanitize it before it leaves here.
|
// every joiner inherits it; sanitize it before it leaves here.
|
||||||
let label = crate::sanitize::sanitize_name(&room_name);
|
let label = crate::sanitize::sanitize_name(&room_name);
|
||||||
@@ -931,7 +1024,10 @@ async fn run_core_loop(
|
|||||||
ticket.to_string()
|
ticket.to_string()
|
||||||
} else {
|
} else {
|
||||||
let ticket_str = ticket.trim().to_string();
|
let ticket_str = ticket.trim().to_string();
|
||||||
crate::log_msg(&format!("Joining room with existing ticket={}", ticket_str));
|
crate::log_msg(&format!(
|
||||||
|
"Joining room with existing ticket={}",
|
||||||
|
crate::redact_for_log(&ticket_str)
|
||||||
|
));
|
||||||
ticket_str
|
ticket_str
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -970,7 +1066,17 @@ async fn run_core_loop(
|
|||||||
.map(|peers| peers.values().cloned().collect())
|
.map(|peers| peers.values().cloned().collect())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
crate::log_msg(&format!("Attempting room_state.join with self_state={:?}, extra_bootstrap={:?}", self_state, extra_bootstrap.iter().map(|a| a.id).collect::<Vec<_>>()));
|
let extra_bootstrap_ids = extra_bootstrap
|
||||||
|
.iter()
|
||||||
|
.map(|a| crate::short_id(&a.id.to_string()))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
crate::log_msg(&format!(
|
||||||
|
"Attempting room_state.join self_id={}, self_name={:?}, sharing={}, extra_bootstrap={:?}",
|
||||||
|
crate::short_id(&self_state.addr.id.to_string()),
|
||||||
|
self_state.name,
|
||||||
|
self_state.sharing.is_some(),
|
||||||
|
extra_bootstrap_ids
|
||||||
|
));
|
||||||
if let Err(e) = room_state.join(&ticket_str, self_state.clone(), extra_bootstrap).await {
|
if let Err(e) = room_state.join(&ticket_str, self_state.clone(), extra_bootstrap).await {
|
||||||
crate::log_msg(&format!("Error room_state.join failed: {:?}", e));
|
crate::log_msg(&format!("Error room_state.join failed: {:?}", e));
|
||||||
let _ = ui_tx.send(UiEvent::Error(format!("Failed to join room: {}", e))).await;
|
let _ = ui_tx.send(UiEvent::Error(format!("Failed to join room: {}", e))).await;
|
||||||
@@ -1135,8 +1241,12 @@ async fn run_core_loop(
|
|||||||
};
|
};
|
||||||
|
|
||||||
while let Some((from_peer, bytes)) = datagram_rx.recv().await {
|
while let Some((from_peer, bytes)) = datagram_rx.recv().await {
|
||||||
if bytes.len() < 4 {
|
if !transport_recv.audio_sender_admitted(from_peer) {
|
||||||
continue; // malformed: missing sequence header
|
continue;
|
||||||
|
}
|
||||||
|
if !audio_datagram_len_ok(bytes.len()) {
|
||||||
|
// Malformed (< sequence header) or oversized Opus payload.
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
let seq = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
|
let seq = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
|
||||||
let payload = bytes[4..].to_vec();
|
let payload = bytes[4..].to_vec();
|
||||||
@@ -1368,6 +1478,7 @@ async fn run_core_loop(
|
|||||||
// A (re)join means the peer is back — cancel any
|
// A (re)join means the peer is back — cancel any
|
||||||
// pending reconnect grace timer before re-adding it.
|
// pending reconnect grace timer before re-adding it.
|
||||||
cancel_grace_timer(&grace_timers_events, &peer_id);
|
cancel_grace_timer(&grace_timers_events, &peer_id);
|
||||||
|
transport_events.admit_audio_sender(peer_id);
|
||||||
// Establish the audio connection as soon as the peer
|
// Establish the audio connection as soon as the peer
|
||||||
// is known (the transport dedupes the full-mesh race).
|
// is known (the transport dedupes the full-mesh race).
|
||||||
// Hand over the full address so reconnects can dial
|
// Hand over the full address so reconnects can dial
|
||||||
@@ -1419,6 +1530,7 @@ async fn run_core_loop(
|
|||||||
{
|
{
|
||||||
peers.remove(&peer_id);
|
peers.remove(&peer_id);
|
||||||
}
|
}
|
||||||
|
transport_events.remove_audio_sender(peer_id);
|
||||||
transport_events.disconnect_peer(peer_id).await;
|
transport_events.disconnect_peer(peer_id).await;
|
||||||
jitter_events.lock().await.remove(&peer_id);
|
jitter_events.lock().await.remove(&peer_id);
|
||||||
let _ = ui_tx_events.send(UiEvent::PeerLeft { id: peer_id }).await;
|
let _ = ui_tx_events.send(UiEvent::PeerLeft { id: peer_id }).await;
|
||||||
@@ -1431,6 +1543,7 @@ async fn run_core_loop(
|
|||||||
// it. Idempotent: an ordinary mute/unmute update just
|
// it. Idempotent: an ordinary mute/unmute update just
|
||||||
// re-records the same address.
|
// re-records the same address.
|
||||||
cancel_grace_timer(&grace_timers_events, &peer_id);
|
cancel_grace_timer(&grace_timers_events, &peer_id);
|
||||||
|
transport_events.admit_audio_sender(peer_id);
|
||||||
transport_events.connect_peer(state.addr.clone()).await;
|
transport_events.connect_peer(state.addr.clone()).await;
|
||||||
// Auto-heal a friend's saved address (W7) on the
|
// Auto-heal a friend's saved address (W7) on the
|
||||||
// re-announce too — this is the path that catches a
|
// re-announce too — this is the path that catches a
|
||||||
@@ -1472,6 +1585,7 @@ async fn run_core_loop(
|
|||||||
// hasn't recovered within RECONNECT_GRACE. A gossip
|
// hasn't recovered within RECONNECT_GRACE. A gossip
|
||||||
// rejoin (PeerJoined/PeerUpdated) or a transport
|
// rejoin (PeerJoined/PeerUpdated) or a transport
|
||||||
// reconnect (ConnEvent::Connected) cancels it first.
|
// reconnect (ConnEvent::Connected) cancels it first.
|
||||||
|
transport_events.keep_audio_sender_for_reconnect_grace(peer_id);
|
||||||
let _ = ui_tx_events.send(UiEvent::PeerConnecting { id: peer_id }).await;
|
let _ = ui_tx_events.send(UiEvent::PeerConnecting { id: peer_id }).await;
|
||||||
arm_grace_timer(
|
arm_grace_timer(
|
||||||
&grace_timers_events,
|
&grace_timers_events,
|
||||||
@@ -1770,22 +1884,60 @@ async fn run_core_loop(
|
|||||||
}
|
}
|
||||||
|
|
||||||
CoreCommand::SetPresenceMode(mode) => {
|
CoreCommand::SetPresenceMode(mode) => {
|
||||||
*presence_mode.lock().unwrap() = mode;
|
let previous_mode = *presence_mode.lock().unwrap();
|
||||||
// W7 P6: re-apply n0 DNS discovery for the new posture (publish on iff
|
let now = tokio::time::Instant::now();
|
||||||
// Discoverable). Runtime — no endpoint rebuild; clears + reinstalls the
|
|
||||||
// address-lookup services. The resolver stays on regardless so we can
|
if previous_mode == mode {
|
||||||
// still look up moved friends.
|
// Same-mode requests are no-ops for discovery wiring, but keep the
|
||||||
let plan = crate::discovery::lookup_plan(network_mode, mode.publishes_to_discovery());
|
// existing UX: re-selecting Discoverable restarts the clock.
|
||||||
if let Err(e) = apply_discovery(&net.endpoint, &net.memory_lookup, plan) {
|
discovery_deadline = if mode == PresenceMode::Discoverable {
|
||||||
crate::log_msg(&format!("discovery: apply failed: {e:#}"));
|
Some(now + crate::discovery::DISCOVERY_TIMEBOX)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
// Arm (Discoverable) or cancel (any other posture) the auto-revert
|
|
||||||
// time-box. Re-selecting Discoverable restarts the clock.
|
// W7 P6/S11: re-apply n0 DNS discovery for the requested posture
|
||||||
discovery_deadline = if mode == crate::presence::PresenceMode::Discoverable {
|
// first, then commit the presence mode only if the endpoint accepted
|
||||||
Some(tokio::time::Instant::now() + crate::discovery::DISCOVERY_TIMEBOX)
|
// that discovery plan. This keeps the UI truthful when dropping the
|
||||||
|
// publisher fails.
|
||||||
|
let plan =
|
||||||
|
crate::discovery::lookup_plan(network_mode, mode.publishes_to_discovery());
|
||||||
|
let apply_result = apply_discovery(&net.endpoint, &net.memory_lookup, plan);
|
||||||
|
let (committed_mode, transition_error) =
|
||||||
|
crate::discovery::resolve_presence_transition(
|
||||||
|
previous_mode,
|
||||||
|
mode,
|
||||||
|
apply_result.is_ok(),
|
||||||
|
);
|
||||||
|
*presence_mode.lock().unwrap() = committed_mode;
|
||||||
|
|
||||||
|
if committed_mode == PresenceMode::Discoverable {
|
||||||
|
if apply_result.is_ok() && mode == PresenceMode::Discoverable {
|
||||||
|
discovery_deadline = Some(now + crate::discovery::DISCOVERY_TIMEBOX);
|
||||||
|
} else {
|
||||||
|
arm_discovery_retry(&mut discovery_deadline, now);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
None
|
discovery_deadline = None;
|
||||||
};
|
}
|
||||||
|
|
||||||
|
if let Err(e) = apply_result {
|
||||||
|
crate::log_msg(&format!("discovery: apply failed: {e:#}"));
|
||||||
|
if committed_mode != mode {
|
||||||
|
let _ = ui_tx
|
||||||
|
.send(UiEvent::PresenceModeReverted {
|
||||||
|
mode: committed_mode,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
if let Some(message) = transition_error {
|
||||||
|
let _ = ui_tx
|
||||||
|
.send(UiEvent::Error(format!("{message} ({e:#})")))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CoreCommand::SetRecordingMode(mode) => {
|
CoreCommand::SetRecordingMode(mode) => {
|
||||||
@@ -1987,8 +2139,8 @@ async fn run_core_loop(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
apply_volume, frame_level, mix_frames, mix_stereo_frames, stereo_to_mono, MicLevelMeter,
|
apply_volume, audio_datagram_len_ok, frame_level, mix_frames, mix_stereo_frames,
|
||||||
MIC_LEVEL_REPORT_SAMPLES,
|
stereo_to_mono, MicLevelMeter, MAX_OPUS_PAYLOAD, MIC_LEVEL_REPORT_SAMPLES,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// A frame of constant amplitude with the given sample count.
|
/// A frame of constant amplitude with the given sample count.
|
||||||
@@ -2005,6 +2157,15 @@ mod tests {
|
|||||||
assert!(m.push(&frame(1000, MIC_LEVEL_REPORT_SAMPLES)).is_some());
|
assert!(m.push(&frame(1000, MIC_LEVEL_REPORT_SAMPLES)).is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audio_datagram_length_gate_preserves_header_and_caps_payload() {
|
||||||
|
assert!(!audio_datagram_len_ok(0));
|
||||||
|
assert!(!audio_datagram_len_ok(3));
|
||||||
|
assert!(audio_datagram_len_ok(4));
|
||||||
|
assert!(audio_datagram_len_ok(4 + MAX_OPUS_PAYLOAD));
|
||||||
|
assert!(!audio_datagram_len_ok(5 + MAX_OPUS_PAYLOAD));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mic_meter_holds_the_peak_across_the_window() {
|
fn mic_meter_holds_the_peak_across_the_window() {
|
||||||
let mut m = MicLevelMeter::new();
|
let mut m = MicLevelMeter::new();
|
||||||
|
|||||||
+96
-10
@@ -8,14 +8,19 @@
|
|||||||
//! The model (from `docs/contacts-plan.md` P6, decided 2026-06-16):
|
//! The model (from `docs/contacts-plan.md` P6, decided 2026-06-16):
|
||||||
//! - **Resolving is always allowed on relay-capable modes** — a stationary friend
|
//! - **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
|
//! (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
|
//! resolve is a DNS query to n0 that publishes nothing, but still exposes query
|
||||||
//! address is stale and the dial falls through to discovery.
|
//! 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
|
//! - **Publishing is gated on `Discoverable`** and asymmetric: only the mover
|
||||||
//! publishes their address to n0 DNS; everyone else just looks it up.
|
//! 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
|
//! - **`DirectOnly` is the explicit no-server posture** — neither resolve nor publish
|
||||||
//! ever touches n0 there, regardless of the Discoverable toggle.
|
//! ever touches n0 there, regardless of the Discoverable toggle.
|
||||||
|
|
||||||
use crate::config::NetworkMode;
|
use crate::config::NetworkMode;
|
||||||
|
use crate::presence::PresenceMode;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
/// How long `Discoverable` stays on before auto-reverting to `Normal`. Discovery is
|
/// 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 {
|
match network_mode {
|
||||||
// The explicit serverless posture: no n0 contact at all, even to resolve.
|
// The explicit serverless posture: no n0 contact at all, even to resolve.
|
||||||
// A Discoverable toggle here is intentionally inert.
|
// 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);
|
// Relay-capable: always resolve (so a stationary friend can find a mover);
|
||||||
// publish only when the user opted into Discoverable.
|
// publish only when the user opted into Discoverable.
|
||||||
NetworkMode::RelayNoDiscovery | NetworkMode::N0Full => {
|
NetworkMode::RelayNoDiscovery | NetworkMode::N0Full => LookupPlan {
|
||||||
LookupPlan { resolver: true, publisher: want_publish }
|
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] {
|
for mode in [NetworkMode::RelayNoDiscovery, NetworkMode::N0Full] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
lookup_plan(mode, false),
|
lookup_plan(mode, false),
|
||||||
LookupPlan { resolver: true, publisher: false },
|
LookupPlan {
|
||||||
|
resolver: true,
|
||||||
|
publisher: false
|
||||||
|
},
|
||||||
"{mode:?}: resolve always on, no publish when not Discoverable"
|
"{mode:?}: resolve always on, no publish when not Discoverable"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
lookup_plan(mode, true),
|
lookup_plan(mode, true),
|
||||||
LookupPlan { resolver: true, publisher: true },
|
LookupPlan {
|
||||||
|
resolver: true,
|
||||||
|
publisher: true
|
||||||
|
},
|
||||||
"{mode:?}: Discoverable adds publish on top of resolve"
|
"{mode:?}: Discoverable adds publish on top of resolve"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -79,12 +121,18 @@ mod tests {
|
|||||||
fn direct_only_never_touches_n0_even_when_discoverable() {
|
fn direct_only_never_touches_n0_even_when_discoverable() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
lookup_plan(NetworkMode::DirectOnly, false),
|
lookup_plan(NetworkMode::DirectOnly, false),
|
||||||
LookupPlan { resolver: false, publisher: false }
|
LookupPlan {
|
||||||
|
resolver: false,
|
||||||
|
publisher: false
|
||||||
|
}
|
||||||
);
|
);
|
||||||
// The serverless posture overrides the Discoverable request entirely.
|
// The serverless posture overrides the Discoverable request entirely.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
lookup_plan(NetworkMode::DirectOnly, true),
|
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() {
|
fn timebox_is_thirty_minutes() {
|
||||||
assert_eq!(DISCOVERY_TIMEBOX, Duration::from_secs(1800));
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+135
-6
@@ -2,6 +2,7 @@ pub mod audio;
|
|||||||
pub mod codec;
|
pub mod codec;
|
||||||
pub mod dsp;
|
pub mod dsp;
|
||||||
pub mod network;
|
pub mod network;
|
||||||
|
pub mod protocol;
|
||||||
pub mod core;
|
pub mod core;
|
||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
@@ -18,9 +19,16 @@ pub mod recents;
|
|||||||
pub mod discovery;
|
pub mod discovery;
|
||||||
pub mod hotkeys;
|
pub mod hotkeys;
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::fs::File;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::OnceLock;
|
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`
|
/// 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
|
/// (via `dirs::state_dir`), falling back to the system temp dir. Computed lazily
|
||||||
/// so we never hardcode a per-user path.
|
/// so we never hardcode a per-user path.
|
||||||
@@ -43,6 +51,74 @@ pub fn log_file_path() -> PathBuf {
|
|||||||
log_path().clone()
|
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) {
|
pub fn log_msg(msg: &str) {
|
||||||
// Format the whole line into one buffer first, then emit it with a single
|
// 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
|
// `write_all`. The file is opened with `O_APPEND`, so a lone `write()` is
|
||||||
@@ -52,12 +128,65 @@ pub fn log_msg(msg: &str) {
|
|||||||
Ok(time) => format!("[{}.{:03}] {}\n", time.as_secs(), time.subsec_millis(), msg),
|
Ok(time) => format!("[{}.{:03}] {}\n", time.as_secs(), time.subsec_millis(), msg),
|
||||||
Err(_) => format!("{}\n", msg),
|
Err(_) => format!("{}\n", msg),
|
||||||
};
|
};
|
||||||
if let Ok(mut file) = std::fs::OpenOptions::new()
|
if let Ok(mut file) = prepare_log_file(log_path()) {
|
||||||
.create(true)
|
|
||||||
.append(true)
|
|
||||||
.open(log_path())
|
|
||||||
{
|
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
let _ = file.write_all(line.as_bytes());
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+174
-15
@@ -12,7 +12,7 @@ use serde::{Serialize, Deserialize};
|
|||||||
|
|
||||||
/// Domain-separation tag mixed into every signed gossip payload so a signature
|
/// Domain-separation tag mixed into every signed gossip payload so a signature
|
||||||
/// can never be lifted out of this protocol/version into another context.
|
/// 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
|
/// 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
|
/// 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")
|
f.debug_struct("GossipPayload")
|
||||||
.field("author", &self.author)
|
.field("author", &self.author)
|
||||||
.field("ts", &self.ts)
|
.field("ts", &self.ts)
|
||||||
.field("msg", &self.msg)
|
.field("msg_kind", &gossip_message_kind(&self.msg))
|
||||||
.finish_non_exhaustive()
|
.finish_non_exhaustive()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,6 +79,58 @@ enum GossipReject {
|
|||||||
BadSignature,
|
BadSignature,
|
||||||
/// Timestamp outside the freshness window — stale (replay) or implausibly future.
|
/// Timestamp outside the freshness window — stale (replay) or implausibly future.
|
||||||
OutOfWindow,
|
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
|
/// 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 {
|
if now_ms.abs_diff(payload.ts) > window_ms {
|
||||||
return Err(GossipReject::OutOfWindow);
|
return Err(GossipReject::OutOfWindow);
|
||||||
}
|
}
|
||||||
|
if let GossipMessage::Announce(state) = &payload.msg
|
||||||
|
&& state.addr.id != payload.author {
|
||||||
|
return Err(GossipReject::AnnounceAddressMismatch);
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,11 +241,25 @@ impl RoomState for IrohGossipState {
|
|||||||
self_state: PeerState,
|
self_state: PeerState,
|
||||||
extra_bootstrap: Vec<EndpointAddr>,
|
extra_bootstrap: Vec<EndpointAddr>,
|
||||||
) -> Result<(), NetError> {
|
) -> 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 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
|
// Stop any currently running topic
|
||||||
let _ = self.leave().await;
|
let _ = self.leave().await;
|
||||||
@@ -236,6 +306,7 @@ impl RoomState for IrohGossipState {
|
|||||||
|
|
||||||
let handle = tokio::spawn(async move {
|
let handle = tokio::spawn(async move {
|
||||||
crate::log_msg(&format!("Spawned gossip topic loop for self_id={:?}", self_id));
|
crate::log_msg(&format!("Spawned gossip topic loop for self_id={:?}", self_id));
|
||||||
|
let mut state_mutations_seen = HashMap::new();
|
||||||
|
|
||||||
// Broadcast initial state
|
// Broadcast initial state
|
||||||
let initial_payload = {
|
let initial_payload = {
|
||||||
@@ -285,7 +356,26 @@ impl RoomState for IrohGossipState {
|
|||||||
continue;
|
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 {
|
match payload.msg {
|
||||||
GossipMessage::Announce(mut state) => {
|
GossipMessage::Announce(mut state) => {
|
||||||
@@ -299,6 +389,10 @@ impl RoomState for IrohGossipState {
|
|||||||
// monogram, so a malformed/oversized/bomb
|
// monogram, so a malformed/oversized/bomb
|
||||||
// image can't crash or exhaust us (W4).
|
// image can't crash or exhaust us (W4).
|
||||||
state.avatar = state.avatar.sanitize_incoming();
|
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);
|
||||||
let (is_new, state_changed) = {
|
let (is_new, state_changed) = {
|
||||||
let mut peer_map = peers.lock().unwrap();
|
let mut peer_map = peers.lock().unwrap();
|
||||||
let is_new = !peer_map.contains_key(&payload.author);
|
let is_new = !peer_map.contains_key(&payload.author);
|
||||||
@@ -310,11 +404,19 @@ impl RoomState for IrohGossipState {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if is_new {
|
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());
|
address_lookup.add_endpoint_info(state.addr.clone());
|
||||||
let _ = event_tx.send(RoomEvent::PeerJoined(payload.author, state)).await;
|
let _ = event_tx.send(RoomEvent::PeerJoined(payload.author, state)).await;
|
||||||
} else if state_changed {
|
} 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;
|
let _ = event_tx.send(RoomEvent::PeerUpdated(payload.author, state)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -390,7 +492,10 @@ impl RoomState for IrohGossipState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn update_self_state(&self, self_state: PeerState) -> Result<(), NetError> {
|
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());
|
*self.self_state.lock().unwrap() = Some(self_state.clone());
|
||||||
|
|
||||||
let sender_opt = self.active_sender.lock().unwrap().clone();
|
let sender_opt = self.active_sender.lock().unwrap().clone();
|
||||||
@@ -489,11 +594,10 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::network::PeerState;
|
use crate::network::PeerState;
|
||||||
use iroh::SecretKey;
|
use iroh::SecretKey;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
fn sample_peer_state() -> PeerState {
|
fn sample_peer_state_for(id: EndpointId) -> PeerState {
|
||||||
let secret = SecretKey::generate();
|
let addr = iroh::EndpointAddr::from(id);
|
||||||
let public = secret.public();
|
|
||||||
let addr = iroh::EndpointAddr::from(public);
|
|
||||||
PeerState {
|
PeerState {
|
||||||
name: "TestPeerGossip".to_string(),
|
name: "TestPeerGossip".to_string(),
|
||||||
is_muted: true,
|
is_muted: true,
|
||||||
@@ -563,7 +667,7 @@ mod tests {
|
|||||||
fn test_gossip_payload_announce_round_trip() {
|
fn test_gossip_payload_announce_round_trip() {
|
||||||
let secret = SecretKey::generate();
|
let secret = SecretKey::generate();
|
||||||
let topic = [9u8; 32];
|
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 payload = sign_gossip(&secret, &topic, 1000, GossipMessage::Announce(peer_state.clone()));
|
||||||
|
|
||||||
let serialized = serde_json::to_string(&payload).unwrap();
|
let serialized = serde_json::to_string(&payload).unwrap();
|
||||||
@@ -732,5 +836,60 @@ mod tests {
|
|||||||
// Within the window (clock skew tolerance) → accepted.
|
// Within the window (clock skew tolerance) → accepted.
|
||||||
assert!(verify_gossip(&p, &topic, 1_000_000 + GOSSIP_FRESHNESS_MS - 1, GOSSIP_FRESHNESS_MS).is_ok());
|
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;
|
||||||
use tokio::sync::mpsc::Receiver;
|
use tokio::sync::mpsc::Receiver;
|
||||||
use std::sync::{Arc, Mutex as StdMutex};
|
use std::sync::{Arc, Mutex as StdMutex};
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use async_trait::async_trait;
|
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
|
/// 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.
|
/// 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
|
/// supervisor inserts its connection when the link comes up and removes it
|
||||||
/// when the link dies.
|
/// when the link dies.
|
||||||
live_conns: StdMutex<HashMap<EndpointId, Connection>>,
|
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)>,
|
incoming_tx: mpsc::Sender<(EndpointId, Bytes)>,
|
||||||
/// Best-effort link-state notifications for the UI (connecting / connected).
|
/// Best-effort link-state notifications for the UI (connecting / connected).
|
||||||
conn_events_tx: mpsc::Sender<ConnEvent>,
|
conn_events_tx: mpsc::Sender<ConnEvent>,
|
||||||
@@ -112,6 +116,16 @@ impl Shared {
|
|||||||
crate::log_msg(&format!("Transport: stopped supervising peer {:?}", peer_id));
|
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.
|
/// 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))
|
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
|
/// 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 —
|
/// 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
|
/// 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() {
|
if shared.self_id.to_string() < peer_id.to_string() {
|
||||||
return Ok(());
|
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
|
// Route the connection to this peer's supervisor (creating it if the
|
||||||
// inbound link beat the gossip join event). try_send keeps the
|
// inbound link arrives after the signed gossip Announce admitted it).
|
||||||
// protocol handler from ever blocking; a full queue only happens if
|
// try_send keeps the protocol handler from ever blocking; a full queue
|
||||||
// links are churning, and the supervisor will get the next one.
|
// only happens if links are churning, and the supervisor gets the next one.
|
||||||
let inbound_tx = shared.ensure_supervisor(peer_id).await;
|
let inbound_tx = shared.ensure_supervisor(peer_id).await;
|
||||||
if inbound_tx.try_send(connection).is_err() {
|
if inbound_tx.try_send(connection).is_err() {
|
||||||
crate::log_msg(&format!("Transport: dropped inbound link from {:?} (queue full)", peer_id));
|
crate::log_msg(&format!("Transport: dropped inbound link from {:?} (queue full)", peer_id));
|
||||||
@@ -369,6 +425,7 @@ impl IrohTransport {
|
|||||||
addrs: StdMutex::new(HashMap::new()),
|
addrs: StdMutex::new(HashMap::new()),
|
||||||
peers: tokio::sync::Mutex::new(HashMap::new()),
|
peers: tokio::sync::Mutex::new(HashMap::new()),
|
||||||
live_conns: StdMutex::new(HashMap::new()),
|
live_conns: StdMutex::new(HashMap::new()),
|
||||||
|
admitted_audio: StdMutex::new(HashSet::new()),
|
||||||
incoming_tx,
|
incoming_tx,
|
||||||
conn_events_tx,
|
conn_events_tx,
|
||||||
});
|
});
|
||||||
@@ -397,11 +454,35 @@ impl IrohTransport {
|
|||||||
}
|
}
|
||||||
self.shared.senders.lock().unwrap().clear();
|
self.shared.senders.lock().unwrap().clear();
|
||||||
self.shared.addrs.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
|
// Give the CONNECTION_CLOSE frames a moment to flush before the caller
|
||||||
// shuts the endpoint/router down (the `conns` clones are still alive
|
// shuts the endpoint/router down (the `conns` clones are still alive
|
||||||
// here, so the endpoint can still transmit them).
|
// here, so the endpoint can still transmit them).
|
||||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
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]
|
#[async_trait]
|
||||||
@@ -443,3 +524,85 @@ impl NetworkTransport for IrohTransport {
|
|||||||
.ok_or_else(|| NetError::Other("Connection events already subscribed".to_string()))
|
.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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+40
-21
@@ -110,26 +110,32 @@ pub enum FriendPresence {
|
|||||||
InRoom { name: String, ticket: String },
|
InRoom { name: String, ticket: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Interpret a peer's reply defensively. Only a `Pong` is a reply (a `Ping` is
|
/// Interpret a peer's reply defensively. `from` must be the connection's
|
||||||
/// not, so it yields `None`). When the peer reports a room, we **sanitize the
|
/// authenticated remote id, not any value carried in the payload. Only a `Pong`
|
||||||
/// peer-supplied name** and **only surface it as joinable if the ticket actually
|
/// is a reply (a `Ping` is not, so it yields `None`). When the peer reports a
|
||||||
/// parses** as a [`crate::network::PeerSpeakTicket`] — a garbage or hostile
|
/// room, we **sanitize the peer-supplied name** and **only surface it as joinable
|
||||||
/// ticket downgrades the friend to plain `Online` rather than offering a dead /
|
/// if the ticket actually parses** as a [`crate::network::PeerSpeakTicket`] and
|
||||||
/// dangerous Join button. (We still never auto-join; the user clicks.)
|
/// points back at the replying friend. A garbage/redirect ticket downgrades the
|
||||||
pub fn interpret_pong(msg: &ControlMsg) -> Option<FriendPresence> {
|
/// 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 {
|
match msg {
|
||||||
ControlMsg::Ping => None,
|
ControlMsg::Ping => None,
|
||||||
ControlMsg::Pong { room: None } => Some(FriendPresence::Online),
|
ControlMsg::Pong { room: None } => Some(FriendPresence::Online),
|
||||||
ControlMsg::Pong { room: Some(r) } => {
|
ControlMsg::Pong { room: Some(r) } => {
|
||||||
if r.ticket.parse::<crate::network::PeerSpeakTicket>().is_ok() {
|
let Ok(ticket) = r.ticket.parse::<crate::network::PeerSpeakTicket>() else {
|
||||||
Some(FriendPresence::InRoom {
|
|
||||||
name: crate::sanitize::sanitize_name(&r.name),
|
|
||||||
ticket: r.ticket.clone(),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// Online, but the advertised room is unusable — don't offer Join.
|
// 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]
|
#[test]
|
||||||
fn interpret_ping_is_not_a_reply() {
|
fn interpret_ping_is_not_a_reply() {
|
||||||
assert_eq!(interpret_pong(&ControlMsg::Ping), None);
|
assert_eq!(interpret_pong(&ControlMsg::Ping, id()), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn interpret_pong_online_and_inroom() {
|
fn interpret_pong_online_and_inroom() {
|
||||||
|
let friend = id();
|
||||||
// No room -> Online.
|
// No room -> Online.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
interpret_pong(&ControlMsg::Pong { room: None }),
|
interpret_pong(&ControlMsg::Pong { room: None }, friend),
|
||||||
Some(FriendPresence::Online)
|
Some(FriendPresence::Online)
|
||||||
);
|
);
|
||||||
// Valid ticket -> InRoom with a sanitized name.
|
// Valid ticket -> InRoom with a sanitized name.
|
||||||
let t = valid_ticket(id());
|
let t = valid_ticket(friend);
|
||||||
let got = interpret_pong(&ControlMsg::Pong {
|
let got = interpret_pong(&ControlMsg::Pong {
|
||||||
room: Some(RoomPresence { name: "HangOut".into(), ticket: t.clone() }),
|
room: Some(RoomPresence { name: "HangOut".into(), ticket: t.clone() }),
|
||||||
});
|
}, friend);
|
||||||
assert_eq!(got, Some(FriendPresence::InRoom { name: "HangOut".into(), ticket: t }));
|
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.
|
// Online — no dead/hostile Join button is surfaced.
|
||||||
let got = interpret_pong(&ControlMsg::Pong {
|
let got = interpret_pong(&ControlMsg::Pong {
|
||||||
room: Some(RoomPresence { name: "Trap".into(), ticket: "not-a-ticket".into() }),
|
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));
|
assert_eq!(got, Some(FriendPresence::Online));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn interpret_pong_sanitizes_a_hostile_room_name() {
|
fn interpret_pong_sanitizes_a_hostile_room_name() {
|
||||||
// Control/bidi characters in a peer-supplied name are stripped.
|
// 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 {
|
let got = interpret_pong(&ControlMsg::Pong {
|
||||||
room: Some(RoomPresence { name: "Hang\u{202e}Out\u{0007}".into(), ticket: t.clone() }),
|
room: Some(RoomPresence { name: "Hang\u{202e}Out\u{0007}".into(), ticket: t.clone() }),
|
||||||
});
|
}, friend);
|
||||||
match got {
|
match got {
|
||||||
Some(FriendPresence::InRoom { name, .. }) => {
|
Some(FriendPresence::InRoom { name, .. }) => {
|
||||||
assert!(!name.contains('\u{202e}'), "bidi override must be stripped");
|
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
|
/// 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.
|
/// 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
|
/// 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
|
/// 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")
|
serde_json::from_slice(bytes).context("failed to decode control message")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Probe `peer` for presence: send a `Ping`, return their `Pong`. An error means
|
/// Probe `peer` for presence: send a `Ping`, return their authenticated id and
|
||||||
/// no usable reply (offline / unreachable / refused / malformed) — the caller
|
/// `Pong`. An error means no usable reply (offline / unreachable / refused /
|
||||||
/// treats that as "appears offline". `peer` is usually a bare [`EndpointId`]
|
/// malformed) — the caller treats that as "appears offline". `peer` is usually a
|
||||||
/// (friends store the stable id); a full [`EndpointAddr`] is also accepted (and
|
/// bare [`EndpointId`] (friends store the stable id); a full [`EndpointAddr`] is
|
||||||
/// used by hermetic tests).
|
/// also accepted (and used by hermetic tests).
|
||||||
pub async fn probe(endpoint: &Endpoint, peer: impl Into<EndpointAddr>) -> Result<ControlMsg> {
|
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))
|
let conn = tokio::time::timeout(IO_TIMEOUT, endpoint.connect(peer, FRIENDS_ALPN))
|
||||||
.await
|
.await
|
||||||
.context("timed out connecting to peer")?
|
.context("timed out connecting to peer")?
|
||||||
.context("failed to connect to peer")?;
|
.context("failed to connect to peer")?;
|
||||||
|
let from = conn.remote_id();
|
||||||
|
|
||||||
let io = async {
|
let io = async {
|
||||||
let (mut send, mut recv) = conn.open_bi().await.context("failed to open control stream")?;
|
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
|
.await
|
||||||
.context("timed out awaiting pong")?;
|
.context("timed out awaiting pong")?;
|
||||||
conn.close(VarInt::from_u32(0), b"done");
|
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
|
/// 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<()> {
|
async fn exchange(conn: &iroh::endpoint::Connection, handler: &Handler) -> Result<()> {
|
||||||
// The authenticated remote id — NOT anything the peer puts in the payload.
|
// The authenticated remote id — NOT anything the peer puts in the payload.
|
||||||
let from = conn.remote_id();
|
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 io = async {
|
||||||
let (mut send, mut recv) = conn.accept_bi().await.context("failed to accept stream")?;
|
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 => {}
|
ControlMsg::Ping => {}
|
||||||
other => bail!("expected a ping, got {other:?}"),
|
other => bail!("expected a ping, got {other:?}"),
|
||||||
}
|
}
|
||||||
// Ask the policy what to send. None -> answer nothing (stranger / invisible):
|
send.write_all(&encode(&reply)?)
|
||||||
// finish the stream with no bytes so the prober sees an empty (unusable) reply.
|
.await
|
||||||
if let Some(reply) = handler(from) {
|
.context("failed to write pong")?;
|
||||||
send.write_all(&encode(&reply)?)
|
|
||||||
.await
|
|
||||||
.context("failed to write pong")?;
|
|
||||||
}
|
|
||||||
send.finish().context("failed to finish reply stream")?;
|
send.finish().context("failed to finish reply stream")?;
|
||||||
Ok::<_, anyhow::Error>(())
|
Ok::<_, anyhow::Error>(())
|
||||||
};
|
};
|
||||||
@@ -220,10 +221,11 @@ mod tests {
|
|||||||
let serve_task = tokio::spawn(async move { serve(server_ep, handler).await });
|
let serve_task = tokio::spawn(async move { serve(server_ep, handler).await });
|
||||||
|
|
||||||
// The allowed prober gets a Pong with the room.
|
// 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
|
.await
|
||||||
.expect("probe timed out")
|
.expect("probe timed out")
|
||||||
.expect("probe failed");
|
.expect("probe failed");
|
||||||
|
assert_eq!(from, server_addr.id);
|
||||||
match pong {
|
match pong {
|
||||||
ControlMsg::Pong { room: Some(r) } => assert_eq!(r.name, "HangOut"),
|
ControlMsg::Pong { room: Some(r) } => assert_eq!(r.name, "HangOut"),
|
||||||
other => panic!("expected Pong with a room, got {other:?}"),
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
-1
@@ -25,6 +25,10 @@ use tokio::process::{Child, Command};
|
|||||||
/// points elsewhere.
|
/// points elsewhere.
|
||||||
const PIXELPASS_BIN: &str = "pixelpass";
|
const PIXELPASS_BIN: &str = "pixelpass";
|
||||||
|
|
||||||
|
/// 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
|
/// 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
|
/// 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.
|
/// is only a safety net so a hung pixelpass can't wedge the caller forever.
|
||||||
@@ -108,6 +112,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
|
/// Resolve the pixelpass binary: an explicit config override (used only if it
|
||||||
/// points at an existing file), otherwise the first `pixelpass` found on
|
/// points at an existing file), otherwise the first `pixelpass` found on
|
||||||
/// `$PATH`. `None` means it isn't installed — a normal, handled state. An
|
/// `$PATH`. `None` means it isn't installed — a normal, handled state. An
|
||||||
@@ -267,12 +284,29 @@ where
|
|||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Ok(Some(line)) = lines.next_line().await {
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
if let Some(ev) = parse_pixelpass_event(&line) {
|
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
|
/// 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
|
/// 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.
|
/// background task so it doesn't linger as a zombie when its window closes.
|
||||||
@@ -340,6 +374,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]
|
#[test]
|
||||||
fn parses_ticket() {
|
fn parses_ticket() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
# Codex task report - 2026-06-16
|
|
||||||
|
|
||||||
## W2 - Per-peer EQ
|
|
||||||
|
|
||||||
- Added `src/audio/eq.rs`: a 3-band listener-side RBJ biquad EQ (low shelf, mid peaking, high shelf) with per-peer state and flat bypass.
|
|
||||||
- Added local config persistence in `AppConfig.peer_eq`, keyed by peer node id string.
|
|
||||||
- Added local `CoreCommand::SetPeerEq` and mixer-side per-peer `Eq` state. EQ is applied after local volume and before pan/mix; raw multitrack stems remain pre-volume/pre-EQ.
|
|
||||||
- Added participant-card controls for Low/Mid/High gain sliders (-12 dB to +12 dB). Changes apply live and persist on slider release.
|
|
||||||
- Tests added for flat identity, low/high boost energy, coefficient finiteness, clamping, and hot-signal processing.
|
|
||||||
|
|
||||||
Unverified: subjective voice quality and zipper/noise behavior on real devices.
|
|
||||||
|
|
||||||
## W1 - Per-listener pan / stereo playback
|
|
||||||
|
|
||||||
- Added `src/audio/pan.rs`: constant-power `pan_gains()` with tests, plus playback gains that preserve the legacy default dual-mono center.
|
|
||||||
- Converted playback mix to interleaved stereo in `src/core/mod.rs`.
|
|
||||||
- Switched PipeWire playback output to 2-channel S16LE and adjusted ring target/capacity/stride accounting in `src/audio/pipewire_impl.rs`.
|
|
||||||
- Kept capture, Opus encode/decode, jitter buffers, and network audio mono.
|
|
||||||
- Limiter now receives the interleaved stereo bus; shared limiter gain ducks both channels consistently.
|
|
||||||
- Mixed WAV and multitrack convenience mix fold the listener stereo mix back to mono before writing. Per-peer stems remain raw mono.
|
|
||||||
- Updated `audio_probe` to send dual-mono stereo frames.
|
|
||||||
- Added tests for exact center dual-mono behavior, hard-left pan contribution, and stereo fold-down.
|
|
||||||
|
|
||||||
Decision for senior sanity-check: pure pan law is constant-power, but playback scales it by sqrt(2) so pan=0 is exactly the old mono signal in both ears. This satisfies the "default behavior unchanged" guardrail at the cost of louder hard-panned extremes, which the existing limiter catches.
|
|
||||||
|
|
||||||
Unverified: real PipeWire stereo playback, underrun behavior on actual hardware, and recorded WAV listening checks.
|
|
||||||
|
|
||||||
## W5 - Focused hotkeys + info popup
|
|
||||||
|
|
||||||
- Added `src/hotkeys.rs`: serializable `KeyBinding`, `HotkeyAction`, `HotkeyMap`, parse/format/lookup, tier checks, and duplicate conflict detection.
|
|
||||||
- Added `AppConfig.hotkeys` with defaults: F9 mute, F10 deafen, F2 Settings, Space push-to-talk, Leave unset.
|
|
||||||
- Replaced the hard-coded PTT key capture with config-backed binding capture.
|
|
||||||
- Added Settings hotkey editor with Set/Clear per action and live conflict warnings.
|
|
||||||
- Added top-right hotkey info popup that lists every action and current binding, showing `unset` for unbound actions.
|
|
||||||
- Routed focused iced key events through the map. App-wide actions can fire from any screen while focused; room-only actions require an active call. PTT press/release still uses `SetPttActive`.
|
|
||||||
- Tests added for unset formatting, duplicate detection, room-tier lookup, defaults, and character parse/format.
|
|
||||||
|
|
||||||
Unverified: manual keyboard interaction in the GUI. No OS-global hooks were added.
|
|
||||||
|
|
||||||
## W3 - PipeWire pro-routing plan (not implemented)
|
|
||||||
|
|
||||||
I stopped at design for W3. The current backend already supports simple target-node routing through PipeWire stream property `node.target`, but true "pro routing" (explicit ports / manual graph links / no-autoconnect patching) would require backend changes that are not safely verifiable offline.
|
|
||||||
|
|
||||||
Proposed future scope:
|
|
||||||
|
|
||||||
- Expose two advanced route targets: capture source node and playback sink node, with optional future per-port routing.
|
|
||||||
- Enumerate available nodes with the existing `pw-cli list-objects Node` parser. For port-level routing, add a separate parser for `pw-cli list-objects Port` collecting `object.id`, `node.id`, `port.name`, direction, and channel position.
|
|
||||||
- For node-level routing, continue using PipeWire stream property `node.target` on stream creation. This is the low-risk path and matches current backend behavior.
|
|
||||||
- For explicit port routing, do not use `AUTOCONNECT`; instead capture the created PeerSpeak stream node/port ids from the PipeWire registry, then link with PipeWire-native APIs or `pw-link <source-port-id> <sink-port-id>`. Degrade by falling back to `node.target` autoconnect if any selected node/port is missing.
|
|
||||||
- Offline tests should cover pure routing-plan decisions: selected node exists/missing, selected port exists/missing, capture/playback direction mismatch, and fallback choice. Real-device tests still need a PipeWire graph.
|
|
||||||
|
|
||||||
Reason for not implementing: the current `run_playback` / `run_capture` code does not retain stream node or port ids, and changing `AUTOCONNECT` behavior plus adding manual `pw-link` calls could destabilize the working audio path. That matches the assignment's "bail if risky" instruction.
|
|
||||||
|
|
||||||
## Backlog A21/A22 - correctness fixes
|
|
||||||
|
|
||||||
- Fixed A21 in `src/core/jitter.rs`: implausibly large sequence discontinuities now reset the per-peer jitter stream instead of being treated as ordinary late packets or packet loss.
|
|
||||||
- The reset threshold is `500` frames, about 10 seconds at 20 ms/frame. That covers both same-identity sender restart back to sequence 0 and a faulty/malicious jump far ahead that would otherwise force a long PLC run.
|
|
||||||
- Added jitter regression tests for both far-behind restart and far-ahead jump cases.
|
|
||||||
- Fixed A22 in `src/audio/recorder.rs`: `WavWriter` now tracks data bytes as `u64`, checks additions before writing, and rejects data that cannot fit both the RIFF size field and the `data` chunk size field.
|
|
||||||
- Added a WAV overflow regression test that exercises the limit without creating a huge file.
|
|
||||||
|
|
||||||
Unverified: the same-identity peer restart has not been exercised in a live 2-machine call; the WAV fix is counter/size-field tested, not a real >12h recording.
|
|
||||||
|
|
||||||
## Backlog A14 - orderly window-close shutdown
|
|
||||||
|
|
||||||
- Added `CoreCommand::Shutdown` and `UiEvent::ShutdownComplete`.
|
|
||||||
- Window close now saves config, marks the GUI as closing, asynchronously queues `Shutdown`, and exits only after the core acknowledges completion or after a 5-second fallback timeout.
|
|
||||||
- Core shutdown finalizes active mixed/multitrack recordings before session teardown, stops the standalone mic monitor, runs `ActiveSession::shutdown()` for active calls, clears room presence/routing, closes the persistent network stack, sends `ShutdownComplete`, and ends the core loop.
|
|
||||||
- The shutdown command is queued with an awaited `mpsc::Sender::send` task instead of the best-effort `try_send`, so a full command queue does not immediately drop the close command.
|
|
||||||
|
|
||||||
Unverified: actual GUI window-close behavior during a live call/recording still needs a manual run; tests/builds only prove the path compiles and existing unit coverage still passes.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- `cargo check` passed.
|
|
||||||
- `cargo test --lib` passed: 288 passed, 0 failed, 2 ignored.
|
|
||||||
- `cargo clippy --all-targets` passed.
|
|
||||||
- `cargo build --release` passed.
|
|
||||||
- Formatted the touched Rust files with `rustfmt --edition 2024`; I did not run repo-wide `cargo fmt` to avoid unrelated formatting churn.
|
|
||||||
|
|
||||||
No new dependencies were added. Runtime/manual/field verification is still pending for audio-device and 2-machine behavior.
|
|
||||||
@@ -169,6 +169,9 @@ async fn loopback_sequenced_audio_reaches_peer_and_decodes() {
|
|||||||
b.lookup.add_endpoint_info(a.endpoint.addr());
|
b.lookup.add_endpoint_info(a.endpoint.addr());
|
||||||
|
|
||||||
let a_id = a.endpoint.id();
|
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.
|
// Subscribe to incoming datagrams on B before any are sent.
|
||||||
let mut b_rx = b.transport.receive_datagrams().await.expect("subscribe B");
|
let mut b_rx = b.transport.receive_datagrams().await.expect("subscribe B");
|
||||||
|
|||||||
Reference in New Issue
Block a user