Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e4f2f2127 | ||
|
|
b6eac330ca | ||
|
|
7fb1c96ca9 | ||
|
|
80a5b73e39 | ||
|
|
adf7d1c0e2 | ||
|
|
bcb597a0ea | ||
|
|
79b24fd567 | ||
|
|
efadc228eb | ||
|
|
2d067a2e41 | ||
|
|
8ea40f719c | ||
|
|
60c1951567 | ||
|
|
f75760b14e | ||
|
|
02cb46550e | ||
|
|
cbba4b644e | ||
|
|
c5375e200a | ||
|
|
06e97b9f50 | ||
|
|
601ec92181 | ||
|
|
713526b2a8 | ||
|
|
450121b591 | ||
|
|
eab9357f23 | ||
|
|
57f21a0edf | ||
|
|
70a0e6798f | ||
|
|
a30d9d5dbf | ||
|
|
2a6e6401ad | ||
|
|
a0a5922389 | ||
|
|
2c93c1c24f | ||
|
|
5564af02f9 | ||
|
|
ae29d1fea2 | ||
|
|
7ff7766ede | ||
|
|
b0fdd4e058 | ||
|
|
306bc295b1 | ||
|
|
8e0b4c16ec | ||
|
|
f52b5ea64e | ||
|
|
4d07e03395 | ||
|
|
20bfcffe6d | ||
|
|
185d47aa8d | ||
|
|
2eae95ede0 | ||
|
|
fdd532de53 | ||
|
|
46809153d8 | ||
|
|
6ccad0d37a | ||
|
|
ddb3d2aabc | ||
|
|
bbbe2d8f17 | ||
|
|
63b45e03ab | ||
|
|
2937e5191a | ||
|
|
47c58047ce | ||
|
|
85b12a26c9 | ||
|
|
e4767be210 | ||
|
|
10ee765ffd | ||
|
|
3034c42f71 | ||
|
|
465c7ba2b0 | ||
|
|
3ec09de87e | ||
|
|
4b8fb92dc5 | ||
|
|
1adf8a97bb | ||
|
|
10707152a3 | ||
|
|
f2e72624f7 | ||
|
|
319d0c5e29 | ||
|
|
d56c2c90b2 | ||
|
|
5086e86bd2 | ||
|
|
54780fa73b | ||
|
|
b1aa751a84 | ||
|
|
9efab491c7 | ||
|
|
f3f399a748 | ||
|
|
1afdccbefe | ||
|
|
7724da73b8 | ||
|
|
8982df364e | ||
|
|
33e3998e7c | ||
|
|
44bad7b70b | ||
|
|
20643a24de |
@@ -0,0 +1,21 @@
|
|||||||
|
# PeerSpeak Codebase Layout and Architecture Rules
|
||||||
|
|
||||||
|
When working in the PeerSpeak repository, adhere to the following architectural boundaries and layout:
|
||||||
|
|
||||||
|
## Code Layout
|
||||||
|
- `src/main.rs`: The application entry point (initializes Tokio and the Iced GUI).
|
||||||
|
- `src/app/`: The UI layer (Iced). Handles themes, views (Home, Room, Settings), and visual state. Must communicate with the core via message passing (`UiEvent`/`CoreCommand`), not direct function calls.
|
||||||
|
- `src/core/`: The central orchestrator.
|
||||||
|
- `mod.rs`: Manages the session lifecycle, ties together network and UI, and manages the async mixer tasks.
|
||||||
|
- `jitter.rs`: Houses the adaptive playout delay JitterBuffer and Packet Loss Concealment (PLC) logic.
|
||||||
|
- `src/network/`: The "Dual-Plane" transport layer.
|
||||||
|
- `gossip.rs` (Control Plane): Built on `iroh-gossip`. Manages room rosters, verified membership, presence, and chat via cryptographically signed envelopes.
|
||||||
|
- `iroh_impl.rs` (Data Plane): Manages raw QUIC endpoints and peer connections. Forwards UDP voice datagrams directly to peers for minimum latency.
|
||||||
|
- `src/audio/`: Hardware audio backends.
|
||||||
|
- Interfaces heavily with `cpal_impl.rs` (Windows/WASAPI) and `pipewire_impl.rs` (Linux).
|
||||||
|
- **CRITICAL RULE**: The RT audio callbacks are strictly lock-free. They communicate with the async core exclusively via Single-Producer Single-Consumer (SPSC) ring buffers (`HeapRb`). Never allocate memory, log to stdout, or lock Mutexes on the RT threads.
|
||||||
|
- `src/codec/`: Audio compression abstractions, standardizing on Opus at 48kHz mono (`opus_impl.rs`).
|
||||||
|
|
||||||
|
## General Directives
|
||||||
|
- **Security**: Audio admission is strictly derived from the verified gossip roster (S8). Never trust raw UDP sender IDs without validating against gossip.
|
||||||
|
- **Latency**: Preserve the deterministic dialer vs acceptor logic in the QUIC layer to prevent connection loops.
|
||||||
@@ -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
|
||||||
@@ -6,3 +6,8 @@
|
|||||||
/packaging/peerspeak/
|
/packaging/peerspeak/
|
||||||
/packaging/*.pkg.tar.*
|
/packaging/*.pkg.tar.*
|
||||||
/packaging/*.log
|
/packaging/*.log
|
||||||
|
|
||||||
|
# Windows installer build artifacts (the staged exe + compiled setup.exe);
|
||||||
|
# the .iss script and .ico are the tracked sources.
|
||||||
|
/packaging/windows/peerspeak.exe
|
||||||
|
/packaging/windows/output/
|
||||||
|
|||||||
@@ -105,6 +105,40 @@ 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"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c88dbbce13b232b26250e1e2e6ac18b6a891a646b8148285036ebce260ac5c3"
|
||||||
|
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 +148,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 +746,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 +1042,40 @@ 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-rs"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"libc",
|
||||||
|
"objc2-audio-toolbox",
|
||||||
|
"objc2-core-audio",
|
||||||
|
"objc2-core-audio-types",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 +1100,59 @@ 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 0.9.1",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"coreaudio-rs 0.11.3",
|
||||||
|
"dasp_sample",
|
||||||
|
"jni 0.21.1",
|
||||||
|
"js-sys",
|
||||||
|
"libc",
|
||||||
|
"mach2 0.4.3",
|
||||||
|
"ndk 0.8.0",
|
||||||
|
"ndk-context",
|
||||||
|
"oboe",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
"windows 0.54.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpal"
|
||||||
|
version = "0.17.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5b1f9c7312f19fc2fa12fd7acaf38de54e8320ba10d1a02dcbe21038def51ccb"
|
||||||
|
dependencies = [
|
||||||
|
"alsa 0.10.0",
|
||||||
|
"coreaudio-rs 0.13.0",
|
||||||
|
"dasp_sample",
|
||||||
|
"jni 0.21.1",
|
||||||
|
"js-sys",
|
||||||
|
"libc",
|
||||||
|
"mach2 0.5.0",
|
||||||
|
"ndk 0.9.0",
|
||||||
|
"ndk-context",
|
||||||
|
"num-derive",
|
||||||
|
"num-traits",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-audio-toolbox",
|
||||||
|
"objc2-avf-audio",
|
||||||
|
"objc2-core-audio",
|
||||||
|
"objc2-core-audio-types",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
"windows 0.62.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -1228,6 +1355,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"
|
||||||
@@ -1487,6 +1620,15 @@ version = "0.6.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "encoding_rs"
|
||||||
|
version = "0.8.35"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "endi"
|
name = "endi"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -1622,6 +1764,12 @@ dependencies = [
|
|||||||
"zune-inflate",
|
"zune-inflate",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "extended"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.4.1"
|
version = "2.4.1"
|
||||||
@@ -2227,7 +2375,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 +2395,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 +2418,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",
|
||||||
@@ -2480,6 +2628,7 @@ dependencies = [
|
|||||||
"iced_core",
|
"iced_core",
|
||||||
"log",
|
"log",
|
||||||
"rustc-hash 2.1.2",
|
"rustc-hash 2.1.2",
|
||||||
|
"tokio",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"wasmtimer",
|
"wasmtimer",
|
||||||
]
|
]
|
||||||
@@ -3102,6 +3251,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 +3628,24 @@ 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]]
|
||||||
|
name = "mach2"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "malloc_buf"
|
name = "malloc_buf"
|
||||||
version = "0.0.6"
|
version = "0.0.6"
|
||||||
@@ -3596,7 +3779,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 +3878,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 +3901,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 +3913,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"
|
||||||
@@ -4127,6 +4333,31 @@ dependencies = [
|
|||||||
"objc2-quartz-core 0.3.2",
|
"objc2-quartz-core 0.3.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-audio-toolbox"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.1",
|
||||||
|
"libc",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-core-audio",
|
||||||
|
"objc2-core-audio-types",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-avf-audio"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be"
|
||||||
|
dependencies = [
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-cloud-kit"
|
name = "objc2-cloud-kit"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -4162,6 +4393,29 @@ dependencies = [
|
|||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-core-audio"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2"
|
||||||
|
dependencies = [
|
||||||
|
"dispatch2",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-core-audio-types",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-core-audio-types"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.1",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-core-data"
|
name = "objc2-core-data"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -4466,6 +4720,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 +4871,13 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "peerspeak"
|
name = "peerspeak"
|
||||||
version = "0.1.0"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"cpal 0.15.3",
|
||||||
"dirs",
|
"dirs",
|
||||||
"iced",
|
"iced",
|
||||||
"image",
|
"image",
|
||||||
@@ -4610,6 +4888,7 @@ dependencies = [
|
|||||||
"rand 0.10.1",
|
"rand 0.10.1",
|
||||||
"rfd",
|
"rfd",
|
||||||
"ringbuf",
|
"ringbuf",
|
||||||
|
"rodio",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
@@ -5049,6 +5328,16 @@ version = "0.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_distr"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
"rand 0.10.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_pcg"
|
name = "rand_pcg"
|
||||||
version = "0.10.2"
|
version = "0.10.2"
|
||||||
@@ -5338,12 +5627,34 @@ dependencies = [
|
|||||||
"portable-atomic-util",
|
"portable-atomic-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rodio"
|
||||||
|
version = "0.22.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d0a536bb79db59098ef71a4dd4246c02eb87b316deceb1b68e0cde7167ec01eb"
|
||||||
|
dependencies = [
|
||||||
|
"cpal 0.17.1",
|
||||||
|
"dasp_sample",
|
||||||
|
"num-rational",
|
||||||
|
"rand 0.10.1",
|
||||||
|
"rand_distr",
|
||||||
|
"rtrb",
|
||||||
|
"symphonia",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "roxmltree"
|
name = "roxmltree"
|
||||||
version = "0.20.0"
|
version = "0.20.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rtrb"
|
||||||
|
version = "0.3.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4ade083ccbb4bf536df69d1f6432cc23deb7acccff86b183f3923a6fd56a1153"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -5436,7 +5747,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 +6188,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",
|
||||||
@@ -6007,6 +6318,153 @@ dependencies = [
|
|||||||
"zeno",
|
"zeno",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039"
|
||||||
|
dependencies = [
|
||||||
|
"lazy_static",
|
||||||
|
"symphonia-bundle-flac",
|
||||||
|
"symphonia-bundle-mp3",
|
||||||
|
"symphonia-codec-aac",
|
||||||
|
"symphonia-codec-pcm",
|
||||||
|
"symphonia-codec-vorbis",
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-format-isomp4",
|
||||||
|
"symphonia-format-ogg",
|
||||||
|
"symphonia-format-riff",
|
||||||
|
"symphonia-metadata",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-bundle-flac"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-metadata",
|
||||||
|
"symphonia-utils-xiph",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-bundle-mp3"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed"
|
||||||
|
dependencies = [
|
||||||
|
"lazy_static",
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-metadata",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-codec-aac"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790"
|
||||||
|
dependencies = [
|
||||||
|
"lazy_static",
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-codec-pcm"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-codec-vorbis"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-utils-xiph",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-core"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
|
||||||
|
dependencies = [
|
||||||
|
"arrayvec",
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"bytemuck",
|
||||||
|
"lazy_static",
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-format-isomp4"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5"
|
||||||
|
dependencies = [
|
||||||
|
"encoding_rs",
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-metadata",
|
||||||
|
"symphonia-utils-xiph",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-format-ogg"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-metadata",
|
||||||
|
"symphonia-utils-xiph",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-format-riff"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f"
|
||||||
|
dependencies = [
|
||||||
|
"extended",
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-metadata",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-metadata"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
|
||||||
|
dependencies = [
|
||||||
|
"encoding_rs",
|
||||||
|
"lazy_static",
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-utils-xiph"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16"
|
||||||
|
dependencies = [
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-metadata",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.117"
|
version = "2.0.117"
|
||||||
@@ -7162,7 +7620,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 +7705,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 +7722,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 +7746,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 +7766,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 +7864,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 +7898,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 +7910,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 +7937,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 +7977,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 +8019,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 +8089,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",
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "peerspeak"
|
name = "peerspeak"
|
||||||
version = "0.1.0"
|
version = "0.3.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"
|
||||||
@@ -25,23 +28,40 @@ async-trait = "0.1.89"
|
|||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
bytes = "1.11.1"
|
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", "tokio"] }
|
||||||
# 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"
|
||||||
|
rodio = "0.22.2"
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.150"
|
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(target_os = "linux")'.dependencies]
|
||||||
|
# Linux audio backend. v0_3_49 exposes `Buffer::requested()` (the graph's per-cycle
|
||||||
|
# quantum), used by the playback RT callback to fill exactly what the device asks
|
||||||
|
# for instead of a hard-coded 1024-frame quantum (crackle on non-1024 hardware).
|
||||||
|
# The field has existed in libpipewire since 0.3.49 (2022).
|
||||||
|
pipewire = { version = "0.9", features = ["v0_3_49"] }
|
||||||
|
# Native file picker via the XDG desktop portal (no GTK) on Linux.
|
||||||
|
rfd = { version = "0.17", default-features = false, features = ["xdg-portal"] }
|
||||||
|
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
# Native file picker using the built-in Win32 dialog backend on Windows.
|
||||||
|
rfd = { version = "0.17", default-features = false }
|
||||||
|
# Windows audio backend: cpal drives WASAPI for capture/playback behind the
|
||||||
|
# AudioBackend trait (src/audio/cpal_impl.rs). The Linux counterpart is pipewire.
|
||||||
|
cpal = "0.15"
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Security Review: `security-scan` branch (PeerSpeak)
|
||||||
|
|
||||||
|
_Date: 2026-06-18_
|
||||||
|
|
||||||
|
**Scope:** Protocol-versioning migration (`src/protocol.rs`, `versioned_topic`,
|
||||||
|
ALPN/domain centralization, gossip topic namespacing) and the `deny.toml`
|
||||||
|
supply-chain policy addition.
|
||||||
|
|
||||||
|
## Result: No high-confidence security vulnerabilities found.
|
||||||
|
|
||||||
|
Each plausible attack surface introduced by this branch was investigated and
|
||||||
|
confirmed safe:
|
||||||
|
|
||||||
|
### 1. `versioned_topic` XOR transform — topic secrecy preserved
|
||||||
|
`src/protocol.rs:46`, used at `src/network/gossip.rs:255`
|
||||||
|
|
||||||
|
The room `topic_id` is a uniformly random 32-byte secret (`rand::random()`,
|
||||||
|
`src/core/mod.rs:1012`) acting as the room capability. XOR-ing it with the public
|
||||||
|
constant `GOSSIP_PROTO.to_le_bytes()` cyclically is **bijective and
|
||||||
|
entropy-preserving** — the result is still uniformly random; no byte becomes
|
||||||
|
predictable and no entropy is lost. The room secret is no more recoverable by an
|
||||||
|
observer than before the change (previously the raw `topic_id` was the on-wire
|
||||||
|
topic; now it's a trivial public XOR of it). Bijectivity also preserves room
|
||||||
|
distinctness, so isolation is not weakened. **Not a vulnerability.**
|
||||||
|
|
||||||
|
### 2. Signature topic-binding — no raw/versioned confusion
|
||||||
|
`src/network/gossip.rs`
|
||||||
|
|
||||||
|
`active_topic_bytes` stores the **raw** `ticket.topic_id` (line 293), and both
|
||||||
|
`sign_gossip` and `verify_gossip` bind against that raw value. Only the
|
||||||
|
*subscribed* swarm topic (line 255) uses the versioned value. There is one swarm
|
||||||
|
per join and every peer signs/verifies against the same raw topic, so no second
|
||||||
|
topic exists to enable a raw↔versioned replay/confusion attack. Code matches
|
||||||
|
VERSIONING.md's claim. **Not a vulnerability.**
|
||||||
|
|
||||||
|
### 3. `GOSSIP_SIG_DOMAIN` — moved verbatim
|
||||||
|
Value identical (`"peerspeak-gossip-v1"`, `src/protocol.rs:34`); cross-version
|
||||||
|
cryptographic domain separation preserved. **Not a vulnerability.**
|
||||||
|
|
||||||
|
### 4. ALPN changes — handshake compatibility only
|
||||||
|
Audio `peerspeak-audio` → `peerspeak/audio/1`, friends `/0` → `/1`. No security
|
||||||
|
check keys off the old ALPN strings (audio admission is gated by live room
|
||||||
|
membership per S8, not the ALPN literal); no residual references to old strings
|
||||||
|
in non-test code. **Not a vulnerability.**
|
||||||
|
|
||||||
|
### 5. `deny.toml`
|
||||||
|
Ignores only two *unmaintained* advisories (`RUSTSEC-2024-0436`,
|
||||||
|
`RUSTSEC-2026-0150`) on compile-time/FFI-only crates — documented, and dependency
|
||||||
|
advisories are out of scope. **Not a vulnerability.**
|
||||||
|
|
||||||
|
The versioning migration is a clean, security-preserving change.
|
||||||
@@ -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.
|
||||||
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 606 B After Width: | Height: | Size: 843 B |
|
Before Width: | Height: | Size: 994 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 5.7 KiB |
@@ -1,41 +1,66 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
|
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
|
||||||
<!-- PeerSpeak app icon: in-app mic glyph + P2P mesh nodes, Catppuccin Mocha. -->
|
<title>PeerSpeak</title>
|
||||||
|
<desc>Two luminous voices meet directly to form a flowing S.</desc>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="tile" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="tile" x1="32" y1="20" x2="225" y2="239" gradientUnits="userSpaceOnUse">
|
||||||
<stop offset="0" stop-color="#1e1e2e"/>
|
<stop stop-color="#101d42"/>
|
||||||
<stop offset="1" stop-color="#181825"/>
|
<stop offset="0.5" stop-color="#071225"/>
|
||||||
|
<stop offset="1" stop-color="#160b31"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
<linearGradient id="voice" x1="45" y1="76" x2="214" y2="184" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#35efff"/>
|
||||||
|
<stop offset="0.42" stop-color="#2583ff"/>
|
||||||
|
<stop offset="0.68" stop-color="#8a42ff"/>
|
||||||
|
<stop offset="1" stop-color="#ff3cdd"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="edge" x1="30" y1="31" x2="225" y2="231" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#2fe9ff" stop-opacity="0.7"/>
|
||||||
|
<stop offset="0.48" stop-color="#386dff" stop-opacity="0.18"/>
|
||||||
|
<stop offset="1" stop-color="#eb42ff" stop-opacity="0.65"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="core">
|
||||||
|
<stop stop-color="#ffffff"/>
|
||||||
|
<stop offset="0.28" stop-color="#baf7ff"/>
|
||||||
|
<stop offset="0.62" stop-color="#7b67ff" stop-opacity="0.65"/>
|
||||||
|
<stop offset="1" stop-color="#7b67ff" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="shadow" x="-35%" y="-35%" width="170%" height="170%">
|
||||||
|
<feGaussianBlur stdDeviation="6"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="soft-shadow" x="-20%" y="-20%" width="140%" height="150%">
|
||||||
|
<feDropShadow dx="0" dy="7" stdDeviation="7" flood-color="#000611" flood-opacity="0.8"/>
|
||||||
|
</filter>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
<!-- Rounded-square tile -->
|
<!-- A dark stage makes the cyan/violet conversation mark legible at taskbar size. -->
|
||||||
<rect x="20" y="20" width="216" height="216" rx="48" fill="url(#tile)"
|
<rect x="8" y="8" width="240" height="240" rx="55" fill="url(#tile)"/>
|
||||||
stroke="#313244" stroke-width="3"/>
|
<rect x="9.5" y="9.5" width="237" height="237" rx="53.5" fill="none" stroke="url(#edge)" stroke-width="3"/>
|
||||||
|
|
||||||
<!-- P2P mesh: edges (under nodes + mic) -->
|
<!-- Broad color glow, kept behind the silhouette. -->
|
||||||
<g stroke="#45475a" stroke-width="6" stroke-linecap="round" fill="none">
|
<path d="M36 128 C50 128 48 101 60 101 C72 101 68 153 81 153 C94 153 90 112 104 112 C115 112 116 128 128 128 C140 128 141 144 152 144 C166 144 162 103 175 103 C188 103 184 155 196 155 C208 155 206 128 220 128"
|
||||||
<line x1="74" y1="74" x2="128" y2="128"/>
|
fill="none" stroke="url(#voice)" stroke-width="25" stroke-linecap="round"
|
||||||
<line x1="182" y1="74" x2="128" y2="128"/>
|
opacity="0.5" filter="url(#shadow)"/>
|
||||||
<line x1="74" y1="182" x2="128" y2="128"/>
|
|
||||||
<line x1="182" y1="182" x2="128" y2="128"/>
|
|
||||||
<line x1="74" y1="74" x2="182" y2="74"/>
|
|
||||||
<line x1="74" y1="182" x2="182" y2="182"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- P2P mesh: peer nodes -->
|
<!-- The two waveform halves are equal peers and meet at one bright point. -->
|
||||||
<g fill="#b4befe">
|
<path d="M36 128 C50 128 48 101 60 101 C72 101 68 153 81 153 C94 153 90 112 104 112 C115 112 116 128 128 128"
|
||||||
<circle cx="74" cy="74" r="11"/>
|
fill="none" stroke="url(#voice)" stroke-width="17" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
<circle cx="182" cy="74" r="11"/>
|
<path d="M128 128 C140 128 141 144 152 144 C166 144 162 103 175 103 C188 103 184 155 196 155 C208 155 206 128 220 128"
|
||||||
<circle cx="74" cy="182" r="11"/>
|
fill="none" stroke="url(#voice)" stroke-width="17" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
<circle cx="182" cy="182" r="11"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- Microphone (hero) — same geometry as the in-app Mic icon, scaled 6.4x -->
|
<!-- A single flowing connection turns the conversation into PeerSpeak's S-mark. -->
|
||||||
<g fill="none" stroke="#89b4fa" stroke-width="13"
|
<path d="M160 66 C142 52 108 57 101 78 C94 98 113 111 128 119 C148 130 163 141 157 164 C151 188 117 199 94 184"
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
fill="none" stroke="#050b1b" stroke-opacity="0.72" stroke-width="33"
|
||||||
<rect x="108.8" y="68.8" width="38.4" height="70.4" rx="19.2"/>
|
stroke-linecap="round" stroke-linejoin="round" filter="url(#soft-shadow)"/>
|
||||||
<path d="M 169.6 123.2 A 41.6 41.6 0 0 0 86.4 123.2"/>
|
<path d="M160 66 C142 52 108 57 101 78 C94 98 113 111 128 119 C148 130 163 141 157 164 C151 188 117 199 94 184"
|
||||||
<line x1="128" y1="164.8" x2="128" y2="187.2"/>
|
fill="none" stroke="url(#voice)" stroke-width="25"
|
||||||
<line x1="105.6" y1="187.2" x2="150.4" y2="187.2"/>
|
stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</g>
|
<path d="M157 65 C139 56 113 62 108 79" fill="none" stroke="#bdf9ff"
|
||||||
|
stroke-opacity="0.68" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<path d="M153 166 C146 184 118 192 98 181" fill="none" stroke="#f4a8ff"
|
||||||
|
stroke-opacity="0.52" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- The direct connection is the brightest and simplest detail. -->
|
||||||
|
<circle cx="128" cy="128" r="30" fill="url(#core)" opacity="0.78" filter="url(#shadow)"/>
|
||||||
|
<circle cx="128" cy="128" r="6.5" fill="#ffffff"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,88 @@
|
|||||||
|
# cargo-deny policy for peerspeak
|
||||||
|
#
|
||||||
|
# Supersedes a bare `cargo audit` run. Enforce with:
|
||||||
|
# cargo install cargo-deny --locked
|
||||||
|
# cargo deny check
|
||||||
|
#
|
||||||
|
# In CI, run `cargo deny check` on a locked tree so the pinned, vetted
|
||||||
|
# versions in Cargo.lock are what actually get audited.
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Advisories: RustSec database. Vulnerabilities and yanked crates are denied
|
||||||
|
# by default. The two `ignore` entries below are *unmaintained* warnings only
|
||||||
|
# (no known exploit); they are deep transitive deps we cannot remove. Pinning
|
||||||
|
# them via Cargo.lock is our real protection — a future malicious release does
|
||||||
|
# not reach us until we deliberately `cargo update`, so each update is a review
|
||||||
|
# checkpoint. Revisit these if either advisory is upgraded to a vulnerability.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
[advisories]
|
||||||
|
ignore = [
|
||||||
|
# paste: unmaintained, compile-time proc-macro only (zero runtime surface),
|
||||||
|
# transitive via iroh/netdev/netlink and rav1e/image/iced. Maintained fork
|
||||||
|
# `pastey` is already in the tree; stragglers will follow upstream.
|
||||||
|
"RUSTSEC-2024-0436",
|
||||||
|
# audiopus_sys: unmaintained FFI bindings to the stable libopus C library,
|
||||||
|
# pulled in via our direct `opus 0.3.1` dep. No drop-in replacement.
|
||||||
|
"RUSTSEC-2026-0150",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bans: shape of the dependency graph.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
[bans]
|
||||||
|
# Multiple versions of the same crate bloat the build; warn rather than fail
|
||||||
|
# since transitive graphs (iroh, iced) routinely carry duplicates we can't fix.
|
||||||
|
multiple-versions = "warn"
|
||||||
|
# Wildcard ("*") version requirements are a supply-chain footgun: they accept
|
||||||
|
# any future release, defeating the lockfile-as-review-checkpoint model.
|
||||||
|
wildcards = "deny"
|
||||||
|
# ...but our own intra-repo path deps may use "*"; don't penalize those.
|
||||||
|
allow-wildcard-paths = true
|
||||||
|
|
||||||
|
# Crates that may never appear in the graph. Add a maintained replacement's
|
||||||
|
# predecessor here once you've migrated off it, to prevent regressions.
|
||||||
|
deny = []
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sources: where crates are allowed to come from. This is the core anti-hijack
|
||||||
|
# control — only the official crates.io registry is trusted; arbitrary git
|
||||||
|
# sources (a common vector for slipping in unaudited code) are rejected.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
[sources]
|
||||||
|
unknown-registry = "deny"
|
||||||
|
unknown-git = "deny"
|
||||||
|
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
|
||||||
|
# allow-git = [] # add a specific, pinned git repo here only if ever needed
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Licenses: permissive set covering the current graph. If `cargo deny check`
|
||||||
|
# reports an unmatched license, vet it and add the SPDX id here (or add a
|
||||||
|
# per-crate entry under [licenses.exceptions]) rather than widening blindly.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
[licenses]
|
||||||
|
allow = [
|
||||||
|
"MIT",
|
||||||
|
"Apache-2.0",
|
||||||
|
"Apache-2.0 WITH LLVM-exception",
|
||||||
|
"BSD-2-Clause",
|
||||||
|
"BSD-3-Clause",
|
||||||
|
"ISC",
|
||||||
|
"Zlib",
|
||||||
|
"MPL-2.0",
|
||||||
|
"Unicode-3.0",
|
||||||
|
"Unicode-DFS-2016",
|
||||||
|
"CC0-1.0",
|
||||||
|
"0BSD",
|
||||||
|
"Unlicense",
|
||||||
|
"BSL-1.0",
|
||||||
|
"NCSA", # University of Illinois/NCSA — BSD-like permissive
|
||||||
|
"CDLA-Permissive-2.0", # Community Data License Agreement, permissive
|
||||||
|
]
|
||||||
|
confidence-threshold = 0.8
|
||||||
|
exceptions = []
|
||||||
|
|
||||||
|
# peerspeak itself has no `license` field and is not published, so skip the
|
||||||
|
# "unlicensed" check for our own (private) crate. Add a license to Cargo.toml
|
||||||
|
# if/when this is ever published.
|
||||||
|
[licenses.private]
|
||||||
|
ignore = true
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# PeerSpeak on Windows
|
||||||
|
|
||||||
|
Current status: the Windows port cross-compiles to `x86_64-pc-windows-gnu` and the `.exe`
|
||||||
|
launches under Wine. A real Windows/WASAPI host is still needed for the final audio-device
|
||||||
|
checks listed below.
|
||||||
|
|
||||||
|
## What works today
|
||||||
|
|
||||||
|
| Area | Status |
|
||||||
|
|---|---|
|
||||||
|
| GUI | Iced/wgpu builds and renders under Wine. |
|
||||||
|
| Networking | Iroh QUIC transport and gossip compile on Windows. |
|
||||||
|
| Audio backend | `cpal` drives WASAPI capture/playback behind `AudioBackend`. |
|
||||||
|
| Codec | Opus remains 48 kHz mono, 20 ms frames. |
|
||||||
|
| Identity | `ring` identity generation/load is platform-neutral. |
|
||||||
|
| Chimes | Windows uses PowerShell `System.Media.SoundPlayer` for WAV playback. |
|
||||||
|
|
||||||
|
Windows paths are resolved through `dirs`:
|
||||||
|
|
||||||
|
- Config: `%APPDATA%\peerspeak\config.json`
|
||||||
|
- Identity: `%APPDATA%\peerspeak\identity.key`
|
||||||
|
- Log: `%LOCALAPPDATA%\peerspeak\peerspeak.log`
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
### Native Windows
|
||||||
|
|
||||||
|
Install MSVC Build Tools and CMake, then build normally:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
If CMake is 4.x or newer, the vendored `opus`/`libopus` build may need:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:CMAKE_POLICY_VERSION_MINIMUM = "3.5"
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cross-compile from Linux
|
||||||
|
|
||||||
|
The current dev path cross-compiles from an Arch environment to the GNU Windows target:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
rustup target add x86_64-pc-windows-gnu
|
||||||
|
sudo pacman -S mingw-w64-gcc cmake
|
||||||
|
CMAKE_POLICY_VERSION_MINIMUM=3.5 cargo build --release --target x86_64-pc-windows-gnu --bin peerspeak
|
||||||
|
```
|
||||||
|
|
||||||
|
Wine is useful for launch/render smoke tests, but it is not a substitute for a real
|
||||||
|
Windows audio-device pass. The deeper migration plan (phases, decisions, the opus build
|
||||||
|
spike) lives in the maintainer's handoff docs, outside the repo.
|
||||||
|
|
||||||
|
## First run and networking
|
||||||
|
|
||||||
|
Expect a Windows Firewall prompt the first time the app opens network sockets. Allow it:
|
||||||
|
PeerSpeak uses UDP for QUIC, plus relay traffic when direct NAT traversal is not available.
|
||||||
|
|
||||||
|
The default network mode keeps the n0 relay available for NAT traversal without publishing
|
||||||
|
presence to n0 DNS. Direct peer-to-peer paths may work when both networks allow them; relayed
|
||||||
|
connections are expected and valid.
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
| Item | Status |
|
||||||
|
|---|---|
|
||||||
|
| Echo cancellation | Linux-only PipeWire feature. The Windows UI shows it disabled as unavailable. |
|
||||||
|
| Screen share | Requires a Windows `pixelpass.exe` on `PATH` or a configured override. |
|
||||||
|
| Chimes | Now routed through Windows `SoundPlayer`; needs a real Windows host to audibly verify. |
|
||||||
|
| Resampling/device format | Cross-compiled. cpal/WASAPI now chooses native 48 kHz when available and otherwise resamples/remaps at the device boundary; needs real Windows hardware audio verification. |
|
||||||
|
| Device persistence | Open. WASAPI friendly names may duplicate or change across driver/profile changes. |
|
||||||
|
| Playback pacing | Cross-compiled. The fixed playback target under WASAPI shared mode still needs real-hardware verification with `audio_probe`. |
|
||||||
|
|
||||||
|
Before calling Windows support done, verify a real Windows machine can create/join a room,
|
||||||
|
capture mic audio, hear remote audio, select devices, restart with selections preserved, and
|
||||||
|
play notification chimes.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
|
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
|
||||||
pkgname=peerspeak-git
|
pkgname=peerspeak-git
|
||||||
_pkgname=peerspeak
|
_pkgname=peerspeak
|
||||||
pkgver=0.1.0
|
pkgver=0.3.0.r229.g7fb1c96
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
|
||||||
|
#
|
||||||
|
# Test-pack split package: ONE `makepkg -si` builds + installs BOTH peerspeak
|
||||||
|
# (voice chat) and pixelpass (screen sharing) from the public gitbutter repos
|
||||||
|
# over https. pixelpass lands on /usr/bin so peerspeak's screen-share button
|
||||||
|
# finds it. Shared version string is derived from peerspeak's git.
|
||||||
|
#
|
||||||
|
# Clone this repo and build from here:
|
||||||
|
# git clone https://gitbutter.xyz/mollusk/peerspeak.git
|
||||||
|
# cd peerspeak/packaging/test-pack
|
||||||
|
# makepkg -si
|
||||||
|
pkgbase=peerspeak-git
|
||||||
|
pkgname=('peerspeak-git' 'pixelpass')
|
||||||
|
pkgver=0.1.0
|
||||||
|
pkgrel=1
|
||||||
|
arch=('x86_64')
|
||||||
|
url="https://gitbutter.xyz/mollusk/peerspeak"
|
||||||
|
license=('custom' 'MIT' 'Apache-2.0' 'OFL-1.1')
|
||||||
|
makedepends=('git' 'cargo' 'pkgconf')
|
||||||
|
options=('!lto' '!debug')
|
||||||
|
source=("peerspeak::git+https://gitbutter.xyz/mollusk/peerspeak.git"
|
||||||
|
"pixelpass::git+https://gitbutter.xyz/mollusk/pixelpass.git#branch=main")
|
||||||
|
sha256sums=('SKIP'
|
||||||
|
'SKIP')
|
||||||
|
|
||||||
|
pkgver() {
|
||||||
|
cd "$srcdir/peerspeak"
|
||||||
|
# Shared across both split packages. 0.1.0.r<commits>.g<short-sha>.
|
||||||
|
printf '%s.r%s.g%s' \
|
||||||
|
"$(awk -F'\"' '/^version =/{print $2; exit}' Cargo.toml)" \
|
||||||
|
"$(git rev-list --count HEAD)" \
|
||||||
|
"$(git rev-parse --short HEAD)"
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare() {
|
||||||
|
# Vendor deps up front so build() can run --frozen (no surprise network).
|
||||||
|
export CARGO_HOME="$srcdir/cargo-home"
|
||||||
|
local host; host="$(rustc -vV | sed -n 's/host: //p')"
|
||||||
|
cd "$srcdir/peerspeak"; cargo fetch --locked --target "$host"
|
||||||
|
cd "$srcdir/pixelpass"; cargo fetch --locked --target "$host"
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
export CARGO_HOME="$srcdir/cargo-home"
|
||||||
|
export RUSTUP_TOOLCHAIN=stable
|
||||||
|
export CARGO_TARGET_DIR=target
|
||||||
|
|
||||||
|
cd "$srcdir/peerspeak"
|
||||||
|
cargo build --frozen --release --bin peerspeak
|
||||||
|
|
||||||
|
cd "$srcdir/pixelpass"
|
||||||
|
# --features gui so the .desktop launcher (pixelpass --gui) works.
|
||||||
|
cargo build --frozen --release --features gui
|
||||||
|
}
|
||||||
|
|
||||||
|
check() {
|
||||||
|
export CARGO_HOME="$srcdir/cargo-home"
|
||||||
|
export RUSTUP_TOOLCHAIN=stable
|
||||||
|
# peerspeak library unit tests only — its integration suites bind real
|
||||||
|
# iroh/QUIC endpoints and fail in a sandboxed/offline build environment.
|
||||||
|
cd "$srcdir/peerspeak"
|
||||||
|
cargo test --frozen --release --lib
|
||||||
|
}
|
||||||
|
|
||||||
|
package_peerspeak-git() {
|
||||||
|
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
||||||
|
depends=('pipewire' 'opus')
|
||||||
|
optdepends=('pixelpass: screen sharing inside a room'
|
||||||
|
'mpv: screen-share viewer (vlc is used as a fallback)')
|
||||||
|
provides=('peerspeak')
|
||||||
|
conflicts=('peerspeak')
|
||||||
|
license=('custom')
|
||||||
|
|
||||||
|
cd "$srcdir/peerspeak"
|
||||||
|
install -Dm755 "target/release/peerspeak" "$pkgdir/usr/bin/peerspeak"
|
||||||
|
install -Dm644 "packaging/peerspeak.desktop" \
|
||||||
|
"$pkgdir/usr/share/applications/peerspeak.desktop"
|
||||||
|
|
||||||
|
# Hicolor icon theme (scalable SVG + the rendered raster sizes).
|
||||||
|
install -Dm644 "assets/icons/peerspeak.svg" \
|
||||||
|
"$pkgdir/usr/share/icons/hicolor/scalable/apps/peerspeak.svg"
|
||||||
|
local s
|
||||||
|
for s in 16 24 32 48 64 128 256 512; do
|
||||||
|
install -Dm644 "assets/icons/peerspeak-$s.png" \
|
||||||
|
"$pkgdir/usr/share/icons/hicolor/${s}x${s}/apps/peerspeak.png"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
package_pixelpass() {
|
||||||
|
pkgdesc='P2P screen sharing over iroh — no port forwarding, no signup'
|
||||||
|
depends=('gstreamer' 'gst-plugins-base' 'gst-plugins-good' 'gst-plugins-bad'
|
||||||
|
'gst-libav' 'gst-plugin-va' 'libpulse' 'hicolor-icon-theme'
|
||||||
|
'libglvnd' 'libxkbcommon' 'wayland')
|
||||||
|
optdepends=('mpv: recommended stream viewer (the GUI launches mpv)'
|
||||||
|
'vlc: alternative stream viewer'
|
||||||
|
'gst-plugins-ugly: software x264 encoding for `pixelpass --no-hwencode`'
|
||||||
|
'gst-plugin-pipewire: screen capture on Wayland sessions'
|
||||||
|
'xorg-xwininfo: share a single window on X11 (`pixelpass --window`)')
|
||||||
|
license=('MIT' 'Apache-2.0' 'OFL-1.1')
|
||||||
|
|
||||||
|
cd "$srcdir/pixelpass"
|
||||||
|
install -Dm0755 "target/release/pixelpass" "$pkgdir/usr/bin/pixelpass"
|
||||||
|
install -Dm0644 assets/pixelpass.desktop \
|
||||||
|
"$pkgdir/usr/share/applications/pixelpass.desktop"
|
||||||
|
install -Dm0644 assets/pixelpass.svg \
|
||||||
|
"$pkgdir/usr/share/icons/hicolor/scalable/apps/pixelpass.svg"
|
||||||
|
install -Dm0644 README.md "$pkgdir/usr/share/doc/pixelpass/README.md"
|
||||||
|
install -Dm0644 LICENSE-MIT "$pkgdir/usr/share/licenses/pixelpass/LICENSE-MIT"
|
||||||
|
install -Dm0644 LICENSE-APACHE "$pkgdir/usr/share/licenses/pixelpass/LICENSE-APACHE"
|
||||||
|
install -Dm0644 assets/NotoSans-OFL.txt \
|
||||||
|
"$pkgdir/usr/share/licenses/pixelpass/NotoSans-OFL.txt"
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# PeerSpeak + PixelPass — CachyOS/Arch test pack
|
||||||
|
|
||||||
|
A single **split PKGBUILD** that builds the latest code from the public gitbutter
|
||||||
|
repos and installs **both** programs at once:
|
||||||
|
|
||||||
|
- `peerspeak` — decentralized P2P voice chat
|
||||||
|
- `pixelpass` — P2P screen sharing (peerspeak launches it for the screen-share button)
|
||||||
|
|
||||||
|
## Build & install (one command)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://gitbutter.xyz/mollusk/peerspeak.git
|
||||||
|
cd peerspeak/packaging/test-pack
|
||||||
|
makepkg -si
|
||||||
|
```
|
||||||
|
|
||||||
|
`makepkg -si` auto-installs every dependency via pacman before building —
|
||||||
|
including the Rust toolchain itself (the `cargo` makedepend is provided by the
|
||||||
|
`rust` package), `git`, `pkgconf`, pipewire + opus for peerspeak, and the
|
||||||
|
gstreamer/VA-API stack for pixelpass. The only prerequisite is the `base-devel`
|
||||||
|
group (which provides `makepkg`). If you already use `rustup`, that satisfies the
|
||||||
|
`cargo` makedepend and the `rust` package won't be pulled in — no conflict.
|
||||||
|
|
||||||
|
When it finishes you'll have `peerspeak` and `pixelpass` on your PATH at
|
||||||
|
`/usr/bin`. To rebuild later with fresh upstream code, re-run `makepkg -si`; the
|
||||||
|
git sources re-pull `main` and the version bumps automatically.
|
||||||
|
|
||||||
|
> Skip the test step with `makepkg -si --nocheck` for a faster build.
|
||||||
|
|
||||||
|
## Running the cross-internet test
|
||||||
|
|
||||||
|
1. Launch `peerspeak` on both machines.
|
||||||
|
2. One person **creates** a room and shares the room code/ticket with the other.
|
||||||
|
3. The other **joins** with that code.
|
||||||
|
4. iroh does NAT hole-punching automatically; if a direct path can't be made it
|
||||||
|
falls back to a public n0 relay — **no port forwarding required**.
|
||||||
|
5. Allow the app through any local firewall if prompted (outbound UDP / QUIC;
|
||||||
|
nothing needs to be opened inbound for relay mode).
|
||||||
|
|
||||||
|
### What we're smoke-testing
|
||||||
|
- Two real humans, two networks, over the internet.
|
||||||
|
- Mic capture + remote playback both directions, no crackle/dropouts.
|
||||||
|
- Mute / deafen, push-to-talk.
|
||||||
|
- Text chat in-room.
|
||||||
|
- Avatars (presets + custom upload) show up on the other side.
|
||||||
|
- Screen share: click the screen-share control → it launches `pixelpass`; the
|
||||||
|
viewer opens in `mpv` on the receiving side.
|
||||||
|
- Notification chimes (join/leave/etc.).
|
||||||
|
- Leave / rejoin cleanly.
|
||||||
|
|
||||||
|
If anything misbehaves, grab the log path peerspeak prints on startup and the
|
||||||
|
exact repro steps.
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# PeerSpeak — how to install and join a call (Windows)
|
||||||
|
|
||||||
|
PeerSpeak is a little voice-chat app — like a private phone call over the
|
||||||
|
internet, with no account, no signup, and no company in the middle. You install
|
||||||
|
it once, then you and I connect directly to each other.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Install it
|
||||||
|
|
||||||
|
1. Double-click **`peerspeak-0.3.0-setup.exe`** (the file I sent you).
|
||||||
|
|
||||||
|
2. **Windows will probably show a blue "Windows protected your PC" warning.**
|
||||||
|
This is normal — it shows up for any app that isn't from a big company with a
|
||||||
|
paid certificate. It is **not** a virus warning.
|
||||||
|
- Click **More info**
|
||||||
|
- Then click **Run anyway**
|
||||||
|
|
||||||
|
3. Windows will ask *"Do you want to allow this app to make changes?"* — click
|
||||||
|
**Yes**.
|
||||||
|
|
||||||
|
4. The setup window opens. Just keep clicking **Next**. Two checkboxes you'll
|
||||||
|
see along the way:
|
||||||
|
- **"Allow PeerSpeak through Windows Firewall"** — leave this **checked**
|
||||||
|
(it lets the call connect without interruptions).
|
||||||
|
- **"Create a desktop shortcut"** — check it if you'd like an icon on your
|
||||||
|
desktop.
|
||||||
|
|
||||||
|
5. Click **Install**, then **Finish**. PeerSpeak opens.
|
||||||
|
|
||||||
|
That's it — it's installed. You can find it again any time from the **Start
|
||||||
|
menu** (search "PeerSpeak").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Get on a call with me
|
||||||
|
|
||||||
|
PeerSpeak connects two people using a **room ticket** — a long code that acts
|
||||||
|
like a one-time phone number for a specific call.
|
||||||
|
|
||||||
|
**The simple way (I host):**
|
||||||
|
|
||||||
|
1. I'll create a room and send you a **ticket** (a long jumble of letters and
|
||||||
|
numbers).
|
||||||
|
2. Copy the whole ticket I sent you.
|
||||||
|
3. In PeerSpeak, paste it into the **"Join Room"** box near the bottom and press
|
||||||
|
**Join**.
|
||||||
|
4. You're in — you should see both our names listed, and we can talk.
|
||||||
|
|
||||||
|
**If you want to host instead:**
|
||||||
|
|
||||||
|
1. Type a room name and click **Create New Room**.
|
||||||
|
2. PeerSpeak gives you a **ticket** — click **Copy Ticket** and send it to me.
|
||||||
|
3. I paste it on my end and join you.
|
||||||
|
|
||||||
|
Either way works the same; it just depends on who makes the room.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. While you're on a call
|
||||||
|
|
||||||
|
- **Your microphone** is on by default. There's a **mute** button if you need
|
||||||
|
it.
|
||||||
|
- The first time, Windows might ask for permission to use your **microphone** —
|
||||||
|
click **Yes / Allow**.
|
||||||
|
- If you can't hear me or I can't hear you, open **Settings** (top right) and
|
||||||
|
check that the right **microphone** and **speakers/headphones** are selected.
|
||||||
|
- To hang up, click **Leave Room**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Chatting and sharing photos/files
|
||||||
|
|
||||||
|
There's a **text chat** box at the bottom of the call window — type a message
|
||||||
|
and press **Enter** to send it to everyone in the room.
|
||||||
|
|
||||||
|
You can also **send a photo or a file**:
|
||||||
|
|
||||||
|
1. Click the **attach button** (the small paperclip-style button) next to the
|
||||||
|
message box.
|
||||||
|
2. Pick a photo or file from your computer.
|
||||||
|
3. It sends to everyone in the room. **Photos show up right in the chat**;
|
||||||
|
other files appear as a small download chip with the file's name.
|
||||||
|
|
||||||
|
To **save** a file someone sent you, click the **Save** (or **Download**)
|
||||||
|
button next to it in the chat and choose where to put it.
|
||||||
|
|
||||||
|
A couple of notes:
|
||||||
|
- There's a size limit of about **25 MB** per file — bigger files are turned
|
||||||
|
away with a message.
|
||||||
|
- Shared files only last for the **current call**. They aren't saved anywhere
|
||||||
|
automatically, so save anything you want to keep before you leave the room.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **"I don't hear anything."** Open Settings and pick the correct microphone and
|
||||||
|
output device. Headphones are best — they prevent echo.
|
||||||
|
- **"It won't connect."** Make sure you pasted the *entire* ticket (they're
|
||||||
|
long and easy to cut off). If it still won't connect, we may just need a fresh
|
||||||
|
ticket — they're meant to be used right away. Also make sure we're both on the
|
||||||
|
**same version** — if I've sent you an updated installer, install it (an old
|
||||||
|
version and a new one can't connect to each other).
|
||||||
|
- **The blue warning again.** Same as install: **More info → Run anyway**. It's
|
||||||
|
the unsigned-app warning, not malware.
|
||||||
|
|
||||||
|
Any trouble, just message me and we'll sort it out.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# PeerSpeak — Windows installer
|
||||||
|
|
||||||
|
This directory builds a Windows setup installer for PeerSpeak using
|
||||||
|
[Inno Setup](https://jrsoftware.org/isinfo.php).
|
||||||
|
|
||||||
|
PeerSpeak ships as a **single self-contained `peerspeak.exe`** — the GUI icon,
|
||||||
|
notification chimes, and avatar presets are all embedded in the binary
|
||||||
|
(`include_bytes!`), and the executable is statically linked against the GNU
|
||||||
|
runtime, so there are no extra DLLs to bundle. The installer payload is just the
|
||||||
|
`.exe` plus an `.ico` for the Start-menu / desktop shortcuts.
|
||||||
|
|
||||||
|
## Version compatibility
|
||||||
|
|
||||||
|
The installer version tracks the crate version in `Cargo.toml` (currently
|
||||||
|
**0.3.0**) — keep `MyAppVersion` in `peerspeak.iss` in sync when it changes.
|
||||||
|
|
||||||
|
Per `VERSIONING.md`, a **MINOR** bump in `0.x` is a **breaking wire change**:
|
||||||
|
peers on different MINOR versions can't connect (they fail fast at the
|
||||||
|
handshake rather than misbehaving). So when you ship a new Windows build after
|
||||||
|
a MINOR bump, **everyone on the call must reinstall** — an old Windows build
|
||||||
|
and a newer Linux/Windows peer won't talk. (0.3.0 was the chat file-sharing +
|
||||||
|
per-peer noise-gate release; it cannot connect to a 0.2.x peer.)
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Tracked | Purpose |
|
||||||
|
|------|---------|---------|
|
||||||
|
| `peerspeak.iss` | yes | Inno Setup script |
|
||||||
|
| `peerspeak.ico` | yes | multi-resolution app icon (from `assets/icons/*.png`) |
|
||||||
|
| `README.md` | yes | this file |
|
||||||
|
| `peerspeak.exe` | no (gitignored) | staged build artifact, copied from `target/x86_64-pc-windows-gnu/release/` |
|
||||||
|
| `output/peerspeak-<ver>-setup.exe` | no (gitignored) | the compiled installer |
|
||||||
|
|
||||||
|
## Build steps
|
||||||
|
|
||||||
|
1. **Cross-compile the Windows binary** (from the repo root, inside the
|
||||||
|
`peerspeak-win` archlinux distrobox):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
RUSTC_BOOTSTRAP=1 ./win-cross-build.sh -Z build-std=std,panic_abort
|
||||||
|
```
|
||||||
|
|
||||||
|
This needs the `rust-src` component and the `x86_64-pc-windows-gnu` target
|
||||||
|
installed in that toolchain. The result is a statically-linked,
|
||||||
|
GUI-subsystem `.exe` (no stray console window).
|
||||||
|
|
||||||
|
2. **Stage the binary** next to the script:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp target/x86_64-pc-windows-gnu/release/peerspeak.exe packaging/windows/
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Regenerate the icon** if the source PNGs changed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
magick assets/icons/peerspeak-16.png assets/icons/peerspeak-24.png \
|
||||||
|
assets/icons/peerspeak-32.png assets/icons/peerspeak-48.png \
|
||||||
|
assets/icons/peerspeak-64.png assets/icons/peerspeak-128.png \
|
||||||
|
assets/icons/peerspeak-256.png packaging/windows/peerspeak.ico
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Compile the installer** with Inno Setup. On Linux this runs under Wine:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd packaging/windows
|
||||||
|
wine ~/.wine/drive_c/InnoSetup6/ISCC.exe peerspeak.iss
|
||||||
|
```
|
||||||
|
|
||||||
|
The installer lands at `output/peerspeak-<version>-setup.exe`.
|
||||||
|
|
||||||
|
## What the installer does
|
||||||
|
|
||||||
|
- Installs `peerspeak.exe` to `Program Files\PeerSpeak` (requires admin / one
|
||||||
|
UAC prompt).
|
||||||
|
- Creates a Start-menu shortcut, with an optional desktop shortcut.
|
||||||
|
- Optionally adds a Windows Firewall allow-rule for PeerSpeak (recommended —
|
||||||
|
iroh uses UDP hole-punching, so this avoids a mid-call firewall prompt). The
|
||||||
|
rule is removed on uninstall.
|
||||||
|
- Provides a standard uninstaller.
|
||||||
|
|
||||||
|
> **Note:** the installer and the binary are **not code-signed**, so Windows
|
||||||
|
> SmartScreen will show an "unknown publisher" warning on first run. The user
|
||||||
|
> clicks *More info → Run anyway*. Removing this warning requires a paid
|
||||||
|
> code-signing certificate.
|
||||||
|
After Width: | Height: | Size: 364 KiB |
@@ -0,0 +1,63 @@
|
|||||||
|
; Inno Setup script for PeerSpeak (Windows installer).
|
||||||
|
;
|
||||||
|
; PeerSpeak is a single self-contained binary: the GUI icon, notification
|
||||||
|
; chimes, and avatar presets are all embedded in the .exe (include_bytes!),
|
||||||
|
; so the only payload here is peerspeak.exe plus an .ico for the shortcuts.
|
||||||
|
;
|
||||||
|
; Build (under Wine on Linux, or native Windows):
|
||||||
|
; wine "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" peerspeak.iss
|
||||||
|
; Output lands in .\output\peerspeak-<version>-setup.exe
|
||||||
|
;
|
||||||
|
; The peerspeak.exe is cross-compiled with win-cross-build.sh
|
||||||
|
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
|
||||||
|
|
||||||
|
#define MyAppName "PeerSpeak"
|
||||||
|
#define MyAppVersion "0.3.0"
|
||||||
|
#define MyAppPublisher "mollusk"
|
||||||
|
#define MyAppExeName "peerspeak.exe"
|
||||||
|
|
||||||
|
[Setup]
|
||||||
|
; A stable AppId keeps upgrades/uninstall tracking consistent across versions.
|
||||||
|
AppId={{2754D6C1-C8A4-4B13-9824-2D303439739D}
|
||||||
|
AppName={#MyAppName}
|
||||||
|
AppVersion={#MyAppVersion}
|
||||||
|
AppVerName={#MyAppName} {#MyAppVersion}
|
||||||
|
AppPublisher={#MyAppPublisher}
|
||||||
|
DefaultDirName={autopf}\{#MyAppName}
|
||||||
|
DefaultGroupName={#MyAppName}
|
||||||
|
DisableProgramGroupPage=yes
|
||||||
|
UninstallDisplayIcon={app}\{#MyAppExeName}
|
||||||
|
SetupIconFile=peerspeak.ico
|
||||||
|
Compression=lzma2/max
|
||||||
|
SolidCompression=yes
|
||||||
|
WizardStyle=modern
|
||||||
|
OutputDir=output
|
||||||
|
OutputBaseFilename=peerspeak-{#MyAppVersion}-setup
|
||||||
|
; Program Files install + firewall rule both need elevation.
|
||||||
|
PrivilegesRequired=admin
|
||||||
|
ArchitecturesAllowed=x64compatible
|
||||||
|
ArchitecturesInstallIn64BitMode=x64compatible
|
||||||
|
|
||||||
|
[Languages]
|
||||||
|
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||||
|
|
||||||
|
[Tasks]
|
||||||
|
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
|
||||||
|
Name: "firewall"; Description: "Allow PeerSpeak through Windows Firewall (recommended for voice calls)"; GroupDescription: "Network:"
|
||||||
|
|
||||||
|
[Files]
|
||||||
|
Source: "peerspeak.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||||
|
Source: "peerspeak.ico"; DestDir: "{app}"; Flags: ignoreversion
|
||||||
|
|
||||||
|
[Icons]
|
||||||
|
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\peerspeak.ico"
|
||||||
|
Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}"
|
||||||
|
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\peerspeak.ico"; Tasks: desktopicon
|
||||||
|
|
||||||
|
[Run]
|
||||||
|
; iroh uses UDP hole-punching; pre-authorizing avoids a mid-call firewall prompt.
|
||||||
|
Filename: "{sys}\netsh.exe"; Parameters: "advfirewall firewall add rule name=""PeerSpeak"" dir=in action=allow program=""{app}\{#MyAppExeName}"" enable=yes profile=any"; Flags: runhidden; Tasks: firewall
|
||||||
|
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#MyAppName}}"; Flags: nowait postinstall skipifsilent
|
||||||
|
|
||||||
|
[UninstallRun]
|
||||||
|
Filename: "{sys}\netsh.exe"; Parameters: "advfirewall firewall delete rule name=""PeerSpeak"""; Flags: runhidden; RunOnceId: "DelPeerSpeakFirewall"
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
//! Independent playback engine for inline chat audio attachments.
|
||||||
|
//!
|
||||||
|
//! The rodio device sink stays on a dedicated OS thread and never enters iced
|
||||||
|
//! state or the call-audio pipeline. The GUI sends small commands and reads a
|
||||||
|
//! shared status snapshot at its redraw cadence.
|
||||||
|
|
||||||
|
use crate::files::AttachmentId;
|
||||||
|
use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source, decoder::DecoderError};
|
||||||
|
use std::io::Cursor;
|
||||||
|
use std::sync::{Arc, Mutex, mpsc};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// State published by the playback thread for the GUI.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct ClipStatus {
|
||||||
|
pub playing_id: Option<AttachmentId>,
|
||||||
|
pub position: Duration,
|
||||||
|
pub total: Option<Duration>,
|
||||||
|
pub paused: bool,
|
||||||
|
/// Set when output initialization or decoding rejects the requested clip.
|
||||||
|
/// The app consumes this as a signal to fall back to the normal file chip.
|
||||||
|
pub failure: Option<ClipFailure>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ClipFailure {
|
||||||
|
pub id: AttachmentId,
|
||||||
|
pub error: String,
|
||||||
|
/// Decoder rejection means the filename hint should fall back to a file
|
||||||
|
/// chip. Output-device failures remain retryable as audio.
|
||||||
|
pub invalid_audio: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type SharedClipStatus = Arc<Mutex<ClipStatus>>;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum ClipCommand {
|
||||||
|
Play(AttachmentId, Vec<u8>),
|
||||||
|
Pause,
|
||||||
|
Resume,
|
||||||
|
Seek(Duration),
|
||||||
|
Stop,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cheap, `Send` command handle for the dedicated playback thread.
|
||||||
|
pub struct ClipPlayer {
|
||||||
|
command_tx: mpsc::Sender<ClipCommand>,
|
||||||
|
status: SharedClipStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClipPlayer {
|
||||||
|
/// Start the playback worker. The system output device is opened lazily on
|
||||||
|
/// first Play, so merely launching PeerSpeak never claims another stream.
|
||||||
|
pub fn new() -> (Self, SharedClipStatus) {
|
||||||
|
let (command_tx, command_rx) = mpsc::channel();
|
||||||
|
let status = Arc::new(Mutex::new(ClipStatus::default()));
|
||||||
|
let worker_status = Arc::clone(&status);
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("peerspeak-clip-player".to_string())
|
||||||
|
.spawn(move || playback_worker(command_rx, worker_status))
|
||||||
|
.expect("failed to spawn clip playback thread");
|
||||||
|
(
|
||||||
|
Self {
|
||||||
|
command_tx,
|
||||||
|
status: Arc::clone(&status),
|
||||||
|
},
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn play(&self, id: AttachmentId, bytes: Vec<u8>) {
|
||||||
|
update_status(&self.status, |status| {
|
||||||
|
status.playing_id = Some(id);
|
||||||
|
status.position = Duration::ZERO;
|
||||||
|
status.total = None;
|
||||||
|
status.paused = false;
|
||||||
|
status.failure = None;
|
||||||
|
});
|
||||||
|
let _ = self.command_tx.send(ClipCommand::Play(id, bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pause(&self) {
|
||||||
|
let _ = self.command_tx.send(ClipCommand::Pause);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resume(&self) {
|
||||||
|
let _ = self.command_tx.send(ClipCommand::Resume);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn seek(&self, position: Duration) {
|
||||||
|
let _ = self.command_tx.send(ClipCommand::Seek(position));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop(&self) {
|
||||||
|
let _ = self.command_tx.send(ClipCommand::Stop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn playback_worker(command_rx: mpsc::Receiver<ClipCommand>, status: SharedClipStatus) {
|
||||||
|
let mut output: Option<MixerDeviceSink> = None;
|
||||||
|
let mut player: Option<Player> = None;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match command_rx.recv_timeout(Duration::from_millis(100)) {
|
||||||
|
Ok(ClipCommand::Play(id, bytes)) => {
|
||||||
|
// In-memory readers do not expose file metadata to rodio. Pass
|
||||||
|
// the known attachment length explicitly so formats without a
|
||||||
|
// duration in their headers (notably MP3 and Vorbis) can derive
|
||||||
|
// a total duration and support reliable seeking.
|
||||||
|
let source = match decode_clip(bytes) {
|
||||||
|
Ok(source) => source,
|
||||||
|
Err(error) => {
|
||||||
|
fail(
|
||||||
|
&status,
|
||||||
|
id,
|
||||||
|
format!("unsupported or invalid audio: {error}"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let total = source.total_duration();
|
||||||
|
|
||||||
|
if output.is_none() {
|
||||||
|
match DeviceSinkBuilder::open_default_sink() {
|
||||||
|
Ok(sink) => {
|
||||||
|
player = Some(Player::connect_new(sink.mixer()));
|
||||||
|
output = Some(sink);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
fail(
|
||||||
|
&status,
|
||||||
|
id,
|
||||||
|
format!("audio output unavailable: {error}"),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(player) = player.as_ref() {
|
||||||
|
player.clear();
|
||||||
|
player.append(source);
|
||||||
|
player.play();
|
||||||
|
update_status(&status, |s| {
|
||||||
|
s.playing_id = Some(id);
|
||||||
|
s.position = Duration::ZERO;
|
||||||
|
s.total = total;
|
||||||
|
s.paused = false;
|
||||||
|
s.failure = None;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(ClipCommand::Pause) => {
|
||||||
|
if let Some(player) = player.as_ref() {
|
||||||
|
player.pause();
|
||||||
|
update_status(&status, |s| s.paused = true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(ClipCommand::Resume) => {
|
||||||
|
if let Some(player) = player.as_ref() {
|
||||||
|
player.play();
|
||||||
|
update_status(&status, |s| s.paused = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(ClipCommand::Seek(position)) => {
|
||||||
|
if let Some(player) = player.as_ref()
|
||||||
|
&& player.try_seek(position).is_ok()
|
||||||
|
{
|
||||||
|
update_status(&status, |s| s.position = position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(ClipCommand::Stop) => {
|
||||||
|
if let Some(player) = player.as_ref() {
|
||||||
|
player.clear();
|
||||||
|
}
|
||||||
|
reset(&status);
|
||||||
|
}
|
||||||
|
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||||
|
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(player) = player.as_ref() {
|
||||||
|
let (active, failed) = status
|
||||||
|
.lock()
|
||||||
|
.map(|s| (s.playing_id.is_some(), s.failure.is_some()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
if active && !failed && player.empty() {
|
||||||
|
reset(&status);
|
||||||
|
} else if active && !failed {
|
||||||
|
update_status(&status, |s| {
|
||||||
|
s.position = player.get_pos();
|
||||||
|
s.paused = player.is_paused();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_clip(bytes: Vec<u8>) -> Result<Decoder<Cursor<Vec<u8>>>, DecoderError> {
|
||||||
|
let byte_len = bytes.len() as u64;
|
||||||
|
Decoder::builder()
|
||||||
|
.with_data(Cursor::new(bytes))
|
||||||
|
.with_byte_len(byte_len)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fail(status: &SharedClipStatus, id: AttachmentId, error: String, invalid_audio: bool) {
|
||||||
|
crate::log_msg(&format!("Inline audio playback failed: {error}"));
|
||||||
|
update_status(status, |s| {
|
||||||
|
// Keep the id active until the GUI observes the failure on its next
|
||||||
|
// tick. This guarantees the active-only timer cannot disappear in the
|
||||||
|
// small window between sending Play and decoder/output failure.
|
||||||
|
s.playing_id = Some(id);
|
||||||
|
s.position = Duration::ZERO;
|
||||||
|
s.total = None;
|
||||||
|
s.paused = false;
|
||||||
|
s.failure = Some(ClipFailure {
|
||||||
|
id,
|
||||||
|
error,
|
||||||
|
invalid_audio,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(status: &SharedClipStatus) {
|
||||||
|
update_status(status, |s| *s = ClipStatus::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_status(status: &SharedClipStatus, update: impl FnOnce(&mut ClipStatus)) {
|
||||||
|
if let Ok(mut status) = status.lock() {
|
||||||
|
update(&mut status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status_snapshot(status: &SharedClipStatus) -> ClipStatus {
|
||||||
|
status.lock().map(|s| s.clone()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format clip time as `mm:ss` (hours are folded into minutes).
|
||||||
|
pub fn format_time(duration: Duration) -> String {
|
||||||
|
let seconds = duration.as_secs();
|
||||||
|
format!("{}:{:02}", seconds / 60, seconds % 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Playback progress in `0.0..=1.0`; unknown and zero durations report zero.
|
||||||
|
pub fn progress(position: Duration, total: Option<Duration>) -> f32 {
|
||||||
|
let Some(total) = total.filter(|duration| !duration.is_zero()) else {
|
||||||
|
return 0.0;
|
||||||
|
};
|
||||||
|
(position.as_secs_f64() / total.as_secs_f64()).clamp(0.0, 1.0) as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a slider fraction into a clamped position within a clip.
|
||||||
|
pub fn seek_target(fraction: f32, total: Duration) -> Duration {
|
||||||
|
total.mul_f64(f64::from(fraction.clamp(0.0, 1.0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use base64::Engine;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn in_memory_mp3_reports_duration() {
|
||||||
|
// One headerless constant-bitrate MP3 frame repeated to model files
|
||||||
|
// that do not carry an Xing/VBR duration header.
|
||||||
|
let frame = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode("//sQxAAABIQVWVRggDCqCKiDNlAAAAGgS4BgAmTT2AQAABCxOD5d7gQOfqBAEHS4Ph/EAIRI7//0A0KBNpABgMRIDCSI04PcIFdF0PJKFgzlUf5eAoF8BRIPfh4FTvUDQl+dUi5pc0w=")
|
||||||
|
.expect("valid test fixture");
|
||||||
|
let bytes = frame.repeat(20);
|
||||||
|
|
||||||
|
let decoder = decode_clip(bytes).expect("CBR MP3 should decode");
|
||||||
|
assert!(decoder.total_duration().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn formats_clip_time() {
|
||||||
|
assert_eq!(format_time(Duration::ZERO), "0:00");
|
||||||
|
assert_eq!(format_time(Duration::from_secs(65)), "1:05");
|
||||||
|
assert_eq!(format_time(Duration::from_secs(3_661)), "61:01");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_handles_unknown_zero_and_clamps() {
|
||||||
|
assert_eq!(progress(Duration::from_secs(1), None), 0.0);
|
||||||
|
assert_eq!(progress(Duration::from_secs(1), Some(Duration::ZERO)), 0.0);
|
||||||
|
assert_eq!(
|
||||||
|
progress(Duration::from_secs(5), Some(Duration::from_secs(10))),
|
||||||
|
0.5
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
progress(Duration::from_secs(20), Some(Duration::from_secs(10))),
|
||||||
|
1.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn seek_target_clamps_fraction() {
|
||||||
|
let total = Duration::from_secs(100);
|
||||||
|
assert_eq!(seek_target(0.25, total), Duration::from_secs(25));
|
||||||
|
assert_eq!(seek_target(-1.0, total), Duration::ZERO);
|
||||||
|
assert_eq!(seek_target(2.0, total), total);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
//! Per-peer listener-side voice EQ.
|
||||||
|
//!
|
||||||
|
//! The EQ is deliberately small and local: three RBJ cookbook biquads at fixed
|
||||||
|
//! voice-oriented frequencies, with only gain exposed to the UI. State lives per
|
||||||
|
//! peer in the playout mixer so filter delay registers are continuous across 20ms
|
||||||
|
//! Opus frames; flat settings are treated as bypass so the default path is cheap
|
||||||
|
//! and sample-exact.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
const DEFAULT_SAMPLE_RATE: f32 = 48_000.0;
|
||||||
|
const LOW_SHELF_HZ: f32 = 160.0;
|
||||||
|
const MID_PEAK_HZ: f32 = 2_400.0;
|
||||||
|
const HIGH_SHELF_HZ: f32 = 6_500.0;
|
||||||
|
const MID_Q: f32 = 1.0;
|
||||||
|
const SHELF_Q: f32 = std::f32::consts::FRAC_1_SQRT_2;
|
||||||
|
const FLAT_EPSILON_DB: f32 = 0.001;
|
||||||
|
|
||||||
|
/// UI and config clamp for each band. Wide enough to be useful for voice, narrow
|
||||||
|
/// enough that a peer cannot accidentally make the listener-side limiter do all
|
||||||
|
/// the work.
|
||||||
|
pub const EQ_GAIN_DB_MIN: f32 = -12.0;
|
||||||
|
pub const EQ_GAIN_DB_MAX: f32 = 12.0;
|
||||||
|
|
||||||
|
/// Persisted per-peer EQ gains, in decibels. `Default` is flat/bypassed.
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct EqSettings {
|
||||||
|
#[serde(default)]
|
||||||
|
pub low_gain_db: f32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub mid_gain_db: f32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub high_gain_db: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for EqSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
low_gain_db: 0.0,
|
||||||
|
mid_gain_db: 0.0,
|
||||||
|
high_gain_db: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EqSettings {
|
||||||
|
pub fn flat() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clamp all public gains to the supported UI/DSP range.
|
||||||
|
pub fn clamped(self) -> Self {
|
||||||
|
Self {
|
||||||
|
low_gain_db: self.low_gain_db.clamp(EQ_GAIN_DB_MIN, EQ_GAIN_DB_MAX),
|
||||||
|
mid_gain_db: self.mid_gain_db.clamp(EQ_GAIN_DB_MIN, EQ_GAIN_DB_MAX),
|
||||||
|
high_gain_db: self.high_gain_db.clamp(EQ_GAIN_DB_MIN, EQ_GAIN_DB_MAX),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when the EQ should be bypassed entirely.
|
||||||
|
pub fn is_flat(self) -> bool {
|
||||||
|
self.low_gain_db.abs() <= FLAT_EPSILON_DB
|
||||||
|
&& self.mid_gain_db.abs() <= FLAT_EPSILON_DB
|
||||||
|
&& self.high_gain_db.abs() <= FLAT_EPSILON_DB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stateful three-band EQ. One instance belongs to one decoded peer stream.
|
||||||
|
pub struct Eq {
|
||||||
|
settings: EqSettings,
|
||||||
|
low: Biquad,
|
||||||
|
mid: Biquad,
|
||||||
|
high: Biquad,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Eq {
|
||||||
|
/// Build an EQ at the application's audio rate (48 kHz).
|
||||||
|
pub fn new(settings: EqSettings) -> Self {
|
||||||
|
Self::with_sample_rate(settings, DEFAULT_SAMPLE_RATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_sample_rate(settings: EqSettings, sample_rate: f32) -> Self {
|
||||||
|
let settings = settings.clamped();
|
||||||
|
Self {
|
||||||
|
settings,
|
||||||
|
low: Biquad::low_shelf(sample_rate, LOW_SHELF_HZ, settings.low_gain_db, SHELF_Q),
|
||||||
|
mid: Biquad::peaking(sample_rate, MID_PEAK_HZ, settings.mid_gain_db, MID_Q),
|
||||||
|
high: Biquad::high_shelf(sample_rate, HIGH_SHELF_HZ, settings.high_gain_db, SHELF_Q),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn settings(&self) -> EqSettings {
|
||||||
|
self.settings
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process one mono PCM frame in place. Flat settings are sample-exact bypass.
|
||||||
|
pub fn process_frame(&mut self, frame: &mut [i16]) {
|
||||||
|
if self.settings.is_flat() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for sample in frame {
|
||||||
|
let x = *sample as f32;
|
||||||
|
let y = self.high.process(self.mid.process(self.low.process(x)));
|
||||||
|
*sample = y.round().clamp(i16::MIN as f32, i16::MAX as f32) as i16;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct Coeffs {
|
||||||
|
b0: f32,
|
||||||
|
b1: f32,
|
||||||
|
b2: f32,
|
||||||
|
a1: f32,
|
||||||
|
a2: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Coeffs {
|
||||||
|
fn normalized(b0: f32, b1: f32, b2: f32, a0: f32, a1: f32, a2: f32) -> Self {
|
||||||
|
let inv_a0 = 1.0 / a0;
|
||||||
|
Self {
|
||||||
|
b0: b0 * inv_a0,
|
||||||
|
b1: b1 * inv_a0,
|
||||||
|
b2: b2 * inv_a0,
|
||||||
|
a1: a1 * inv_a0,
|
||||||
|
a2: a2 * inv_a0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn all_finite(self) -> bool {
|
||||||
|
self.b0.is_finite()
|
||||||
|
&& self.b1.is_finite()
|
||||||
|
&& self.b2.is_finite()
|
||||||
|
&& self.a1.is_finite()
|
||||||
|
&& self.a2.is_finite()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Direct Form II transposed biquad. The two delay registers are the state that
|
||||||
|
/// must survive across frames.
|
||||||
|
struct Biquad {
|
||||||
|
coeffs: Coeffs,
|
||||||
|
z1: f32,
|
||||||
|
z2: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Biquad {
|
||||||
|
fn new(coeffs: Coeffs) -> Self {
|
||||||
|
debug_assert!(coeffs.all_finite());
|
||||||
|
Self {
|
||||||
|
coeffs,
|
||||||
|
z1: 0.0,
|
||||||
|
z2: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn low_shelf(sample_rate: f32, freq: f32, gain_db: f32, q: f32) -> Self {
|
||||||
|
let (a, cos_w0, alpha) = rbj_terms(sample_rate, freq, gain_db, q);
|
||||||
|
let sqrt_a = a.sqrt();
|
||||||
|
let b0 = a * ((a + 1.0) - (a - 1.0) * cos_w0 + 2.0 * sqrt_a * alpha);
|
||||||
|
let b1 = 2.0 * a * ((a - 1.0) - (a + 1.0) * cos_w0);
|
||||||
|
let b2 = a * ((a + 1.0) - (a - 1.0) * cos_w0 - 2.0 * sqrt_a * alpha);
|
||||||
|
let a0 = (a + 1.0) + (a - 1.0) * cos_w0 + 2.0 * sqrt_a * alpha;
|
||||||
|
let a1 = -2.0 * ((a - 1.0) + (a + 1.0) * cos_w0);
|
||||||
|
let a2 = (a + 1.0) + (a - 1.0) * cos_w0 - 2.0 * sqrt_a * alpha;
|
||||||
|
Self::new(Coeffs::normalized(b0, b1, b2, a0, a1, a2))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peaking(sample_rate: f32, freq: f32, gain_db: f32, q: f32) -> Self {
|
||||||
|
let (a, cos_w0, alpha) = rbj_terms(sample_rate, freq, gain_db, q);
|
||||||
|
let b0 = 1.0 + alpha * a;
|
||||||
|
let b1 = -2.0 * cos_w0;
|
||||||
|
let b2 = 1.0 - alpha * a;
|
||||||
|
let a0 = 1.0 + alpha / a;
|
||||||
|
let a1 = -2.0 * cos_w0;
|
||||||
|
let a2 = 1.0 - alpha / a;
|
||||||
|
Self::new(Coeffs::normalized(b0, b1, b2, a0, a1, a2))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn high_shelf(sample_rate: f32, freq: f32, gain_db: f32, q: f32) -> Self {
|
||||||
|
let (a, cos_w0, alpha) = rbj_terms(sample_rate, freq, gain_db, q);
|
||||||
|
let sqrt_a = a.sqrt();
|
||||||
|
let b0 = a * ((a + 1.0) + (a - 1.0) * cos_w0 + 2.0 * sqrt_a * alpha);
|
||||||
|
let b1 = -2.0 * a * ((a - 1.0) + (a + 1.0) * cos_w0);
|
||||||
|
let b2 = a * ((a + 1.0) + (a - 1.0) * cos_w0 - 2.0 * sqrt_a * alpha);
|
||||||
|
let a0 = (a + 1.0) - (a - 1.0) * cos_w0 + 2.0 * sqrt_a * alpha;
|
||||||
|
let a1 = 2.0 * ((a - 1.0) - (a + 1.0) * cos_w0);
|
||||||
|
let a2 = (a + 1.0) - (a - 1.0) * cos_w0 - 2.0 * sqrt_a * alpha;
|
||||||
|
Self::new(Coeffs::normalized(b0, b1, b2, a0, a1, a2))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process(&mut self, x: f32) -> f32 {
|
||||||
|
let y = self.coeffs.b0 * x + self.z1;
|
||||||
|
self.z1 = self.coeffs.b1 * x - self.coeffs.a1 * y + self.z2;
|
||||||
|
self.z2 = self.coeffs.b2 * x - self.coeffs.a2 * y;
|
||||||
|
|
||||||
|
// Avoid carrying denormal-sized state forever on long quiet tails.
|
||||||
|
if self.z1.abs() < 1.0e-20 {
|
||||||
|
self.z1 = 0.0;
|
||||||
|
}
|
||||||
|
if self.z2.abs() < 1.0e-20 {
|
||||||
|
self.z2 = 0.0;
|
||||||
|
}
|
||||||
|
y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rbj_terms(sample_rate: f32, freq: f32, gain_db: f32, q: f32) -> (f32, f32, f32) {
|
||||||
|
let sr = sample_rate.max(1.0);
|
||||||
|
let f = freq.clamp(1.0, sr * 0.49);
|
||||||
|
let w0 = 2.0 * std::f32::consts::PI * f / sr;
|
||||||
|
let a = 10.0f32.powf(gain_db / 40.0);
|
||||||
|
let alpha = w0.sin() / (2.0 * q.max(0.001));
|
||||||
|
(a, w0.cos(), alpha)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn sine(freq: f32, len: usize, amp: f32) -> Vec<i16> {
|
||||||
|
(0..len)
|
||||||
|
.map(|n| {
|
||||||
|
let t = n as f32 / DEFAULT_SAMPLE_RATE;
|
||||||
|
(amp * (2.0 * std::f32::consts::PI * freq * t).sin()).round() as i16
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rms(frame: &[i16]) -> f32 {
|
||||||
|
let sum: f32 = frame.iter().map(|&s| (s as f32).powi(2)).sum();
|
||||||
|
(sum / frame.len().max(1) as f32).sqrt()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flat_eq_is_sample_exact_identity() {
|
||||||
|
let mut eq = Eq::new(EqSettings::flat());
|
||||||
|
let mut frame: Vec<i16> = (-480..480).map(|n| (n * 31) as i16).collect();
|
||||||
|
let original = frame.clone();
|
||||||
|
eq.process_frame(&mut frame);
|
||||||
|
assert_eq!(frame, original);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn low_shelf_boost_raises_low_frequency_energy() {
|
||||||
|
let mut eq = Eq::new(EqSettings {
|
||||||
|
low_gain_db: 9.0,
|
||||||
|
..EqSettings::flat()
|
||||||
|
});
|
||||||
|
let mut low = sine(100.0, 48_000, 3_000.0);
|
||||||
|
let before = rms(&low);
|
||||||
|
eq.process_frame(&mut low);
|
||||||
|
let after = rms(&low);
|
||||||
|
assert!(after > before * 1.6, "low shelf should boost low RMS: {before} -> {after}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn high_shelf_boost_raises_high_frequency_energy() {
|
||||||
|
let mut eq = Eq::new(EqSettings {
|
||||||
|
high_gain_db: 9.0,
|
||||||
|
..EqSettings::flat()
|
||||||
|
});
|
||||||
|
let mut high = sine(8_000.0, 48_000, 3_000.0);
|
||||||
|
let before = rms(&high);
|
||||||
|
eq.process_frame(&mut high);
|
||||||
|
let after = rms(&high);
|
||||||
|
assert!(after > before * 1.6, "high shelf should boost high RMS: {before} -> {after}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coefficients_are_finite_across_supported_gain_range() {
|
||||||
|
for gain in [EQ_GAIN_DB_MIN, -6.0, 0.0, 6.0, EQ_GAIN_DB_MAX] {
|
||||||
|
for b in [
|
||||||
|
Biquad::low_shelf(DEFAULT_SAMPLE_RATE, LOW_SHELF_HZ, gain, SHELF_Q),
|
||||||
|
Biquad::peaking(DEFAULT_SAMPLE_RATE, MID_PEAK_HZ, gain, MID_Q),
|
||||||
|
Biquad::high_shelf(DEFAULT_SAMPLE_RATE, HIGH_SHELF_HZ, gain, SHELF_Q),
|
||||||
|
] {
|
||||||
|
assert!(b.coeffs.all_finite(), "coefficients must be finite at {gain} dB");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hot_signal_does_not_nan_or_wrap() {
|
||||||
|
let mut eq = Eq::new(EqSettings {
|
||||||
|
low_gain_db: 12.0,
|
||||||
|
mid_gain_db: 12.0,
|
||||||
|
high_gain_db: 12.0,
|
||||||
|
});
|
||||||
|
let mut frame = sine(1_000.0, 48_000, 30_000.0);
|
||||||
|
eq.process_frame(&mut frame);
|
||||||
|
let peak = frame
|
||||||
|
.iter()
|
||||||
|
.map(|&s| i32::from(s).abs())
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0);
|
||||||
|
assert!(peak > 1_000, "processed signal should retain audible energy");
|
||||||
|
assert!(
|
||||||
|
frame.iter().any(|&s| s > 0) && frame.iter().any(|&s| s < 0),
|
||||||
|
"a boosted sine should retain both polarities"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_are_clamped() {
|
||||||
|
let s = EqSettings {
|
||||||
|
low_gain_db: -99.0,
|
||||||
|
mid_gain_db: 2.0,
|
||||||
|
high_gain_db: 99.0,
|
||||||
|
}
|
||||||
|
.clamped();
|
||||||
|
assert_eq!(s.low_gain_db, EQ_GAIN_DB_MIN);
|
||||||
|
assert_eq!(s.mid_gain_db, 2.0);
|
||||||
|
assert_eq!(s.high_gain_db, EQ_GAIN_DB_MAX);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,22 @@
|
|||||||
use std::sync::mpsc::{Sender, Receiver};
|
use std::sync::mpsc::{Receiver, Sender};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::AtomicUsize;
|
use std::sync::atomic::AtomicUsize;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
/// Target depth of the playback ring buffer, in samples (48kHz mono).
|
/// Playback output channel count. Capture/encode/network remain mono; only the
|
||||||
|
/// listener-side playout bus is stereo.
|
||||||
|
pub const PLAYBACK_CHANNELS: usize = 2;
|
||||||
|
|
||||||
|
/// Target depth of the playback ring buffer, in interleaved samples (48kHz
|
||||||
|
/// stereo).
|
||||||
///
|
///
|
||||||
/// The playout chain is paced to keep the ring near this level: production is
|
/// The playout chain is paced to keep the ring near this level: production is
|
||||||
/// driven by how fast PipeWire actually drains the ring (the hardware clock),
|
/// driven by how fast PipeWire actually drains the ring (the hardware clock),
|
||||||
/// not by a fixed software timer — which is what eliminates the producer/
|
/// not by a fixed software timer — which is what eliminates the producer/
|
||||||
/// consumer beat that otherwise churns ~20% of audio into drops + silence.
|
/// consumer beat that otherwise churns ~20% of audio into drops + silence.
|
||||||
/// 2880 = 60ms = 3×20ms frames, comfortably above the 2048-sample max quantum
|
/// 5760 = 60ms = 3×20ms stereo frames, comfortably above the 2048-frame max quantum
|
||||||
/// so a single hardware pull can never empty the ring before the mixer refills.
|
/// so a single hardware pull can never empty the ring before the mixer refills.
|
||||||
pub const PLAYBACK_TARGET_SAMPLES: usize = 2880;
|
pub const PLAYBACK_TARGET_SAMPLES: usize = 2880 * PLAYBACK_CHANNELS;
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum AudioError {
|
pub enum AudioError {
|
||||||
@@ -51,10 +56,61 @@ pub trait AudioBackend: Send + Sync {
|
|||||||
fn stop(&self) -> Result<(), AudioError>;
|
fn stop(&self) -> Result<(), AudioError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod echo_cancel;
|
pub mod clip_player;
|
||||||
|
pub mod eq;
|
||||||
pub mod gate;
|
pub mod gate;
|
||||||
pub mod limiter;
|
pub mod limiter;
|
||||||
pub mod multitrack;
|
pub mod multitrack;
|
||||||
|
pub mod pan;
|
||||||
|
// Linear resamplers used by the Windows/cpal backend (W4). Platform-neutral and
|
||||||
|
// pure, so it builds (and its tests run) everywhere even though only the cpal
|
||||||
|
// backend wires it in.
|
||||||
|
pub mod resample;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub mod echo_cancel;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
pub mod pipewire_impl;
|
pub mod pipewire_impl;
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub mod cpal_impl;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
pub mod pw_cli;
|
pub mod pw_cli;
|
||||||
pub mod recorder;
|
pub mod recorder;
|
||||||
|
|
||||||
|
/// A selectable audio device for the input/output pickers. `name` is the stable
|
||||||
|
/// identifier the backend uses to request the device (`target_node`);
|
||||||
|
/// `description` is the human-facing label shown in the UI. The two may be equal
|
||||||
|
/// (cpal/WASAPI) or differ (PipeWire node name vs. description).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct AudioDevice {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub is_input: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for AudioDevice {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enumerate audio input/output devices for the pickers (sorted by description),
|
||||||
|
// returning the same `AudioDevice` shape regardless of platform: PipeWire
|
||||||
|
// (`pw-cli`) on Linux, cpal/WASAPI on Windows.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub use pw_cli::enumerate_audio_devices;
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub use cpal_impl::enumerate_audio_devices;
|
||||||
|
|
||||||
|
/// The audio backend implementation for the current platform.
|
||||||
|
///
|
||||||
|
/// The whole app constructs and threads this alias (via
|
||||||
|
/// `PlatformAudioBackend::new()`) rather than any concrete backend type, so
|
||||||
|
/// platform selection lives entirely here. Both implementations satisfy the
|
||||||
|
/// [`AudioBackend`] trait, which is the only interface the core talks to.
|
||||||
|
///
|
||||||
|
/// - Linux → PipeWire ([`pipewire_impl::PipeWireBackend`]).
|
||||||
|
/// - Windows → cpal/WASAPI ([`cpal_impl::CpalBackend`]).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub type PlatformAudioBackend = pipewire_impl::PipeWireBackend;
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub type PlatformAudioBackend = cpal_impl::CpalBackend;
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
//! Listener-side stereo pan law.
|
||||||
|
//!
|
||||||
|
//! Capture, Opus, and the network stay mono. These helpers are used only after a
|
||||||
|
//! peer has been decoded locally, just before the playout mix is written to the
|
||||||
|
//! stereo playback bus.
|
||||||
|
|
||||||
|
/// Clamp and compute constant-power pan gains for `pan` in `[-1.0, 1.0]`.
|
||||||
|
///
|
||||||
|
/// - `-1.0` is hard left `(1, 0)`
|
||||||
|
/// - `0.0` is center `(sqrt(1/2), sqrt(1/2))`
|
||||||
|
/// - `1.0` is hard right `(0, 1)`
|
||||||
|
pub fn pan_gains(pan: f32) -> (f32, f32) {
|
||||||
|
let pan = pan.clamp(-1.0, 1.0);
|
||||||
|
let theta = (pan + 1.0) * std::f32::consts::FRAC_PI_4;
|
||||||
|
(theta.cos(), theta.sin())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gains used by the legacy-compatible playback mixer.
|
||||||
|
///
|
||||||
|
/// The pure law above is constant-power. The existing application, however, was
|
||||||
|
/// mono and users heard the full old mono signal in both ears. Scaling by sqrt(2)
|
||||||
|
/// makes `pan = 0` exactly dual-mono `(1, 1)`, preserving the default sound while
|
||||||
|
/// still following the same equal-power curve as a peer is moved away from center.
|
||||||
|
pub fn playback_pan_gains(pan: f32) -> (f32, f32) {
|
||||||
|
let (left, right) = pan_gains(pan);
|
||||||
|
(left * std::f32::consts::SQRT_2, right * std::f32::consts::SQRT_2)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const EPS: f32 = 1.0e-6;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hard_left_and_right_are_endpoints() {
|
||||||
|
assert_eq!(pan_gains(-1.0), (1.0, 0.0));
|
||||||
|
let (l, r) = pan_gains(1.0);
|
||||||
|
assert!(l.abs() < EPS, "left at hard-right should be zero-ish, got {l}");
|
||||||
|
assert!((r - 1.0).abs() < EPS, "right at hard-right should be one, got {r}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn center_is_equal_and_power_preserving() {
|
||||||
|
let (l, r) = pan_gains(0.0);
|
||||||
|
assert!((l - r).abs() < EPS);
|
||||||
|
assert!((l - std::f32::consts::FRAC_1_SQRT_2).abs() < EPS);
|
||||||
|
assert!(((l * l + r * r) - 1.0).abs() < EPS);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gains_move_monotonically() {
|
||||||
|
let pans = [-1.0, -0.5, 0.0, 0.5, 1.0];
|
||||||
|
let mut prev_l = f32::INFINITY;
|
||||||
|
let mut prev_r = f32::NEG_INFINITY;
|
||||||
|
for pan in pans {
|
||||||
|
let (l, r) = pan_gains(pan);
|
||||||
|
assert!(l <= prev_l + EPS, "left gain must not rise as pan moves right");
|
||||||
|
assert!(r >= prev_r - EPS, "right gain must not fall as pan moves right");
|
||||||
|
prev_l = l;
|
||||||
|
prev_r = r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn playback_center_preserves_legacy_dual_mono() {
|
||||||
|
let (l, r) = playback_pan_gains(0.0);
|
||||||
|
assert!((l - 1.0).abs() < EPS);
|
||||||
|
assert!((r - 1.0).abs() < EPS);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn input_is_clamped() {
|
||||||
|
assert_eq!(pan_gains(-9.0), pan_gains(-1.0));
|
||||||
|
assert_eq!(pan_gains(9.0), pan_gains(1.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -283,8 +283,9 @@ fn run_playback(
|
|||||||
let core = context.connect_rc(None)
|
let core = context.connect_rc(None)
|
||||||
.map_err(|e| AudioError::Init(e.to_string()))?;
|
.map_err(|e| AudioError::Init(e.to_string()))?;
|
||||||
|
|
||||||
// Ring buffer setup: 9600 samples (200ms capacity for mono 48kHz).
|
// Ring buffer setup: 19200 interleaved samples (200ms capacity for stereo
|
||||||
const RING_CAPACITY: usize = 9600;
|
// 48kHz).
|
||||||
|
const RING_CAPACITY: usize = 9600 * crate::audio::PLAYBACK_CHANNELS;
|
||||||
let rb = HeapRb::<i16>::new(RING_CAPACITY);
|
let rb = HeapRb::<i16>::new(RING_CAPACITY);
|
||||||
let (mut producer, consumer) = rb.split();
|
let (mut producer, consumer) = rb.split();
|
||||||
|
|
||||||
@@ -371,7 +372,7 @@ fn run_playback(
|
|||||||
let data = &mut datas[0];
|
let data = &mut datas[0];
|
||||||
let mut total_size = 0;
|
let mut total_size = 0;
|
||||||
if let Some(slice) = data.data() {
|
if let Some(slice) = data.data() {
|
||||||
let stride = 2; // S16LE Mono = 2 bytes per frame
|
let stride = 2 * crate::audio::PLAYBACK_CHANNELS; // S16LE stereo
|
||||||
// Fill exactly what the graph asked for this cycle (with
|
// Fill exactly what the graph asked for this cycle (with
|
||||||
// a safe fallback), never the whole mapped slice — that
|
// a safe fallback), never the whole mapped slice — that
|
||||||
// over-pull past the ring depth was the original crackle.
|
// over-pull past the ring depth was the original crackle.
|
||||||
@@ -383,6 +384,8 @@ fn run_playback(
|
|||||||
user_data.callback_count.fetch_add(1, Ordering::Relaxed);
|
user_data.callback_count.fetch_add(1, Ordering::Relaxed);
|
||||||
let mut starved = 0u64;
|
let mut starved = 0u64;
|
||||||
for i in 0..n_frames {
|
for i in 0..n_frames {
|
||||||
|
let start = i * stride;
|
||||||
|
for ch in 0..crate::audio::PLAYBACK_CHANNELS {
|
||||||
let val = match user_data.consumer.try_pop() {
|
let val = match user_data.consumer.try_pop() {
|
||||||
Some(v) => v,
|
Some(v) => v,
|
||||||
None => {
|
None => {
|
||||||
@@ -391,9 +394,10 @@ fn run_playback(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let bytes = val.to_le_bytes();
|
let bytes = val.to_le_bytes();
|
||||||
let start = i * stride;
|
let offset = start + ch * 2;
|
||||||
slice[start] = bytes[0];
|
slice[offset] = bytes[0];
|
||||||
slice[start + 1] = bytes[1];
|
slice[offset + 1] = bytes[1];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if starved > 0 {
|
if starved > 0 {
|
||||||
// One wait-free atomic add per quantum — RT-safe.
|
// One wait-free atomic add per quantum — RT-safe.
|
||||||
@@ -403,7 +407,8 @@ fn run_playback(
|
|||||||
// actually pulled (excluding underruns, which removed
|
// actually pulled (excluding underruns, which removed
|
||||||
// nothing) so the mixer paces against true ring depth.
|
// nothing) so the mixer paces against true ring depth.
|
||||||
// Wait-free fetch_sub, RT-safe.
|
// Wait-free fetch_sub, RT-safe.
|
||||||
let popped = n_frames - starved as usize;
|
let requested_samples = n_frames * crate::audio::PLAYBACK_CHANNELS;
|
||||||
|
let popped = requested_samples - starved as usize;
|
||||||
if popped > 0 {
|
if popped > 0 {
|
||||||
user_data.fill_gauge.fetch_sub(popped, Ordering::Relaxed);
|
user_data.fill_gauge.fetch_sub(popped, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
@@ -411,7 +416,7 @@ fn run_playback(
|
|||||||
}
|
}
|
||||||
let chunk = data.chunk_mut();
|
let chunk = data.chunk_mut();
|
||||||
*chunk.offset_mut() = 0;
|
*chunk.offset_mut() = 0;
|
||||||
*chunk.stride_mut() = 2;
|
*chunk.stride_mut() = (2 * crate::audio::PLAYBACK_CHANNELS) as _;
|
||||||
*chunk.size_mut() = total_size as _;
|
*chunk.size_mut() = total_size as _;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -422,7 +427,7 @@ fn run_playback(
|
|||||||
let mut audio_info = spa::param::audio::AudioInfoRaw::new();
|
let mut audio_info = spa::param::audio::AudioInfoRaw::new();
|
||||||
audio_info.set_format(spa::param::audio::AudioFormat::S16LE);
|
audio_info.set_format(spa::param::audio::AudioFormat::S16LE);
|
||||||
audio_info.set_rate(48000);
|
audio_info.set_rate(48000);
|
||||||
audio_info.set_channels(1); // Mono
|
audio_info.set_channels(crate::audio::PLAYBACK_CHANNELS as u32); // Stereo playback
|
||||||
|
|
||||||
let obj = pw::spa::pod::Object {
|
let obj = pw::spa::pod::Object {
|
||||||
type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
|
type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
|
||||||
@@ -450,7 +455,7 @@ fn run_playback(
|
|||||||
// `frames_to_produce`). `requested()`, not the buffer size, now governs
|
// `frames_to_produce`). `requested()`, not the buffer size, now governs
|
||||||
// per-cycle output, so this is a generous max rather than a hard pin.
|
// per-cycle output, so this is a generous max rather than a hard pin.
|
||||||
const MAX_QUANTUM_FRAMES: i32 = 8192;
|
const MAX_QUANTUM_FRAMES: i32 = 8192;
|
||||||
const STRIDE: i32 = 2; // S16LE mono = 2 bytes/frame
|
const STRIDE: i32 = 2 * crate::audio::PLAYBACK_CHANNELS as i32; // S16LE stereo
|
||||||
let buffers_obj = pw::spa::pod::Object {
|
let buffers_obj = pw::spa::pod::Object {
|
||||||
type_: pw::spa::utils::SpaTypes::ObjectParamBuffers.as_raw(),
|
type_: pw::spa::utils::SpaTypes::ObjectParamBuffers.as_raw(),
|
||||||
id: pw::spa::param::ParamType::Buffers.as_raw(),
|
id: pw::spa::param::ParamType::Buffers.as_raw(),
|
||||||
@@ -555,7 +560,7 @@ fn run_playback(
|
|||||||
if verbose || du > 0 || dd > 0 {
|
if verbose || du > 0 || dd > 0 {
|
||||||
crate::log_msg(&format!(
|
crate::log_msg(&format!(
|
||||||
"playout-health: fill={fill} samples (~{}ms) | underrun +{du} samples/s (total {u}) | dropped +{dd} frames/s (total {d}) | quantum={q} frames, {dc} callbacks/s",
|
"playout-health: fill={fill} samples (~{}ms) | underrun +{du} samples/s (total {u}) | dropped +{dd} frames/s (total {d}) | quantum={q} frames, {dc} callbacks/s",
|
||||||
fill / 48,
|
fill / (48 * crate::audio::PLAYBACK_CHANNELS),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,6 @@
|
|||||||
|
use super::AudioDevice;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct AudioDevice {
|
|
||||||
pub name: String,
|
|
||||||
pub description: String,
|
|
||||||
pub is_input: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for AudioDevice {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
write!(f, "{}", self.description)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn enumerate_audio_devices() -> Vec<AudioDevice> {
|
pub fn enumerate_audio_devices() -> Vec<AudioDevice> {
|
||||||
let output = Command::new("pw-cli")
|
let output = Command::new("pw-cli")
|
||||||
.arg("list-objects")
|
.arg("list-objects")
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ use std::path::{Path, PathBuf};
|
|||||||
const SAMPLE_RATE: u32 = 48_000;
|
const SAMPLE_RATE: u32 = 48_000;
|
||||||
const BITS_PER_SAMPLE: u16 = 16;
|
const BITS_PER_SAMPLE: u16 = 16;
|
||||||
const CHANNELS: u16 = 1;
|
const CHANNELS: u16 = 1;
|
||||||
|
const RIFF_DATA_OVERHEAD: u64 = 36;
|
||||||
|
const MAX_RIFF_DATA_BYTES: u64 = u32::MAX as u64 - RIFF_DATA_OVERHEAD;
|
||||||
|
|
||||||
/// Cap on buffered mic samples (~200ms). Bounds how far recording lag can drift
|
/// Cap on buffered mic samples (~200ms). Bounds how far recording lag can drift
|
||||||
/// if the capture clock runs persistently faster than playout — past this we drop
|
/// if the capture clock runs persistently faster than playout — past this we drop
|
||||||
@@ -34,7 +36,7 @@ const MAX_MIC_FIFO: usize = SAMPLE_RATE as usize / 5;
|
|||||||
pub struct WavWriter {
|
pub struct WavWriter {
|
||||||
file: File,
|
file: File,
|
||||||
/// Bytes of PCM data written so far (for the size fields).
|
/// Bytes of PCM data written so far (for the size fields).
|
||||||
data_bytes: u32,
|
data_bytes: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WavWriter {
|
impl WavWriter {
|
||||||
@@ -42,7 +44,10 @@ impl WavWriter {
|
|||||||
pub fn new(path: &Path) -> io::Result<Self> {
|
pub fn new(path: &Path) -> io::Result<Self> {
|
||||||
let mut file = File::create(path)?;
|
let mut file = File::create(path)?;
|
||||||
file.write_all(&Self::header(0))?;
|
file.write_all(&Self::header(0))?;
|
||||||
Ok(Self { file, data_bytes: 0 })
|
Ok(Self {
|
||||||
|
file,
|
||||||
|
data_bytes: 0,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The 44-byte canonical WAV/PCM header for the given data length in bytes.
|
/// The 44-byte canonical WAV/PCM header for the given data length in bytes.
|
||||||
@@ -68,21 +73,40 @@ impl WavWriter {
|
|||||||
|
|
||||||
/// Append PCM samples to the data chunk.
|
/// Append PCM samples to the data chunk.
|
||||||
pub fn write_samples(&mut self, samples: &[i16]) -> io::Result<()> {
|
pub fn write_samples(&mut self, samples: &[i16]) -> io::Result<()> {
|
||||||
|
let added_bytes = u64::try_from(samples.len())
|
||||||
|
.ok()
|
||||||
|
.and_then(|len| len.checked_mul(2))
|
||||||
|
.ok_or_else(|| io::Error::other("WAV sample buffer too large"))?;
|
||||||
|
let new_data_bytes = self
|
||||||
|
.data_bytes
|
||||||
|
.checked_add(added_bytes)
|
||||||
|
.ok_or_else(|| io::Error::other("WAV data size overflow"))?;
|
||||||
|
if new_data_bytes > MAX_RIFF_DATA_BYTES {
|
||||||
|
return Err(io::Error::other("WAV too large for RIFF"));
|
||||||
|
}
|
||||||
|
|
||||||
let mut buf = Vec::with_capacity(samples.len() * 2);
|
let mut buf = Vec::with_capacity(samples.len() * 2);
|
||||||
for &s in samples {
|
for &s in samples {
|
||||||
buf.extend_from_slice(&s.to_le_bytes());
|
buf.extend_from_slice(&s.to_le_bytes());
|
||||||
}
|
}
|
||||||
self.file.write_all(&buf)?;
|
self.file.write_all(&buf)?;
|
||||||
self.data_bytes += (samples.len() * 2) as u32;
|
self.data_bytes = new_data_bytes;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Patch the RIFF + data size fields and flush. Consumes the writer.
|
/// Patch the RIFF + data size fields and flush. Consumes the writer.
|
||||||
pub fn finalize(mut self) -> io::Result<()> {
|
pub fn finalize(mut self) -> io::Result<()> {
|
||||||
|
let data_bytes = u32::try_from(self.data_bytes)
|
||||||
|
.map_err(|_| io::Error::other("WAV too large for RIFF"))?;
|
||||||
|
let riff_size = self
|
||||||
|
.data_bytes
|
||||||
|
.checked_add(RIFF_DATA_OVERHEAD)
|
||||||
|
.and_then(|size| u32::try_from(size).ok())
|
||||||
|
.ok_or_else(|| io::Error::other("WAV too large for RIFF"))?;
|
||||||
self.file.seek(SeekFrom::Start(4))?;
|
self.file.seek(SeekFrom::Start(4))?;
|
||||||
self.file.write_all(&(36 + self.data_bytes).to_le_bytes())?;
|
self.file.write_all(&riff_size.to_le_bytes())?;
|
||||||
self.file.seek(SeekFrom::Start(40))?;
|
self.file.seek(SeekFrom::Start(40))?;
|
||||||
self.file.write_all(&self.data_bytes.to_le_bytes())?;
|
self.file.write_all(&data_bytes.to_le_bytes())?;
|
||||||
self.file.flush()?;
|
self.file.flush()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -209,11 +233,29 @@ mod tests {
|
|||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wav_writer_rejects_data_that_would_overflow_riff_header() {
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join(format!("peerspeak-overflow-{}.wav", std::process::id()));
|
||||||
|
let mut w = WavWriter::new(&path).unwrap();
|
||||||
|
w.data_bytes = MAX_RIFF_DATA_BYTES - 1;
|
||||||
|
let before_len = std::fs::metadata(&path).unwrap().len();
|
||||||
|
|
||||||
|
let err = w.write_samples(&[0]).unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(err.kind(), io::ErrorKind::Other);
|
||||||
|
assert_eq!(w.data_bytes, MAX_RIFF_DATA_BYTES - 1);
|
||||||
|
assert_eq!(std::fs::metadata(&path).unwrap().len(), before_len);
|
||||||
|
drop(w);
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mic_is_summed_with_mix_when_present() {
|
fn mic_is_summed_with_mix_when_present() {
|
||||||
let dir = std::env::temp_dir();
|
let dir = std::env::temp_dir();
|
||||||
let mut r = Recorder {
|
let mut r = Recorder {
|
||||||
writer: WavWriter::new(&dir.join(format!("ps-sum-{}.wav", std::process::id()))).unwrap(),
|
writer: WavWriter::new(&dir.join(format!("ps-sum-{}.wav", std::process::id())))
|
||||||
|
.unwrap(),
|
||||||
mic_fifo: VecDeque::new(),
|
mic_fifo: VecDeque::new(),
|
||||||
path: PathBuf::new(),
|
path: PathBuf::new(),
|
||||||
};
|
};
|
||||||
@@ -223,7 +265,11 @@ mod tests {
|
|||||||
r.write_frame(&[10, 20]).unwrap();
|
r.write_frame(&[10, 20]).unwrap();
|
||||||
assert_eq!(r.mic_fifo.len(), 1, "two samples consumed, one mic left");
|
assert_eq!(r.mic_fifo.len(), 1, "two samples consumed, one mic left");
|
||||||
r.write_frame(&[0, 0]).unwrap();
|
r.write_frame(&[0, 0]).unwrap();
|
||||||
assert_eq!(r.mic_fifo.len(), 0, "remaining mic sample consumed; rest is silence");
|
assert_eq!(
|
||||||
|
r.mic_fifo.len(),
|
||||||
|
0,
|
||||||
|
"remaining mic sample consumed; rest is silence"
|
||||||
|
);
|
||||||
let _ = r.finalize();
|
let _ = r.finalize();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +277,8 @@ mod tests {
|
|||||||
fn mic_fifo_is_capped() {
|
fn mic_fifo_is_capped() {
|
||||||
let dir = std::env::temp_dir();
|
let dir = std::env::temp_dir();
|
||||||
let mut r = Recorder {
|
let mut r = Recorder {
|
||||||
writer: WavWriter::new(&dir.join(format!("ps-cap-{}.wav", std::process::id()))).unwrap(),
|
writer: WavWriter::new(&dir.join(format!("ps-cap-{}.wav", std::process::id())))
|
||||||
|
.unwrap(),
|
||||||
mic_fifo: VecDeque::new(),
|
mic_fifo: VecDeque::new(),
|
||||||
path: PathBuf::new(),
|
path: PathBuf::new(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
//! Dep-free linear-interpolation resamplers for the Windows/cpal backend (W4).
|
||||||
|
//!
|
||||||
|
//! The pipeline runs internally at 48 kHz (Opus + the 20 ms frame), but a WASAPI
|
||||||
|
//! endpoint may run at a different rate (commonly 44.1 kHz) and/or a non-stereo
|
||||||
|
//! channel layout. These convert at the device boundary so such a device plays and
|
||||||
|
//! captures instead of hard-erroring (the W4 limitation in the Windows port).
|
||||||
|
//!
|
||||||
|
//! ## Where each is used
|
||||||
|
//! - [`PushResampler`] (single channel) converts **capture** from the device rate
|
||||||
|
//! to 48 kHz on the capture drain thread — off the RT callback.
|
||||||
|
//! - [`StereoPullResampler`] converts **playback** from the internal 48 kHz stereo
|
||||||
|
//! bus to the device rate inside the output RT callback, pulling internal frames
|
||||||
|
//! from the ring on demand. It allocates nothing in `next`, so it is RT-safe.
|
||||||
|
//!
|
||||||
|
//! ## Quality
|
||||||
|
//! This is plain linear interpolation with no anti-aliasing filter: correct,
|
||||||
|
//! allocation-free, and adequate for speech, but it adds some aliasing when
|
||||||
|
//! downsampling. The seam is intentionally tiny so a higher-quality polyphase/FIR
|
||||||
|
//! resampler (e.g. the `rubato` crate, pending a supply-chain decision) can later
|
||||||
|
//! replace the internals without touching the cpal backend. The matching-rate /
|
||||||
|
//! matching-layout path in the backend bypasses these entirely and stays bit-exact.
|
||||||
|
|
||||||
|
/// Linear interpolation between `a` and `b` at fractional position `frac` in `[0, 1)`.
|
||||||
|
#[inline]
|
||||||
|
fn lerp(a: f32, b: f32, frac: f32) -> f32 {
|
||||||
|
a + (b - a) * frac
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stateful single-channel **push** resampler: feed input samples at `in_rate`,
|
||||||
|
/// receive output samples at `out_rate` through an `emit` callback. It carries the
|
||||||
|
/// fractional read position and the previous input sample across calls, so feeding
|
||||||
|
/// the stream block-by-block joins seamlessly. Neither [`push`](Self::push) nor
|
||||||
|
/// [`process`](Self::process) allocates.
|
||||||
|
pub struct PushResampler {
|
||||||
|
/// Input samples consumed per output sample (`in_rate / out_rate`).
|
||||||
|
step: f64,
|
||||||
|
/// Position of the next output sample, in input-sample units, measured from the
|
||||||
|
/// index of `prev` (the most recent input). Always advanced to stay `< 1.0`
|
||||||
|
/// after each input is consumed.
|
||||||
|
next: f64,
|
||||||
|
/// The previous input sample (left edge of the current interpolation segment).
|
||||||
|
prev: f32,
|
||||||
|
/// Whether any input has been seen yet (anchors the first output at input[0]).
|
||||||
|
started: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PushResampler {
|
||||||
|
/// Build a resampler from `in_rate` to `out_rate` (both in Hz). Rates are
|
||||||
|
/// clamped to `>= 1` so `step` is always finite and non-zero: a zero `step`
|
||||||
|
/// would make [`push`](Self::push)'s `while self.next < 1.0` loop forever. The
|
||||||
|
/// cpal backend's `resolve()` also rejects such rates up front, so this is
|
||||||
|
/// belt-and-suspenders against a future caller (review W7).
|
||||||
|
pub fn new(in_rate: u32, out_rate: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
step: in_rate.max(1) as f64 / out_rate.max(1) as f64,
|
||||||
|
next: 0.0,
|
||||||
|
prev: 0.0,
|
||||||
|
started: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed one input sample; `emit` is called for each output sample produced
|
||||||
|
/// (zero or more, depending on the rate ratio).
|
||||||
|
pub fn push(&mut self, cur: f32, mut emit: impl FnMut(f32)) {
|
||||||
|
if !self.started {
|
||||||
|
// First sample: just establish the left edge. Linear interpolation
|
||||||
|
// needs the next input as the right edge, so the first output is
|
||||||
|
// produced on the next push. This gives exact alignment
|
||||||
|
// (`output[k] == input[k]` at equal rates) with one input-sample of
|
||||||
|
// latency — negligible (~20 µs at 48 kHz).
|
||||||
|
self.started = true;
|
||||||
|
self.prev = cur;
|
||||||
|
self.next = 0.0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// `prev` sits at position 0 of this segment and `cur` at position 1; emit
|
||||||
|
// every output whose position falls in [0, 1).
|
||||||
|
while self.next < 1.0 {
|
||||||
|
emit(lerp(self.prev, cur, self.next as f32));
|
||||||
|
self.next += self.step;
|
||||||
|
}
|
||||||
|
self.next -= 1.0;
|
||||||
|
self.prev = cur;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience for tests / batch callers: push a whole slice.
|
||||||
|
pub fn process(&mut self, input: &[f32], mut emit: impl FnMut(f32)) {
|
||||||
|
for &s in input {
|
||||||
|
self.push(s, &mut emit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stateful stereo **pull** resampler: produce output frames at `out_rate` by
|
||||||
|
/// pulling input frames at `in_rate` from a closure on demand. Call
|
||||||
|
/// [`next`](Self::next) once per output frame; it pulls as many input frames as the
|
||||||
|
/// ratio requires and returns the interpolated `(left, right)`, or `None` when the
|
||||||
|
/// puller runs dry (an underrun). Allocates nothing, so it is safe in an RT output
|
||||||
|
/// callback.
|
||||||
|
pub struct StereoPullResampler {
|
||||||
|
/// Input frames consumed per output frame (`in_rate / out_rate`).
|
||||||
|
step: f64,
|
||||||
|
/// Position of the next output frame within `[prev, cur)`, in `[0, 1)`.
|
||||||
|
frac: f64,
|
||||||
|
/// Left edge of the current interpolation segment.
|
||||||
|
prev: (f32, f32),
|
||||||
|
/// Right edge of the current interpolation segment.
|
||||||
|
cur: (f32, f32),
|
||||||
|
/// Whether `prev`/`cur` have been primed from the puller yet.
|
||||||
|
primed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StereoPullResampler {
|
||||||
|
/// Build a resampler from `in_rate` to `out_rate` (both in Hz). Rates are
|
||||||
|
/// clamped to `>= 1` so `step` is finite and non-zero — otherwise
|
||||||
|
/// [`next`](Self::next)'s `while self.frac >= 1.0` could spin (review W7).
|
||||||
|
pub fn new(in_rate: u32, out_rate: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
step: in_rate.max(1) as f64 / out_rate.max(1) as f64,
|
||||||
|
frac: 0.0,
|
||||||
|
prev: (0.0, 0.0),
|
||||||
|
cur: (0.0, 0.0),
|
||||||
|
primed: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Produce the next output frame, pulling input frames via `pull` as needed.
|
||||||
|
/// Returns `None` if `pull` returns `None` before the frame can be formed
|
||||||
|
/// (underrun); the caller should substitute silence for that frame.
|
||||||
|
pub fn next(&mut self, mut pull: impl FnMut() -> Option<(f32, f32)>) -> Option<(f32, f32)> {
|
||||||
|
if !self.primed {
|
||||||
|
// Prime both edges from two pulls so the first output frame aligns
|
||||||
|
// exactly with the first input frame (`out[0] == in[0]` at equal
|
||||||
|
// rates). Needs two frames available to start, which the prefilled
|
||||||
|
// playback ring always has.
|
||||||
|
self.prev = pull()?;
|
||||||
|
self.cur = pull()?;
|
||||||
|
self.primed = true;
|
||||||
|
self.frac = 0.0;
|
||||||
|
}
|
||||||
|
// Advance the segment until the read position lands inside [prev, cur).
|
||||||
|
while self.frac >= 1.0 {
|
||||||
|
self.prev = self.cur;
|
||||||
|
self.cur = pull()?;
|
||||||
|
self.frac -= 1.0;
|
||||||
|
}
|
||||||
|
let f = self.frac as f32;
|
||||||
|
let out = (lerp(self.prev.0, self.cur.0, f), lerp(self.prev.1, self.cur.1, f));
|
||||||
|
self.frac += self.step;
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Equal rates align exactly: `output[k] == input[k]`. The final input lands on
|
||||||
|
/// the next push (one-sample streaming latency), so we get `n - 1` outputs.
|
||||||
|
#[test]
|
||||||
|
fn push_identity_when_rates_match() {
|
||||||
|
let mut r = PushResampler::new(48_000, 48_000);
|
||||||
|
let input = [0.0, 0.1, 0.2, 0.3, 0.4];
|
||||||
|
let mut out = Vec::new();
|
||||||
|
r.process(&input, |s| out.push(s));
|
||||||
|
assert_eq!(out.len(), input.len() - 1);
|
||||||
|
for (a, b) in out.iter().zip(input.iter()) {
|
||||||
|
assert!((a - b).abs() < 1e-6, "{a} vs {b}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upsampling 2x roughly doubles the output count and the midpoints interpolate.
|
||||||
|
#[test]
|
||||||
|
fn push_upsample_2x_interpolates_midpoints() {
|
||||||
|
let mut r = PushResampler::new(24_000, 48_000); // step = 0.5
|
||||||
|
let input = [0.0, 1.0, 2.0, 3.0];
|
||||||
|
let mut out = Vec::new();
|
||||||
|
r.process(&input, |s| out.push(s));
|
||||||
|
// (n - 1) segments at 2 outputs each = 6.
|
||||||
|
assert_eq!(out.len(), 6, "out {out:?}");
|
||||||
|
// A half-step between 1.0 and 2.0 must appear near 1.5.
|
||||||
|
assert!(
|
||||||
|
out.iter().any(|&s| (s - 1.5).abs() < 1e-3),
|
||||||
|
"expected a ~1.5 midpoint in {out:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downsampling drops the rate: fewer outputs than inputs, monotonic ramp preserved.
|
||||||
|
#[test]
|
||||||
|
fn push_downsample_reduces_count() {
|
||||||
|
let mut r = PushResampler::new(48_000, 44_100); // step ~1.088
|
||||||
|
let input: Vec<f32> = (0..441).map(|i| i as f32).collect();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
r.process(&input, |s| out.push(s));
|
||||||
|
// 441 in @ 48k -> ~405 out @ 44.1k.
|
||||||
|
assert!(
|
||||||
|
(390..=410).contains(&out.len()),
|
||||||
|
"expected ~405 outputs, got {}",
|
||||||
|
out.len()
|
||||||
|
);
|
||||||
|
// Output stays within the input's value range and is non-decreasing.
|
||||||
|
for w in out.windows(2) {
|
||||||
|
assert!(w[1] >= w[0] - 1e-3, "ramp should not reverse: {w:?}");
|
||||||
|
}
|
||||||
|
assert!(*out.last().unwrap() <= 440.0 + 1e-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull resampler at equal rates returns each input frame in order, aligned.
|
||||||
|
/// Two-pull priming uses one frame of lookahead, so `n` inputs yield `n - 1`
|
||||||
|
/// outputs (the last frame emits once a successor arrives).
|
||||||
|
#[test]
|
||||||
|
fn pull_identity_when_rates_match() {
|
||||||
|
let mut r = StereoPullResampler::new(48_000, 48_000);
|
||||||
|
let frames = [(0.0, 9.0), (1.0, 8.0), (2.0, 7.0), (3.0, 6.0)];
|
||||||
|
let mut idx = 0;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
while let Some(f) = r.next(|| {
|
||||||
|
let v = frames.get(idx).copied();
|
||||||
|
idx += 1;
|
||||||
|
v
|
||||||
|
}) {
|
||||||
|
out.push(f);
|
||||||
|
}
|
||||||
|
assert_eq!(out.len(), frames.len() - 1, "out {out:?}");
|
||||||
|
for (got, want) in out.iter().zip(frames.iter()) {
|
||||||
|
assert!((got.0 - want.0).abs() < 1e-6 && (got.1 - want.1).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull resampler reports underrun (`None`) once the source is exhausted.
|
||||||
|
#[test]
|
||||||
|
fn pull_returns_none_on_underrun() {
|
||||||
|
let mut r = StereoPullResampler::new(48_000, 44_100); // step ~1.088 -> pulls >1 per out
|
||||||
|
let frames = [(0.0, 0.0), (1.0, -1.0)];
|
||||||
|
let mut idx = 0;
|
||||||
|
let mut pull = || {
|
||||||
|
let v = frames.get(idx).copied();
|
||||||
|
idx += 1;
|
||||||
|
v
|
||||||
|
};
|
||||||
|
// First frame primes + emits; subsequent calls eventually exhaust the source.
|
||||||
|
let mut produced = 0;
|
||||||
|
let mut hit_none = false;
|
||||||
|
for _ in 0..10 {
|
||||||
|
if r.next(&mut pull).is_some() {
|
||||||
|
produced += 1;
|
||||||
|
} else {
|
||||||
|
hit_none = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(produced >= 1, "should produce at least the primed frame");
|
||||||
|
assert!(hit_none, "should report underrun once the puller is dry");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downsampling via pull consumes more input frames than it emits output frames.
|
||||||
|
#[test]
|
||||||
|
fn pull_downsample_consumes_more_than_it_emits() {
|
||||||
|
let mut r = StereoPullResampler::new(48_000, 24_000); // step = 2.0
|
||||||
|
let input: Vec<(f32, f32)> = (0..100).map(|i| (i as f32, -(i as f32))).collect();
|
||||||
|
let mut idx = 0;
|
||||||
|
let mut emitted = 0;
|
||||||
|
for _ in 0..40 {
|
||||||
|
let f = r.next(|| {
|
||||||
|
let v = input.get(idx).copied();
|
||||||
|
idx += 1;
|
||||||
|
v
|
||||||
|
});
|
||||||
|
if f.is_some() {
|
||||||
|
emitted += 1;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// At step 2.0 we consume ~2 input frames per output frame.
|
||||||
|
assert!(idx > emitted, "consumed {idx} input, emitted {emitted} output");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A zero rate must not produce a zero `step` (which would spin `push`'s inner
|
||||||
|
/// `while self.next < 1.0` forever). Clamping makes the call terminate (W7).
|
||||||
|
#[test]
|
||||||
|
fn push_zero_rate_does_not_spin() {
|
||||||
|
let mut r = PushResampler::new(0, 48_000);
|
||||||
|
let mut count = 0usize;
|
||||||
|
// Feed two samples; with a clamped non-zero step this returns promptly.
|
||||||
|
r.push(0.0, |_| count += 1);
|
||||||
|
r.push(1.0, |_| count += 1);
|
||||||
|
// Reaching here at all is the assertion (no hang); some output is produced.
|
||||||
|
assert!(count >= 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A zero output rate must not make the pull resampler's segment-advance loop
|
||||||
|
/// spin. Clamping keeps `step` finite so `next` terminates (W7).
|
||||||
|
#[test]
|
||||||
|
fn pull_zero_out_rate_does_not_spin() {
|
||||||
|
let mut r = StereoPullResampler::new(48_000, 0);
|
||||||
|
let frames = [(0.0, 0.0), (1.0, 1.0), (2.0, 2.0)];
|
||||||
|
let mut idx = 0;
|
||||||
|
let got = r.next(|| {
|
||||||
|
let v = frames.get(idx).copied();
|
||||||
|
idx += 1;
|
||||||
|
v
|
||||||
|
});
|
||||||
|
// Terminates and yields the primed frame instead of hanging.
|
||||||
|
assert!(got.is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
//! Custom UI background (W16): turn a user-picked image into a capped PNG to
|
||||||
|
//! render behind the whole UI, plus the legibility scrim drawn over it.
|
||||||
|
//!
|
||||||
|
//! This is the pure, unit-testable seam — `process_background` decodes/downscales
|
||||||
|
//! arbitrary input defensively (same caution as avatar uploads) and `scrim_color`
|
||||||
|
//! computes the overlay tint. The file I/O, the `rfd` picker, and the iced
|
||||||
|
//! `stack!` that layers image → scrim → UI all live at the app edge in
|
||||||
|
//! `src/app/mod.rs`. The background is **local-only** — never sent to peers — so
|
||||||
|
//! there's no gossip-frame budget here (hence a much larger size cap than avatars).
|
||||||
|
|
||||||
|
use iced::Color;
|
||||||
|
|
||||||
|
/// Longest side a custom background is downscaled to on ingest (aspect preserved,
|
||||||
|
/// never upscaled). Big enough to look crisp filling the window, small enough to
|
||||||
|
/// decode and cache cheaply. Local-only, so this is generous vs. the avatar cap.
|
||||||
|
pub const BACKGROUND_MAX_PX: u32 = 1920;
|
||||||
|
|
||||||
|
/// Default scrim strength. `0.0` = the image shows at full strength, `1.0` = it's
|
||||||
|
/// fully hidden behind the theme's base colour. Half keeps a photo clearly visible
|
||||||
|
/// while text and cards stay readable over it.
|
||||||
|
pub const DEFAULT_DIM: f32 = 0.5;
|
||||||
|
|
||||||
|
/// Decode an arbitrary user image (png/jpeg/…), downscale so its longest side is
|
||||||
|
/// at most [`BACKGROUND_MAX_PX`] (aspect preserved; smaller images are left as-is,
|
||||||
|
/// never upscaled), and re-encode as PNG bytes ready to write to disk. Decoding is
|
||||||
|
/// bounded by the `image` crate's defaults so a malformed/huge file is rejected
|
||||||
|
/// rather than exhausting memory. Errors come back as a message for the UI.
|
||||||
|
pub fn process_background(raw: &[u8]) -> Result<Vec<u8>, String> {
|
||||||
|
let img = image::load_from_memory(raw).map_err(|e| format!("Couldn't read image: {e}"))?;
|
||||||
|
// Only ever shrink. `resize` preserves aspect, fitting within the box; a
|
||||||
|
// higher-quality filter than `thumbnail` since a background fills the window.
|
||||||
|
let scaled = if img.width() > BACKGROUND_MAX_PX || img.height() > BACKGROUND_MAX_PX {
|
||||||
|
img.resize(
|
||||||
|
BACKGROUND_MAX_PX,
|
||||||
|
BACKGROUND_MAX_PX,
|
||||||
|
image::imageops::FilterType::Lanczos3,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
img
|
||||||
|
};
|
||||||
|
let mut png = std::io::Cursor::new(Vec::new());
|
||||||
|
scaled
|
||||||
|
.write_to(&mut png, image::ImageFormat::Png)
|
||||||
|
.map_err(|e| format!("Couldn't encode image: {e}"))?;
|
||||||
|
Ok(png.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The legibility scrim drawn between the background image and the UI: the active
|
||||||
|
/// theme's base colour at `dim` alpha (clamped to `0.0..=1.0`). A higher `dim`
|
||||||
|
/// recedes the image so body text and panel chrome stay readable, and it re-tints
|
||||||
|
/// per theme since `base` comes from the active palette.
|
||||||
|
pub fn scrim_color(base: Color, dim: f32) -> Color {
|
||||||
|
Color { a: dim.clamp(0.0, 1.0), ..base }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A valid PNG of the given size, as raw bytes (test helper).
|
||||||
|
fn make_png(w: u32, h: u32) -> Vec<u8> {
|
||||||
|
let img = image::DynamicImage::new_rgb8(w, h);
|
||||||
|
let mut buf = std::io::Cursor::new(Vec::new());
|
||||||
|
img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
|
||||||
|
buf.into_inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn process_background_downscales_oversized() {
|
||||||
|
// A 4000x2000 image is shrunk so the longest side is BACKGROUND_MAX_PX,
|
||||||
|
// aspect preserved, and the result re-decodes as a PNG within bounds.
|
||||||
|
let raw = make_png(4000, 2000);
|
||||||
|
let png = process_background(&raw).expect("should process");
|
||||||
|
let decoded = image::load_from_memory(&png).unwrap();
|
||||||
|
assert_eq!(decoded.width().max(decoded.height()), BACKGROUND_MAX_PX);
|
||||||
|
assert_eq!(decoded.width(), BACKGROUND_MAX_PX);
|
||||||
|
assert_eq!(decoded.height(), BACKGROUND_MAX_PX / 2); // 2:1 aspect kept
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn process_background_leaves_small_images_unscaled() {
|
||||||
|
let raw = make_png(640, 480);
|
||||||
|
let png = process_background(&raw).expect("should process");
|
||||||
|
let decoded = image::load_from_memory(&png).unwrap();
|
||||||
|
assert_eq!((decoded.width(), decoded.height()), (640, 480));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn process_background_rejects_non_image() {
|
||||||
|
assert!(process_background(b"definitely not an image").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scrim_color_sets_alpha_and_keeps_rgb() {
|
||||||
|
let base = Color::from_rgb(0.1, 0.2, 0.3);
|
||||||
|
let s = scrim_color(base, 0.5);
|
||||||
|
assert_eq!((s.r, s.g, s.b), (0.1, 0.2, 0.3));
|
||||||
|
assert!((s.a - 0.5).abs() < f32::EPSILON);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scrim_color_clamps_dim() {
|
||||||
|
let base = Color::BLACK;
|
||||||
|
assert!((scrim_color(base, -1.0).a - 0.0).abs() < f32::EPSILON);
|
||||||
|
assert!((scrim_color(base, 2.0).a - 1.0).abs() < f32::EPSILON);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
//! Audio playout diagnostic probe.
|
//! Audio playout diagnostic probe.
|
||||||
//!
|
//!
|
||||||
//! Drives a phase-continuous sine tone through the *real* PipeWire playback path
|
//! Drives a phase-continuous sine tone through the *real* playback path
|
||||||
//! (`PipeWireBackend::start_playback`), using the *same* fill-paced production
|
//! (PipeWire on Linux, cpal/WASAPI on Windows), using the *same* fill-paced
|
||||||
//! the production mixer uses (`core/mod.rs`): generate a frame only while the
|
//! production the production mixer uses (`core/mod.rs`): generate a frame only while the
|
||||||
//! playback ring is below `PLAYBACK_TARGET_SAMPLES`, so production tracks the
|
//! playback ring is below `PLAYBACK_TARGET_SAMPLES`, so production tracks the
|
||||||
//! PipeWire hardware clock. No network, no microphone — this isolates the local
|
//! hardware clock. No network, no microphone — this isolates the local output
|
||||||
//! output path so we can confirm the clock-paced playout is glitch-free.
|
//! path so we can confirm the clock-paced playout is glitch-free.
|
||||||
//!
|
//!
|
||||||
//! Use your ears on the tone (any click/pop is a glitch) together with the
|
//! Use your ears on the tone (any click/pop is a glitch) together with the
|
||||||
//! `playout-health:` lines tailed to stdout:
|
//! `playout-health:` lines tailed to stdout:
|
||||||
@@ -17,7 +17,29 @@
|
|||||||
//!
|
//!
|
||||||
//! 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 platform playback backend directly: PipeWire on Linux
|
||||||
|
//! and cpal/WASAPI on Windows. Other targets use a stub that explains the limitation.
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn main() {
|
||||||
|
unix_probe::run();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn main() {
|
||||||
|
win_probe::run();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "linux", windows)))]
|
||||||
|
fn main() {
|
||||||
|
eprintln!(
|
||||||
|
"audio_probe is only supported on Linux and Windows builds (it drives the platform playback backend directly)."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod unix_probe {
|
||||||
use std::io::{BufRead, BufReader, Seek, SeekFrom};
|
use std::io::{BufRead, BufReader, Seek, SeekFrom};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::AtomicUsize;
|
use std::sync::atomic::AtomicUsize;
|
||||||
@@ -26,12 +48,12 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use peerspeak::audio::AudioBackend;
|
use peerspeak::audio::AudioBackend;
|
||||||
use peerspeak::audio::pipewire_impl::PipeWireBackend;
|
use peerspeak::audio::pipewire_impl::PipeWireBackend;
|
||||||
use peerspeak::core::jitter::FRAME_SAMPLES; // 960 samples = 20ms @ 48kHz mono
|
use peerspeak::core::jitter::FRAME_SAMPLES; // 960 mono frames = 20ms @ 48kHz
|
||||||
|
|
||||||
const SAMPLE_RATE: f32 = 48_000.0;
|
const SAMPLE_RATE: f32 = 48_000.0;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
pub async fn run() {
|
||||||
let mut args = std::env::args().skip(1);
|
let mut args = std::env::args().skip(1);
|
||||||
let freq: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(440.0);
|
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 secs: u64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(30);
|
||||||
@@ -69,11 +91,14 @@ async fn main() {
|
|||||||
tokio::time::sleep(Duration::from_millis(2)).await;
|
tokio::time::sleep(Duration::from_millis(2)).await;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let mut frame = Vec::with_capacity(FRAME_SAMPLES);
|
let mut frame = Vec::with_capacity(FRAME_SAMPLES * peerspeak::audio::PLAYBACK_CHANNELS);
|
||||||
for _ in 0..FRAME_SAMPLES {
|
for _ in 0..FRAME_SAMPLES {
|
||||||
let t = n as f32 / SAMPLE_RATE;
|
let t = n as f32 / SAMPLE_RATE;
|
||||||
// 0.25 amplitude: clearly audible but not harsh.
|
// 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;
|
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);
|
frame.push(sample);
|
||||||
n += 1;
|
n += 1;
|
||||||
}
|
}
|
||||||
@@ -117,3 +142,110 @@ fn spawn_log_tailer() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod win_probe {
|
||||||
|
use std::io::{BufRead, BufReader, Seek, SeekFrom};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::AtomicUsize;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use peerspeak::audio::AudioBackend;
|
||||||
|
use peerspeak::audio::cpal_impl::CpalBackend;
|
||||||
|
use peerspeak::core::jitter::FRAME_SAMPLES; // 960 mono frames = 20ms @ 48kHz
|
||||||
|
|
||||||
|
const SAMPLE_RATE: f32 = 48_000.0;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
pub async fn run() {
|
||||||
|
let mut args = std::env::args().skip(1);
|
||||||
|
let freq: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(440.0);
|
||||||
|
let secs: u64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(30);
|
||||||
|
let target_node: Option<String> = args.next();
|
||||||
|
|
||||||
|
// The playout-health logger is quiet in normal operation (it only logs
|
||||||
|
// glitches); ask it for the full once-per-second heartbeat so the probe can
|
||||||
|
// show the steady-state numbers.
|
||||||
|
// SAFETY: set before any playback thread starts, so no concurrent env read.
|
||||||
|
unsafe { std::env::set_var("PEERSPEAK_AUDIO_VERBOSE", "1") };
|
||||||
|
|
||||||
|
println!("audio_probe: {freq} Hz tone for {secs}s through the real playback path.");
|
||||||
|
println!("Listen for clicks/pops; watch the playout-health lines below.\n");
|
||||||
|
|
||||||
|
// Tail the app log (where playout-health lines land) to stdout in the
|
||||||
|
// background so it's all in one terminal.
|
||||||
|
spawn_log_tailer();
|
||||||
|
|
||||||
|
let backend = CpalBackend::new();
|
||||||
|
let (tx, rx) = mpsc::channel::<Vec<i16>>();
|
||||||
|
let ring_fill = Arc::new(AtomicUsize::new(0));
|
||||||
|
if let Err(e) = backend.start_playback(rx, target_node, ring_fill.clone()) {
|
||||||
|
eprintln!("failed to start playback: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase-continuous sine, generated one 20ms frame at a time, fill-paced
|
||||||
|
// exactly like the production mixer: only produce while the ring is below
|
||||||
|
// target, so production tracks the cpal/WASAPI hardware clock.
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(secs);
|
||||||
|
let mut n: u64 = 0; // running sample index keeps phase continuous across frames
|
||||||
|
while tokio::time::Instant::now() < deadline {
|
||||||
|
if ring_fill.load(Ordering::Relaxed) >= peerspeak::audio::PLAYBACK_TARGET_SAMPLES {
|
||||||
|
tokio::time::sleep(Duration::from_millis(2)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut frame = Vec::with_capacity(FRAME_SAMPLES * peerspeak::audio::PLAYBACK_CHANNELS);
|
||||||
|
for _ in 0..FRAME_SAMPLES {
|
||||||
|
let t = n as f32 / SAMPLE_RATE;
|
||||||
|
// 0.25 amplitude: clearly audible but not harsh.
|
||||||
|
let sample =
|
||||||
|
(0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16;
|
||||||
|
// Stereo playback bus: duplicate the probe tone to L/R.
|
||||||
|
frame.push(sample);
|
||||||
|
frame.push(sample);
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
if tx.send(frame).is_err() {
|
||||||
|
eprintln!("playback channel closed early");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let the ring drain, then stop.
|
||||||
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||||
|
let _ = backend.stop();
|
||||||
|
println!("\naudio_probe: done.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the app log, seek to the end, and echo new lines (the `playout-health:`
|
||||||
|
/// reports) to stdout once they appear.
|
||||||
|
fn spawn_log_tailer() {
|
||||||
|
let path = peerspeak::log_file_path();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
// Wait for the file to exist (first log_msg creates it).
|
||||||
|
let file = loop {
|
||||||
|
if let Ok(f) = std::fs::File::open(&path) {
|
||||||
|
break f;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
|
};
|
||||||
|
let mut reader = BufReader::new(file);
|
||||||
|
let _ = reader.seek(SeekFrom::End(0));
|
||||||
|
loop {
|
||||||
|
let mut line = String::new();
|
||||||
|
match reader.read_line(&mut line) {
|
||||||
|
Ok(0) => std::thread::sleep(Duration::from_millis(150)),
|
||||||
|
Ok(_) => {
|
||||||
|
if line.contains("playout-health:") {
|
||||||
|
print!("{line}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => std::thread::sleep(Duration::from_millis(150)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::notify::Sound;
|
use crate::notify::Sound;
|
||||||
use crate::theme::AppTheme;
|
use crate::theme::AppTheme;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
@@ -105,6 +106,10 @@ fn default_true() -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_background_dim() -> f32 {
|
||||||
|
crate::background::DEFAULT_DIM
|
||||||
|
}
|
||||||
|
|
||||||
fn default_volume() -> f32 {
|
fn default_volume() -> f32 {
|
||||||
1.0
|
1.0
|
||||||
}
|
}
|
||||||
@@ -184,6 +189,16 @@ pub struct AppConfig {
|
|||||||
/// Our chosen avatar (W4): monogram fallback or a bundled preset.
|
/// Our chosen avatar (W4): monogram fallback or a bundled preset.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub avatar: crate::avatar::Avatar,
|
pub avatar: crate::avatar::Avatar,
|
||||||
|
/// Custom UI background image (W16): path to the downscaled PNG we wrote into
|
||||||
|
/// the config dir (see `background_path`). `None` = use the theme background.
|
||||||
|
/// Local-only; never sent to peers.
|
||||||
|
#[serde(default)]
|
||||||
|
pub background: Option<String>,
|
||||||
|
/// Scrim strength drawn over the custom background for legibility (0.0 = image
|
||||||
|
/// at full strength, 1.0 = fully hidden behind the theme base). See
|
||||||
|
/// `crate::background::scrim_color`.
|
||||||
|
#[serde(default = "default_background_dim")]
|
||||||
|
pub background_dim: f32,
|
||||||
/// What a call recording captures (mixed / per-peer stems / both).
|
/// What a call recording captures (mixed / per-peer stems / both).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub recording_mode: RecordingMode,
|
pub recording_mode: RecordingMode,
|
||||||
@@ -232,6 +247,26 @@ pub struct AppConfig {
|
|||||||
/// capped (see `recents`). Defaulted empty so older configs upgrade cleanly.
|
/// capped (see `recents`). Defaulted empty so older configs upgrade cleanly.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub recents: Vec<crate::recents::Recent>,
|
pub recents: Vec<crate::recents::Recent>,
|
||||||
|
/// Per-peer listener-side EQ settings, keyed by peer node id string. Local
|
||||||
|
/// preference only; never sent to peers.
|
||||||
|
#[serde(default)]
|
||||||
|
pub peer_eq: HashMap<String, crate::audio::eq::EqSettings>,
|
||||||
|
/// Per-peer listener-side pan (`-1.0` left, `0.0` center, `1.0` right),
|
||||||
|
/// keyed by peer node id string. Local preference only.
|
||||||
|
#[serde(default)]
|
||||||
|
pub peer_pan: HashMap<String, f32>,
|
||||||
|
/// Per-peer listener-side volume/gain (`1.0` = unity), keyed by peer node id
|
||||||
|
/// string. Local preference only; never sent to peers. Absent entry = unity.
|
||||||
|
#[serde(default)]
|
||||||
|
pub peer_volume: HashMap<String, f32>,
|
||||||
|
/// Per-peer listener-side noise-gate threshold (normalized RMS, `0.0` = off),
|
||||||
|
/// keyed by peer node id string. Local preference only; never sent to peers.
|
||||||
|
/// Absent entry = gate disabled (pass-through).
|
||||||
|
#[serde(default)]
|
||||||
|
pub peer_gate: HashMap<String, f32>,
|
||||||
|
/// Focused app-local keyboard shortcuts.
|
||||||
|
#[serde(default)]
|
||||||
|
pub hotkeys: crate::hotkeys::HotkeyMap,
|
||||||
/// Last window size (px), restored as the initial size on next launch.
|
/// Last window size (px), restored as the initial size on next launch.
|
||||||
/// Saved on close.
|
/// Saved on close.
|
||||||
#[serde(default = "default_window_width")]
|
#[serde(default = "default_window_width")]
|
||||||
@@ -268,6 +303,8 @@ impl Default for AppConfig {
|
|||||||
room_layout: RoomLayout::default(),
|
room_layout: RoomLayout::default(),
|
||||||
theme: AppTheme::default(),
|
theme: AppTheme::default(),
|
||||||
avatar: crate::avatar::Avatar::default(),
|
avatar: crate::avatar::Avatar::default(),
|
||||||
|
background: None,
|
||||||
|
background_dim: default_background_dim(),
|
||||||
recording_mode: RecordingMode::default(),
|
recording_mode: RecordingMode::default(),
|
||||||
custom_sound_self_join: None,
|
custom_sound_self_join: None,
|
||||||
custom_sound_peer_join: None,
|
custom_sound_peer_join: None,
|
||||||
@@ -287,6 +324,11 @@ impl Default for AppConfig {
|
|||||||
sound_reconnect_failed_enabled: true,
|
sound_reconnect_failed_enabled: true,
|
||||||
pixelpass_path: None,
|
pixelpass_path: None,
|
||||||
recents: Vec::new(),
|
recents: Vec::new(),
|
||||||
|
peer_eq: HashMap::new(),
|
||||||
|
peer_pan: HashMap::new(),
|
||||||
|
peer_volume: HashMap::new(),
|
||||||
|
peer_gate: HashMap::new(),
|
||||||
|
hotkeys: crate::hotkeys::HotkeyMap::default(),
|
||||||
window_width: default_window_width(),
|
window_width: default_window_width(),
|
||||||
window_height: default_window_height(),
|
window_height: default_window_height(),
|
||||||
window_x: None,
|
window_x: None,
|
||||||
@@ -333,6 +375,17 @@ impl AppConfig {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Path the processed custom-background PNG (W16) is written to, alongside
|
||||||
|
/// `config.json` in the app config dir. We store our own downscaled copy here
|
||||||
|
/// (rather than base64 in the config) so the JSON stays small.
|
||||||
|
pub fn background_path() -> Option<PathBuf> {
|
||||||
|
dirs::config_dir().map(|mut p| {
|
||||||
|
p.push("peerspeak");
|
||||||
|
p.push("background.png");
|
||||||
|
p
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn load() -> Self {
|
pub fn load() -> Self {
|
||||||
if let Some(path) = Self::config_path()
|
if let Some(path) = Self::config_path()
|
||||||
&& let Ok(contents) = fs::read_to_string(&path)
|
&& let Ok(contents) = fs::read_to_string(&path)
|
||||||
@@ -411,6 +464,20 @@ mod tests {
|
|||||||
assert_eq!(deserialized.window_height, 760.0);
|
assert_eq!(deserialized.window_height, 760.0);
|
||||||
// Configs predating the recents list load an empty list.
|
// Configs predating the recents list load an empty list.
|
||||||
assert!(deserialized.recents.is_empty());
|
assert!(deserialized.recents.is_empty());
|
||||||
|
// Configs predating per-peer listener shaping load flat/center/default
|
||||||
|
// shortcut settings.
|
||||||
|
assert!(deserialized.peer_eq.is_empty());
|
||||||
|
assert!(deserialized.peer_pan.is_empty());
|
||||||
|
assert!(deserialized.peer_volume.is_empty());
|
||||||
|
assert!(deserialized.peer_gate.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
crate::hotkeys::format_binding(
|
||||||
|
deserialized
|
||||||
|
.hotkeys
|
||||||
|
.binding(crate::hotkeys::HotkeyAction::PushToTalk)
|
||||||
|
),
|
||||||
|
"Space"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -596,4 +663,3 @@ mod tests {
|
|||||||
assert_eq!(config.noise_gate_threshold, 0.01);
|
assert_eq!(config.noise_gate_threshold, 0.01);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ const PRIME_TIMEOUT_TICKS: usize = 25;
|
|||||||
/// badly behind, so we drop the oldest and resync rather than grow unbounded.
|
/// badly behind, so we drop the oldest and resync rather than grow unbounded.
|
||||||
const MAX_BUFFERED_FRAMES: usize = 32;
|
const MAX_BUFFERED_FRAMES: usize = 32;
|
||||||
|
|
||||||
|
/// Sequence discontinuities larger than this (~10s at 20ms/frame) are treated
|
||||||
|
/// as a restarted/new stream, not ordinary packet loss or reordering.
|
||||||
|
const MAX_REASONABLE_SEQ_GAP: u32 = 500;
|
||||||
|
|
||||||
pub struct JitterBuffer {
|
pub struct JitterBuffer {
|
||||||
decoder: OpusDecoder,
|
decoder: OpusDecoder,
|
||||||
/// Reorder window: sequence number -> encoded Opus payload.
|
/// Reorder window: sequence number -> encoded Opus payload.
|
||||||
@@ -116,6 +120,14 @@ impl JitterBuffer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn reset_to_stream(&mut self, seq: u32, payload: Vec<u8>) {
|
||||||
|
self.packets.clear();
|
||||||
|
self.packets.insert(seq, payload);
|
||||||
|
self.next_seq = None;
|
||||||
|
self.clean_run = 0;
|
||||||
|
self.buffering_ticks = 0;
|
||||||
|
}
|
||||||
|
|
||||||
/// Store a received packet, dropping ones we've already played past and
|
/// Store a received packet, dropping ones we've already played past and
|
||||||
/// bounding total depth.
|
/// bounding total depth.
|
||||||
pub fn insert(&mut self, seq: u32, payload: Vec<u8>) {
|
pub fn insert(&mut self, seq: u32, payload: Vec<u8>) {
|
||||||
@@ -124,9 +136,19 @@ impl JitterBuffer {
|
|||||||
if let Some(next) = self.next_seq
|
if let Some(next) = self.next_seq
|
||||||
&& seq_before(seq, next)
|
&& seq_before(seq, next)
|
||||||
{
|
{
|
||||||
|
if next.wrapping_sub(seq) > MAX_REASONABLE_SEQ_GAP {
|
||||||
|
self.reset_to_stream(seq, payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
self.note_disruption();
|
self.note_disruption();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if let Some(next) = self.next_seq
|
||||||
|
&& seq.wrapping_sub(next) > MAX_REASONABLE_SEQ_GAP
|
||||||
|
{
|
||||||
|
self.reset_to_stream(seq, payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
self.packets.insert(seq, payload);
|
self.packets.insert(seq, payload);
|
||||||
|
|
||||||
while self.packets.len() > MAX_BUFFERED_FRAMES {
|
while self.packets.len() > MAX_BUFFERED_FRAMES {
|
||||||
@@ -283,6 +305,44 @@ mod tests {
|
|||||||
assert_eq!(jb.packets.len(), 1); // only seq 7 remains buffered
|
assert_eq!(jb.packets.len(), 1); // only seq 7 remains buffered
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn far_behind_sequence_resets_as_restarted_stream() {
|
||||||
|
let mut jb = JitterBuffer::new().unwrap();
|
||||||
|
jb.next_seq = Some(5_000);
|
||||||
|
jb.packets.insert(5_000, vec![9]);
|
||||||
|
jb.clean_run = 12;
|
||||||
|
jb.buffering_ticks = 4;
|
||||||
|
|
||||||
|
jb.insert(0, vec![1]);
|
||||||
|
|
||||||
|
assert_eq!(jb.next_seq, None);
|
||||||
|
assert_eq!(jb.packets.len(), 1);
|
||||||
|
assert_eq!(jb.packets.get(&0).map(Vec::as_slice), Some(&[1][..]));
|
||||||
|
assert_eq!(jb.clean_run, 0);
|
||||||
|
assert_eq!(jb.buffering_ticks, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn far_ahead_sequence_resets_to_bound_plc_run() {
|
||||||
|
let mut jb = JitterBuffer::new().unwrap();
|
||||||
|
jb.next_seq = Some(10);
|
||||||
|
jb.packets.insert(10, vec![9]);
|
||||||
|
jb.clean_run = 12;
|
||||||
|
jb.buffering_ticks = 4;
|
||||||
|
|
||||||
|
let jumped_seq = 10 + MAX_REASONABLE_SEQ_GAP + 1;
|
||||||
|
jb.insert(jumped_seq, vec![2]);
|
||||||
|
|
||||||
|
assert_eq!(jb.next_seq, None);
|
||||||
|
assert_eq!(jb.packets.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
jb.packets.get(&jumped_seq).map(Vec::as_slice),
|
||||||
|
Some(&[2][..])
|
||||||
|
);
|
||||||
|
assert_eq!(jb.clean_run, 0);
|
||||||
|
assert_eq!(jb.buffering_ticks, 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_seq_before_ordering() {
|
fn test_seq_before_ordering() {
|
||||||
// Basic ordering
|
// Basic ordering
|
||||||
@@ -635,4 +695,3 @@ mod tests {
|
|||||||
assert_eq!(jb.clean_run, 0);
|
assert_eq!(jb.clean_run, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ pub enum CoreCommand {
|
|||||||
/// for a NEW room; it's ignored when joining (the label rides in the ticket).
|
/// for a NEW room; it's ignored when joining (the label rides in the ticket).
|
||||||
Join { name: String, ticket: String, room_name: String, input_device: Option<String>, output_device: Option<String>, echo_cancellation: bool, avatar: crate::avatar::Avatar },
|
Join { name: String, ticket: String, room_name: String, input_device: Option<String>, output_device: Option<String>, echo_cancellation: bool, avatar: crate::avatar::Avatar },
|
||||||
Leave,
|
Leave,
|
||||||
|
/// Orderly app shutdown: finalize recordings, leave any active room, stop local
|
||||||
|
/// audio/screen-share work, close the persistent network stack, then ack with
|
||||||
|
/// [`UiEvent::ShutdownComplete`].
|
||||||
|
Shutdown,
|
||||||
ToggleMute,
|
ToggleMute,
|
||||||
/// Change our avatar (W4) and re-announce it to the room over presence.
|
/// Change our avatar (W4) and re-announce it to the room over presence.
|
||||||
SetAvatar(crate::avatar::Avatar),
|
SetAvatar(crate::avatar::Avatar),
|
||||||
@@ -18,6 +22,14 @@ pub enum CoreCommand {
|
|||||||
SetPttMode(bool),
|
SetPttMode(bool),
|
||||||
SetPttActive(bool),
|
SetPttActive(bool),
|
||||||
SetPeerVolume(EndpointId, f32),
|
SetPeerVolume(EndpointId, f32),
|
||||||
|
/// Listener-side per-peer EQ. Local only; never leaves this app instance.
|
||||||
|
SetPeerEq(EndpointId, crate::audio::eq::EqSettings),
|
||||||
|
/// Listener-side per-peer pan. Local only; never leaves this app instance.
|
||||||
|
SetPeerPan(EndpointId, f32),
|
||||||
|
/// Listener-side per-peer noise gate threshold (normalized RMS, `0.0` = off).
|
||||||
|
/// Applies the same smooth gate as the mic path to a peer's incoming audio,
|
||||||
|
/// to suppress their background noise on our end. Local only.
|
||||||
|
SetPeerGate(EndpointId, f32),
|
||||||
/// Locally mute/unmute a peer: when muted, their audio is decoded (so levels
|
/// Locally mute/unmute a peer: when muted, their audio is decoded (so levels
|
||||||
/// still show) but not mixed into our output.
|
/// still show) but not mixed into our output.
|
||||||
SetPeerMuted(EndpointId, bool),
|
SetPeerMuted(EndpointId, bool),
|
||||||
@@ -41,6 +53,14 @@ pub enum CoreCommand {
|
|||||||
SetRecordingMode(RecordingMode),
|
SetRecordingMode(RecordingMode),
|
||||||
/// Broadcast a room text-chat message. No-op when not in a call.
|
/// Broadcast a room text-chat message. No-op when not in a call.
|
||||||
SendChat(String),
|
SendChat(String),
|
||||||
|
/// Send a chat message carrying a file attachment. The app has already read +
|
||||||
|
/// capped the file and built the descriptor; core makes the bytes available
|
||||||
|
/// on the file plane and broadcasts the descriptor.
|
||||||
|
SendChatFile { text: String, attachment: crate::files::ChatAttachment, data: Vec<u8> },
|
||||||
|
/// Fetch a received attachment's bytes from its sender over the file plane
|
||||||
|
/// (used for on-demand file/chip downloads; images are auto-fetched on
|
||||||
|
/// receipt). Replies with `AttachmentReady`/`AttachmentFailed`.
|
||||||
|
FetchAttachment { from: EndpointId, attachment: crate::files::ChatAttachment },
|
||||||
/// Set the pixelpass binary location (config override, empty = use `$PATH`).
|
/// Set the pixelpass binary location (config override, empty = use `$PATH`).
|
||||||
/// Sent at startup so screen-share can resolve the binary.
|
/// Sent at startup so screen-share can resolve the binary.
|
||||||
SetPixelpassPath(Option<String>),
|
SetPixelpassPath(Option<String>),
|
||||||
@@ -77,6 +97,9 @@ pub enum UiEvent {
|
|||||||
RoomLeft,
|
RoomLeft,
|
||||||
PeerJoined { id: EndpointId, state: PeerState },
|
PeerJoined { id: EndpointId, state: PeerState },
|
||||||
PeerLeft { id: EndpointId },
|
PeerLeft { id: EndpointId },
|
||||||
|
/// The fixed reconnect grace expired and bounded background gossip recovery
|
||||||
|
/// has started. This is non-terminal and must not play the failure chime.
|
||||||
|
PeerRecoveryStarted { id: EndpointId },
|
||||||
PeerConnectionFailed { id: EndpointId },
|
PeerConnectionFailed { id: EndpointId },
|
||||||
PeerUpdated { id: EndpointId, state: PeerState },
|
PeerUpdated { id: EndpointId, state: PeerState },
|
||||||
/// Audio link to a peer is being (re)established — show a connecting state.
|
/// Audio link to a peer is being (re)established — show a connecting state.
|
||||||
@@ -94,7 +117,13 @@ pub enum UiEvent {
|
|||||||
/// A room text-chat message arrived from a peer (never our own — local
|
/// A room text-chat message arrived from a peer (never our own — local
|
||||||
/// messages are echoed by the UI on send). `from` is the sender's node id
|
/// messages are echoed by the UI on send). `from` is the sender's node id
|
||||||
/// string, used to key their avatar (W4).
|
/// string, used to key their avatar (W4).
|
||||||
ChatMessage { from: String, name: String, text: String },
|
ChatMessage { from: String, name: String, text: String, attachment: Option<crate::files::ChatAttachment> },
|
||||||
|
/// An attachment's bytes are now available (auto-fetched for images, or
|
||||||
|
/// fetched on demand for files). Keyed by attachment id so the UI can match
|
||||||
|
/// it to the chat entry.
|
||||||
|
AttachmentReady { id: crate::files::AttachmentId, data: Vec<u8> },
|
||||||
|
/// An attachment fetch failed (sender gone, too large, decode error, etc.).
|
||||||
|
AttachmentFailed { id: crate::files::AttachmentId, error: String },
|
||||||
/// Our own screen share started; the UI flips the Share button to "Stop".
|
/// Our own screen share started; the UI flips the Share button to "Stop".
|
||||||
ScreenShareStarted,
|
ScreenShareStarted,
|
||||||
/// Our own screen share stopped (or failed to start).
|
/// Our own screen share stopped (or failed to start).
|
||||||
@@ -116,11 +145,12 @@ 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.
|
||||||
|
ShutdownComplete,
|
||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
use crate::network::{RoomState, gossip::IrohGossipState};
|
||||||
|
use iroh::{EndpointAddr, EndpointId};
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
use tokio::time::Instant;
|
||||||
|
|
||||||
|
const RECOVERY_COMMAND_CAPACITY: usize = 64;
|
||||||
|
const RECOVERY_DELAYS: [Duration; 7] = [
|
||||||
|
Duration::from_secs(1),
|
||||||
|
Duration::from_secs(2),
|
||||||
|
Duration::from_secs(4),
|
||||||
|
Duration::from_secs(8),
|
||||||
|
Duration::from_secs(15),
|
||||||
|
Duration::from_secs(30),
|
||||||
|
Duration::from_secs(60),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn recovery_delay(attempt: usize) -> Duration {
|
||||||
|
RECOVERY_DELAYS[attempt.min(RECOVERY_DELAYS.len() - 1)]
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RecoveryCommand {
|
||||||
|
Start {
|
||||||
|
peer_id: EndpointId,
|
||||||
|
addr: EndpointAddr,
|
||||||
|
},
|
||||||
|
Cancel(EndpointId),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RecoveryEntry {
|
||||||
|
addr: EndpointAddr,
|
||||||
|
attempt: usize,
|
||||||
|
next_attempt: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
trait RecoveryRoom: Send + Sync {
|
||||||
|
async fn rebootstrap_peers(&self, peers: Vec<EndpointAddr>) -> Result<(), String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl RecoveryRoom for IrohGossipState {
|
||||||
|
async fn rebootstrap_peers(&self, peers: Vec<EndpointAddr>) -> Result<(), String> {
|
||||||
|
RoomState::rebootstrap_peers(self, peers)
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cloneable command side of the single per-session recovery coordinator.
|
||||||
|
/// `active` is shared with transport/event handlers so cancellation is visible
|
||||||
|
/// immediately even while the coordinator is awaiting an in-flight gossip call.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(super) struct RecoveryCoordinator {
|
||||||
|
tx: mpsc::Sender<RecoveryCommand>,
|
||||||
|
active: Arc<Mutex<HashSet<EndpointId>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecoveryCoordinator {
|
||||||
|
pub(super) fn spawn(room_state: Arc<IrohGossipState>) -> (Self, JoinHandle<()>) {
|
||||||
|
Self::spawn_inner(room_state)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_inner(room_state: Arc<dyn RecoveryRoom>) -> (Self, JoinHandle<()>) {
|
||||||
|
let (tx, rx) = mpsc::channel(RECOVERY_COMMAND_CAPACITY);
|
||||||
|
let active = Arc::new(Mutex::new(HashSet::new()));
|
||||||
|
let handle = Self {
|
||||||
|
tx,
|
||||||
|
active: active.clone(),
|
||||||
|
};
|
||||||
|
let task = tokio::spawn(run_coordinator(room_state, active, rx));
|
||||||
|
(handle, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reserve one recovery slot before grace-expiry teardown begins. Returns
|
||||||
|
/// false when the peer is already recovering, preventing duplicate work.
|
||||||
|
pub(super) fn begin(&self, peer_id: EndpointId) -> bool {
|
||||||
|
self.active.lock().unwrap().insert(peer_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Activate the reserved slot with its retained authenticated address.
|
||||||
|
/// Uses a bounded non-blocking send while holding the active-set lock so a
|
||||||
|
/// concurrent cancellation is ordered before or after this command.
|
||||||
|
pub(super) fn activate(&self, peer_id: EndpointId, addr: EndpointAddr) -> Result<bool, ()> {
|
||||||
|
let mut active = self.active.lock().unwrap();
|
||||||
|
if !active.contains(&peer_id) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.tx
|
||||||
|
.try_send(RecoveryCommand::Start { peer_id, addr })
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
active.remove(&peer_id);
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn cancel(&self, peer_id: EndpointId) {
|
||||||
|
self.active.lock().unwrap().remove(&peer_id);
|
||||||
|
// Cancellation is governed by the shared active set, so it remains
|
||||||
|
// immediate even if the bounded command queue is temporarily full.
|
||||||
|
let _ = self.tx.try_send(RecoveryCommand::Cancel(peer_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn is_active(&self, peer_id: &EndpointId) -> bool {
|
||||||
|
self.active.lock().unwrap().contains(peer_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_coordinator(
|
||||||
|
room_state: Arc<dyn RecoveryRoom>,
|
||||||
|
active: Arc<Mutex<HashSet<EndpointId>>>,
|
||||||
|
mut rx: mpsc::Receiver<RecoveryCommand>,
|
||||||
|
) {
|
||||||
|
let mut entries: HashMap<EndpointId, RecoveryEntry> = HashMap::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// The shared active set is the authoritative cancellation gate. Prune
|
||||||
|
// here as well as on Cancel commands so a saturated command queue cannot
|
||||||
|
// leave an inactive, past-due entry spinning the timer loop.
|
||||||
|
let active_snapshot = active.lock().unwrap().clone();
|
||||||
|
entries.retain(|peer_id, _| active_snapshot.contains(peer_id));
|
||||||
|
let next_deadline = entries.values().map(|entry| entry.next_attempt).min();
|
||||||
|
let command = match next_deadline {
|
||||||
|
Some(deadline) => {
|
||||||
|
tokio::select! {
|
||||||
|
command = rx.recv() => command,
|
||||||
|
_ = tokio::time::sleep_until(deadline) => {
|
||||||
|
let now = Instant::now();
|
||||||
|
let active_snapshot = active.lock().unwrap().clone();
|
||||||
|
let due: Vec<(EndpointId, EndpointAddr)> = entries
|
||||||
|
.iter()
|
||||||
|
.filter(|(id, entry)| {
|
||||||
|
entry.next_attempt <= now && active_snapshot.contains(*id)
|
||||||
|
})
|
||||||
|
.map(|(id, entry)| (*id, entry.addr.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !due.is_empty() {
|
||||||
|
let addrs = due.iter().map(|(_, addr)| addr.clone()).collect();
|
||||||
|
if let Err(error) = room_state.rebootstrap_peers(addrs).await {
|
||||||
|
crate::log_msg(&format!(
|
||||||
|
"Background peer recovery attempt failed: {error}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let scheduled_at = Instant::now();
|
||||||
|
for (peer_id, _) in due {
|
||||||
|
if !active.lock().unwrap().contains(&peer_id) {
|
||||||
|
entries.remove(&peer_id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(entry) = entries.get_mut(&peer_id) {
|
||||||
|
entry.next_attempt = scheduled_at + recovery_delay(entry.attempt);
|
||||||
|
entry.attempt = entry.attempt.saturating_add(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => rx.recv().await,
|
||||||
|
};
|
||||||
|
|
||||||
|
match command {
|
||||||
|
Some(RecoveryCommand::Start { peer_id, addr }) => {
|
||||||
|
if active.lock().unwrap().contains(&peer_id) {
|
||||||
|
entries.entry(peer_id).or_insert(RecoveryEntry {
|
||||||
|
addr,
|
||||||
|
attempt: 0,
|
||||||
|
next_attempt: Instant::now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(RecoveryCommand::Cancel(peer_id)) => {
|
||||||
|
entries.remove(&peer_id);
|
||||||
|
}
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use iroh::SecretKey;
|
||||||
|
|
||||||
|
struct RecordingRoom {
|
||||||
|
attempts: mpsc::UnboundedSender<Vec<EndpointAddr>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl RecoveryRoom for RecordingRoom {
|
||||||
|
async fn rebootstrap_peers(&self, peers: Vec<EndpointAddr>) -> Result<(), String> {
|
||||||
|
self.attempts.send(peers).map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retry_backoff_reaches_and_stays_at_sixty_seconds() {
|
||||||
|
let actual: Vec<u64> = (0..10)
|
||||||
|
.map(|attempt| recovery_delay(attempt).as_secs())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(actual, vec![1, 2, 4, 8, 15, 30, 60, 60, 60, 60]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovery_slots_are_deduplicated_and_cancel_immediately() {
|
||||||
|
let (tx, mut rx) = mpsc::channel(4);
|
||||||
|
let coordinator = RecoveryCoordinator {
|
||||||
|
tx,
|
||||||
|
active: Arc::new(Mutex::new(HashSet::new())),
|
||||||
|
};
|
||||||
|
let peer_id = SecretKey::generate().public();
|
||||||
|
|
||||||
|
assert!(coordinator.begin(peer_id));
|
||||||
|
assert!(
|
||||||
|
!coordinator.begin(peer_id),
|
||||||
|
"a peer gets only one recovery slot"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
coordinator.activate(peer_id, EndpointAddr::from(peer_id)),
|
||||||
|
Ok(true)
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
rx.try_recv(),
|
||||||
|
Ok(RecoveryCommand::Start { peer_id: id, .. }) if id == peer_id
|
||||||
|
));
|
||||||
|
|
||||||
|
coordinator.cancel(peer_id);
|
||||||
|
assert!(!coordinator.is_active(&peer_id));
|
||||||
|
assert!(matches!(
|
||||||
|
rx.try_recv(),
|
||||||
|
Ok(RecoveryCommand::Cancel(id)) if id == peer_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn coordinator_attempts_rebootstrap_immediately() {
|
||||||
|
let (attempts_tx, mut attempts_rx) = mpsc::unbounded_channel();
|
||||||
|
let (coordinator, task) = RecoveryCoordinator::spawn_inner(Arc::new(RecordingRoom {
|
||||||
|
attempts: attempts_tx,
|
||||||
|
}));
|
||||||
|
let peer_id = SecretKey::generate().public();
|
||||||
|
let addr = EndpointAddr::from(peer_id);
|
||||||
|
|
||||||
|
assert!(coordinator.begin(peer_id));
|
||||||
|
assert_eq!(coordinator.activate(peer_id, addr.clone()), Ok(true));
|
||||||
|
let attempted = tokio::time::timeout(Duration::from_secs(1), attempts_rx.recv())
|
||||||
|
.await
|
||||||
|
.expect("first recovery attempt should be immediate")
|
||||||
|
.expect("recording room remains subscribed");
|
||||||
|
assert_eq!(attempted, vec![addr]);
|
||||||
|
|
||||||
|
coordinator.cancel(peer_id);
|
||||||
|
task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,13 +51,44 @@ 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}."
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,368 @@
|
|||||||
|
//! Chat file attachments: the compact descriptor that rides a gossip chat
|
||||||
|
//! message, plus the pure validation/sanitization seams for the file-transfer
|
||||||
|
//! plane.
|
||||||
|
//!
|
||||||
|
//! Attachment **bytes do not travel over gossip** — gossip is a small-frame
|
||||||
|
//! broadcast plane (see `avatar` for why image bytes there are hard-capped to
|
||||||
|
//! tens of KB). Instead a chat message carries a [`ChatAttachment`] *descriptor*
|
||||||
|
//! (name, size, kind, id); the sender serves the actual bytes over the dedicated
|
||||||
|
//! file ALPN (`protocol::FILES_ALPN`) via direct QUIC streams, and recipients
|
||||||
|
//! fetch them point-to-point. Everything in this module is dependency-light and
|
||||||
|
//! pure so it can be unit-tested away from the network and the GUI.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Hard ceiling on a single attachment's byte size. Bounds the memory a peer can
|
||||||
|
/// make us hold (when fetching) or serve, and the time a transfer can take.
|
||||||
|
/// 25 MiB comfortably covers phone photos and ordinary documents.
|
||||||
|
pub const MAX_ATTACHMENT_BYTES: u64 = 25 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Max decoded pixels per side for an inline image preview. Defends against a
|
||||||
|
/// decode-bomb (a small file that expands to an enormous bitmap), independent of
|
||||||
|
/// the byte cap. Applied via `image::Limits` when validating/decoding.
|
||||||
|
pub const MAX_IMAGE_PX: u32 = 4096;
|
||||||
|
|
||||||
|
/// Longest filename we keep and display. Keeps the gossip descriptor compact and
|
||||||
|
/// the UI tidy; the real bytes are unaffected.
|
||||||
|
pub const MAX_FILENAME_LEN: usize = 96;
|
||||||
|
|
||||||
|
/// A 32-byte opaque id identifying one attachment for the fetch request. Minted
|
||||||
|
/// randomly per attachment by the sender (see core); the transfer itself is
|
||||||
|
/// authenticated + encrypted + room-member gated, so the id only needs to be a
|
||||||
|
/// hard-to-guess handle into the sender's serve store, not a content hash.
|
||||||
|
pub type AttachmentId = [u8; 32];
|
||||||
|
|
||||||
|
/// How the receiver should present an attachment. A *hint* derived from the
|
||||||
|
/// sender's content sniff — never trusted for a safety decision. The receiver
|
||||||
|
/// re-validates image bytes itself before decoding, and falls back to a file
|
||||||
|
/// chip if an "Image" doesn't actually decode.
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
pub enum AttachmentKind {
|
||||||
|
Image,
|
||||||
|
File,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The descriptor carried inside a `GossipMessage::Chat`. Compact by design: it
|
||||||
|
/// holds no file bytes, only what the UI needs to render a placeholder/chip and
|
||||||
|
/// what a fetch needs to pull the bytes.
|
||||||
|
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
|
||||||
|
pub struct ChatAttachment {
|
||||||
|
/// Sanitized display filename (already path-stripped — see
|
||||||
|
/// [`sanitize_filename`]). Never used as a filesystem path on receipt without
|
||||||
|
/// the user choosing a save location.
|
||||||
|
pub name: String,
|
||||||
|
/// Byte length of the file. Bounds the fetch read; must be
|
||||||
|
/// `<= MAX_ATTACHMENT_BYTES` (enforced by [`size_within_cap`]).
|
||||||
|
pub size: u64,
|
||||||
|
/// Presentation hint (image vs. generic file).
|
||||||
|
pub kind: AttachmentKind,
|
||||||
|
/// Opaque handle the receiver writes on the file plane to request the bytes.
|
||||||
|
pub id: AttachmentId,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sanitize an arbitrary (possibly hostile) filename for display and as a
|
||||||
|
/// save-dialog default. Strips any directory component (both `/` and `\`),
|
||||||
|
/// removes control characters, collapses whitespace, trims, caps the length
|
||||||
|
/// while trying to preserve a short extension, and rejects the `.`/`..` traps.
|
||||||
|
/// Always returns a non-empty, path-component-free name (falls back to `file`).
|
||||||
|
pub fn sanitize_filename(raw: &str) -> String {
|
||||||
|
// Take only the final *non-empty* path component, defeating
|
||||||
|
// `../../etc/passwd`, `C:\foo\bar`, embedded separators, and trailing slashes
|
||||||
|
// (`a/b/c/` → `c`).
|
||||||
|
let base = raw
|
||||||
|
.rsplit(['/', '\\'])
|
||||||
|
.find(|s| !s.trim().is_empty())
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// Drop control chars; turn other whitespace into single spaces later.
|
||||||
|
let cleaned: String = base
|
||||||
|
.chars()
|
||||||
|
.filter(|c| !c.is_control())
|
||||||
|
.collect();
|
||||||
|
let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
|
let collapsed = collapsed.trim_matches('.').trim();
|
||||||
|
|
||||||
|
if collapsed.is_empty() {
|
||||||
|
return "file".to_string();
|
||||||
|
}
|
||||||
|
if collapsed.chars().count() <= MAX_FILENAME_LEN {
|
||||||
|
return collapsed.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Too long: keep the extension (if short + sane) and truncate the stem.
|
||||||
|
if let Some((stem, ext)) = collapsed.rsplit_once('.')
|
||||||
|
&& !ext.is_empty()
|
||||||
|
&& ext.chars().count() <= 8
|
||||||
|
&& ext.chars().all(|c| c.is_ascii_alphanumeric())
|
||||||
|
{
|
||||||
|
let keep = MAX_FILENAME_LEN.saturating_sub(ext.chars().count() + 1);
|
||||||
|
let truncated: String = stem.chars().take(keep).collect();
|
||||||
|
return format!("{truncated}.{ext}");
|
||||||
|
}
|
||||||
|
collapsed.chars().take(MAX_FILENAME_LEN).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a declared/observed size is within the transfer cap and non-zero.
|
||||||
|
/// Used both when sending (reject before serving) and when fetching (reject a
|
||||||
|
/// descriptor before opening a stream).
|
||||||
|
pub fn size_within_cap(size: u64) -> bool {
|
||||||
|
size > 0 && size <= MAX_ATTACHMENT_BYTES
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sniff the leading bytes for a known image container, to set the attachment
|
||||||
|
/// *kind* hint at send time. Recognizes PNG, JPEG, GIF, WebP, and BMP. This is a
|
||||||
|
/// presentation hint only — actual inline rendering still depends on the bytes
|
||||||
|
/// decoding (we only build image features for PNG/JPEG), with a file-chip
|
||||||
|
/// fallback otherwise.
|
||||||
|
pub fn is_probably_image(bytes: &[u8]) -> bool {
|
||||||
|
let b = bytes;
|
||||||
|
let png = b.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]);
|
||||||
|
let jpeg = b.starts_with(&[0xFF, 0xD8, 0xFF]);
|
||||||
|
let gif = b.starts_with(b"GIF87a") || b.starts_with(b"GIF89a");
|
||||||
|
let bmp = b.starts_with(b"BM");
|
||||||
|
let webp = b.len() >= 12 && b.starts_with(b"RIFF") && &b[8..12] == b"WEBP";
|
||||||
|
png || jpeg || gif || bmp || webp
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sniff the leading bytes for an audio container supported by the inline clip
|
||||||
|
/// player. Audio remains [`AttachmentKind::File`] on the wire; this receiver-side
|
||||||
|
/// check confirms that a filename-based player hint actually contains WAV, MP3,
|
||||||
|
/// Ogg Vorbis, or FLAC data before playback is attempted.
|
||||||
|
pub fn is_probably_audio(bytes: &[u8]) -> bool {
|
||||||
|
let flac = bytes.starts_with(b"fLaC");
|
||||||
|
let ogg = bytes.starts_with(b"OggS");
|
||||||
|
let wav = bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WAVE";
|
||||||
|
let mp3_id3 = bytes.starts_with(b"ID3");
|
||||||
|
let mp3_frame = bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] & 0xE0 == 0xE0;
|
||||||
|
flac || ogg || wav || mp3_id3 || mp3_frame
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a sanitized attachment name has an extension supported by the
|
||||||
|
/// inline audio player. This is only a pre-fetch presentation hint; fetched
|
||||||
|
/// bytes are confirmed with [`is_probably_audio`] before being decoded.
|
||||||
|
pub fn looks_like_audio_name(name: &str) -> bool {
|
||||||
|
let Some((_, extension)) = name.rsplit_once('.') else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
matches!(extension.to_ascii_lowercase().as_str(), "wav" | "mp3" | "ogg" | "oga" | "flac")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The attachment kind for some file bytes: [`AttachmentKind::Image`] if it
|
||||||
|
/// sniffs as an image container, else [`AttachmentKind::File`].
|
||||||
|
pub fn classify(bytes: &[u8]) -> AttachmentKind {
|
||||||
|
if is_probably_image(bytes) {
|
||||||
|
AttachmentKind::Image
|
||||||
|
} else {
|
||||||
|
AttachmentKind::File
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Defensively decode image bytes under strict pixel limits to confirm they're a
|
||||||
|
/// real, sane image before we hand them to the renderer. Returns the decoded
|
||||||
|
/// dimensions on success. Guards against decode-bombs (small file → huge bitmap)
|
||||||
|
/// regardless of [`MAX_ATTACHMENT_BYTES`]. Only PNG/JPEG are buildable in our
|
||||||
|
/// `image` feature set; anything else returns `None` and the caller shows a chip.
|
||||||
|
pub fn validate_image_bytes(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||||
|
let mut limits = image::Limits::default();
|
||||||
|
limits.max_image_width = Some(MAX_IMAGE_PX);
|
||||||
|
limits.max_image_height = Some(MAX_IMAGE_PX);
|
||||||
|
let reader = image::ImageReader::new(std::io::Cursor::new(bytes))
|
||||||
|
.with_guessed_format()
|
||||||
|
.ok()?;
|
||||||
|
let mut reader = reader;
|
||||||
|
reader.limits(limits);
|
||||||
|
let img = reader.decode().ok()?;
|
||||||
|
let (w, h) = (img.width(), img.height());
|
||||||
|
if w == 0 || h == 0 || w > MAX_IMAGE_PX || h > MAX_IMAGE_PX {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((w, h))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a file-plane request: it must be exactly one [`AttachmentId`] (32
|
||||||
|
/// bytes). Anything else is rejected so a peer can't send a malformed/oversized
|
||||||
|
/// request frame. Pure half of the serve handler.
|
||||||
|
pub fn parse_request(bytes: &[u8]) -> Option<AttachmentId> {
|
||||||
|
if bytes.len() != 32 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut id = [0u8; 32];
|
||||||
|
id.copy_from_slice(bytes);
|
||||||
|
Some(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A human-readable size like `2.3 MB` / `812 KB` / `40 B` for the file chip.
|
||||||
|
pub fn human_size(bytes: u64) -> String {
|
||||||
|
const KB: u64 = 1024;
|
||||||
|
const MB: u64 = 1024 * KB;
|
||||||
|
if bytes >= MB {
|
||||||
|
format!("{:.1} MB", bytes as f64 / MB as f64)
|
||||||
|
} else if bytes >= KB {
|
||||||
|
format!("{:.0} KB", bytes as f64 / KB as f64)
|
||||||
|
} else {
|
||||||
|
format!("{bytes} B")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_strips_directory_traversal() {
|
||||||
|
assert_eq!(sanitize_filename("../../etc/passwd"), "passwd");
|
||||||
|
assert_eq!(sanitize_filename("/abs/path/photo.png"), "photo.png");
|
||||||
|
assert_eq!(sanitize_filename(r"C:\Users\me\secret.doc"), "secret.doc");
|
||||||
|
assert_eq!(sanitize_filename("a/b/c/"), "c");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_rejects_dot_traps_and_empty() {
|
||||||
|
assert_eq!(sanitize_filename(""), "file");
|
||||||
|
assert_eq!(sanitize_filename("."), "file");
|
||||||
|
assert_eq!(sanitize_filename(".."), "file");
|
||||||
|
assert_eq!(sanitize_filename(" "), "file");
|
||||||
|
assert_eq!(sanitize_filename("/"), "file");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_removes_control_chars_and_collapses_ws() {
|
||||||
|
// Control chars (incl. tab/newline) are stripped entirely.
|
||||||
|
assert_eq!(sanitize_filename("my\tphoto\n.png"), "myphoto.png");
|
||||||
|
assert_eq!(sanitize_filename("a\u{0000}b.txt"), "ab.txt");
|
||||||
|
// Real spaces are collapsed but preserved.
|
||||||
|
assert_eq!(sanitize_filename("my photo .png"), "my photo .png");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_caps_length_preserving_extension() {
|
||||||
|
let long_stem = "x".repeat(200);
|
||||||
|
let name = format!("{long_stem}.png");
|
||||||
|
let out = sanitize_filename(&name);
|
||||||
|
assert!(out.chars().count() <= MAX_FILENAME_LEN, "len was {}", out.chars().count());
|
||||||
|
assert!(out.ends_with(".png"), "extension preserved: {out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn size_cap_bounds() {
|
||||||
|
assert!(!size_within_cap(0));
|
||||||
|
assert!(size_within_cap(1));
|
||||||
|
assert!(size_within_cap(MAX_ATTACHMENT_BYTES));
|
||||||
|
assert!(!size_within_cap(MAX_ATTACHMENT_BYTES + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn image_sniffing_recognizes_containers() {
|
||||||
|
assert!(is_probably_image(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0]));
|
||||||
|
assert!(is_probably_image(&[0xFF, 0xD8, 0xFF, 0xE0]));
|
||||||
|
assert!(is_probably_image(b"GIF89a...."));
|
||||||
|
let mut webp = b"RIFF".to_vec();
|
||||||
|
webp.extend_from_slice(&[0, 0, 0, 0]);
|
||||||
|
webp.extend_from_slice(b"WEBP");
|
||||||
|
assert!(is_probably_image(&webp));
|
||||||
|
assert!(!is_probably_image(b"%PDF-1.7"));
|
||||||
|
assert!(!is_probably_image(b""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audio_sniffing_recognizes_supported_containers() {
|
||||||
|
assert!(is_probably_audio(b"fLaC\0\0\0\x22"));
|
||||||
|
assert!(is_probably_audio(b"OggS\0\x02"));
|
||||||
|
|
||||||
|
let mut wav = b"RIFF".to_vec();
|
||||||
|
wav.extend_from_slice(&[0, 0, 0, 0]);
|
||||||
|
wav.extend_from_slice(b"WAVE");
|
||||||
|
assert!(is_probably_audio(&wav));
|
||||||
|
|
||||||
|
assert!(is_probably_audio(b"ID3\x04\0\0"));
|
||||||
|
assert!(is_probably_audio(&[0xFF, 0xFB, 0x90, 0x64]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audio_sniffing_disambiguates_wav_from_webp() {
|
||||||
|
let mut wav = b"RIFF".to_vec();
|
||||||
|
wav.extend_from_slice(&[0, 0, 0, 0]);
|
||||||
|
wav.extend_from_slice(b"WAVE");
|
||||||
|
assert!(is_probably_audio(&wav));
|
||||||
|
assert!(!is_probably_image(&wav));
|
||||||
|
|
||||||
|
let mut webp = b"RIFF".to_vec();
|
||||||
|
webp.extend_from_slice(&[0, 0, 0, 0]);
|
||||||
|
webp.extend_from_slice(b"WEBP");
|
||||||
|
assert!(is_probably_image(&webp));
|
||||||
|
assert!(!is_probably_audio(&webp));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audio_sniffing_rejects_non_audio() {
|
||||||
|
assert!(!is_probably_audio(b"%PDF-1.7"));
|
||||||
|
assert!(!is_probably_audio(&[0x89, b'P', b'N', b'G']));
|
||||||
|
assert!(!is_probably_audio(&[]));
|
||||||
|
assert!(!is_probably_audio(&[0xFF]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audio_name_detection_is_case_insensitive() {
|
||||||
|
for name in ["clip.wav", "clip.mp3", "clip.ogg", "clip.oga", "clip.flac"] {
|
||||||
|
assert!(looks_like_audio_name(name), "{name}");
|
||||||
|
}
|
||||||
|
assert!(looks_like_audio_name("VOICE.MP3"));
|
||||||
|
assert!(looks_like_audio_name("mix.FlAc"));
|
||||||
|
assert!(!looks_like_audio_name("recording"));
|
||||||
|
assert!(!looks_like_audio_name("notes.pdf"));
|
||||||
|
assert!(!looks_like_audio_name("photo.webp"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classify_maps_sniff_to_kind() {
|
||||||
|
assert_eq!(classify(&[0xFF, 0xD8, 0xFF]), AttachmentKind::Image);
|
||||||
|
assert_eq!(classify(b"fLaC\0\0\0\x22"), AttachmentKind::File);
|
||||||
|
assert_eq!(classify(b"plain text"), AttachmentKind::File);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_request_requires_exact_32_bytes() {
|
||||||
|
assert_eq!(parse_request(&[7u8; 32]), Some([7u8; 32]));
|
||||||
|
assert_eq!(parse_request(&[7u8; 31]), None);
|
||||||
|
assert_eq!(parse_request(&[7u8; 33]), None);
|
||||||
|
assert_eq!(parse_request(&[]), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_image_rejects_garbage() {
|
||||||
|
assert_eq!(validate_image_bytes(b"not an image"), None);
|
||||||
|
assert_eq!(validate_image_bytes(&[]), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_image_accepts_a_real_png() {
|
||||||
|
// Encode a tiny PNG in-memory, then validate it.
|
||||||
|
let img = image::RgbImage::from_pixel(4, 3, image::Rgb([10, 20, 30]));
|
||||||
|
let mut buf = std::io::Cursor::new(Vec::new());
|
||||||
|
image::DynamicImage::ImageRgb8(img)
|
||||||
|
.write_to(&mut buf, image::ImageFormat::Png)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(validate_image_bytes(&buf.into_inner()), Some((4, 3)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn human_size_units() {
|
||||||
|
assert_eq!(human_size(40), "40 B");
|
||||||
|
assert_eq!(human_size(2048), "2 KB");
|
||||||
|
assert_eq!(human_size(3 * 1024 * 1024 + 300 * 1024), "3.3 MB");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachment_descriptor_round_trips_json() {
|
||||||
|
let a = ChatAttachment {
|
||||||
|
name: "photo.png".to_string(),
|
||||||
|
size: 12345,
|
||||||
|
kind: AttachmentKind::Image,
|
||||||
|
id: [9u8; 32],
|
||||||
|
};
|
||||||
|
let bytes = serde_json::to_vec(&a).unwrap();
|
||||||
|
let back: ChatAttachment = serde_json::from_slice(&bytes).unwrap();
|
||||||
|
assert_eq!(a, back);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
//! Focused, app-local keyboard shortcuts.
|
||||||
|
//!
|
||||||
|
//! These helpers are intentionally pure: key serialization, formatting, lookup,
|
||||||
|
//! and conflict detection live here, while iced event handling stays at the app
|
||||||
|
//! edge. There are no OS-global shortcuts.
|
||||||
|
|
||||||
|
use iced::keyboard;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// A serializable key identity. Modifiers are deliberately out of scope for this
|
||||||
|
/// first pass; iced delivers the focused app key and we compare that exact key.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub enum KeyBinding {
|
||||||
|
Named(String),
|
||||||
|
Character(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeyBinding {
|
||||||
|
pub fn from_key(key: &keyboard::Key) -> Option<Self> {
|
||||||
|
match key {
|
||||||
|
keyboard::Key::Named(named) => Some(Self::Named(format!("{named:?}"))),
|
||||||
|
keyboard::Key::Character(ch) => {
|
||||||
|
let s = ch.to_string();
|
||||||
|
if s.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(Self::Character(s.to_lowercase()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keyboard::Key::Unidentified => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label(&self) -> String {
|
||||||
|
match self {
|
||||||
|
KeyBinding::Named(name) => name.clone(),
|
||||||
|
KeyBinding::Character(ch) => ch.to_uppercase(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a hand-editable binding string from config/docs/tests. Empty and
|
||||||
|
/// `"unset"` are unbound.
|
||||||
|
pub fn parse_binding(input: &str) -> Option<KeyBinding> {
|
||||||
|
let trimmed = input.trim();
|
||||||
|
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("unset") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if trimmed.chars().count() == 1 {
|
||||||
|
Some(KeyBinding::Character(trimmed.to_lowercase()))
|
||||||
|
} else {
|
||||||
|
Some(KeyBinding::Named(trimmed.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_binding(binding: Option<&KeyBinding>) -> String {
|
||||||
|
binding
|
||||||
|
.map(KeyBinding::label)
|
||||||
|
.unwrap_or_else(|| "unset".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub enum HotkeyAction {
|
||||||
|
ToggleMute,
|
||||||
|
ToggleDeafen,
|
||||||
|
OpenSettings,
|
||||||
|
PushToTalk,
|
||||||
|
LeaveRoom,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HotkeyAction {
|
||||||
|
pub const ALL: [HotkeyAction; 5] = [
|
||||||
|
HotkeyAction::ToggleMute,
|
||||||
|
HotkeyAction::ToggleDeafen,
|
||||||
|
HotkeyAction::OpenSettings,
|
||||||
|
HotkeyAction::PushToTalk,
|
||||||
|
HotkeyAction::LeaveRoom,
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
HotkeyAction::ToggleMute => "Toggle mute",
|
||||||
|
HotkeyAction::ToggleDeafen => "Toggle deafen",
|
||||||
|
HotkeyAction::OpenSettings => "Open Settings",
|
||||||
|
HotkeyAction::PushToTalk => "Push-to-talk",
|
||||||
|
HotkeyAction::LeaveRoom => "Leave room",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tier(self) -> HotkeyTier {
|
||||||
|
match self {
|
||||||
|
HotkeyAction::ToggleMute
|
||||||
|
| HotkeyAction::ToggleDeafen
|
||||||
|
| HotkeyAction::OpenSettings => HotkeyTier::AppWide,
|
||||||
|
HotkeyAction::PushToTalk | HotkeyAction::LeaveRoom => HotkeyTier::RoomOnly,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum HotkeyTier {
|
||||||
|
AppWide,
|
||||||
|
RoomOnly,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct HotkeyContext {
|
||||||
|
pub in_call: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HotkeyContext {
|
||||||
|
fn allows(self, action: HotkeyAction) -> bool {
|
||||||
|
matches!(action.tier(), HotkeyTier::AppWide) || self.in_call
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persisted shortcut map. Defaults preserve the old Space push-to-talk binding
|
||||||
|
/// and add a few function-key app shortcuts that do not collide with typing.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct HotkeyMap {
|
||||||
|
#[serde(default = "default_mute")]
|
||||||
|
pub toggle_mute: Option<KeyBinding>,
|
||||||
|
#[serde(default = "default_deafen")]
|
||||||
|
pub toggle_deafen: Option<KeyBinding>,
|
||||||
|
#[serde(default = "default_settings")]
|
||||||
|
pub open_settings: Option<KeyBinding>,
|
||||||
|
#[serde(default = "default_ptt")]
|
||||||
|
pub push_to_talk: Option<KeyBinding>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub leave_room: Option<KeyBinding>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for HotkeyMap {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
toggle_mute: default_mute(),
|
||||||
|
toggle_deafen: default_deafen(),
|
||||||
|
open_settings: default_settings(),
|
||||||
|
push_to_talk: default_ptt(),
|
||||||
|
leave_room: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn named(name: &str) -> Option<KeyBinding> {
|
||||||
|
Some(KeyBinding::Named(name.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_mute() -> Option<KeyBinding> {
|
||||||
|
named("F9")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_deafen() -> Option<KeyBinding> {
|
||||||
|
named("F10")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_settings() -> Option<KeyBinding> {
|
||||||
|
named("F2")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_ptt() -> Option<KeyBinding> {
|
||||||
|
named("Space")
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HotkeyMap {
|
||||||
|
pub fn binding(&self, action: HotkeyAction) -> Option<&KeyBinding> {
|
||||||
|
match action {
|
||||||
|
HotkeyAction::ToggleMute => self.toggle_mute.as_ref(),
|
||||||
|
HotkeyAction::ToggleDeafen => self.toggle_deafen.as_ref(),
|
||||||
|
HotkeyAction::OpenSettings => self.open_settings.as_ref(),
|
||||||
|
HotkeyAction::PushToTalk => self.push_to_talk.as_ref(),
|
||||||
|
HotkeyAction::LeaveRoom => self.leave_room.as_ref(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_binding(&mut self, action: HotkeyAction, binding: Option<KeyBinding>) {
|
||||||
|
match action {
|
||||||
|
HotkeyAction::ToggleMute => self.toggle_mute = binding,
|
||||||
|
HotkeyAction::ToggleDeafen => self.toggle_deafen = binding,
|
||||||
|
HotkeyAction::OpenSettings => self.open_settings = binding,
|
||||||
|
HotkeyAction::PushToTalk => self.push_to_talk = binding,
|
||||||
|
HotkeyAction::LeaveRoom => self.leave_room = binding,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn lookup_key(&self, key: &keyboard::Key, context: HotkeyContext) -> Option<HotkeyAction> {
|
||||||
|
let pressed = KeyBinding::from_key(key)?;
|
||||||
|
HotkeyAction::ALL
|
||||||
|
.into_iter()
|
||||||
|
.find(|&action| context.allows(action) && self.binding(action) == Some(&pressed))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn lookup_binding(
|
||||||
|
&self,
|
||||||
|
binding: &KeyBinding,
|
||||||
|
context: HotkeyContext,
|
||||||
|
) -> Option<HotkeyAction> {
|
||||||
|
HotkeyAction::ALL
|
||||||
|
.into_iter()
|
||||||
|
.find(|&action| context.allows(action) && self.binding(action) == Some(binding))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn conflicts(&self) -> Vec<HotkeyConflict> {
|
||||||
|
let mut conflicts = Vec::new();
|
||||||
|
let actions = HotkeyAction::ALL;
|
||||||
|
for i in 0..actions.len() {
|
||||||
|
for j in (i + 1)..actions.len() {
|
||||||
|
let a = actions[i];
|
||||||
|
let b = actions[j];
|
||||||
|
if let (Some(ab), Some(bb)) = (self.binding(a), self.binding(b))
|
||||||
|
&& ab == bb
|
||||||
|
{
|
||||||
|
conflicts.push(HotkeyConflict {
|
||||||
|
binding: ab.clone(),
|
||||||
|
first: a,
|
||||||
|
second: b,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
conflicts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct HotkeyConflict {
|
||||||
|
pub binding: KeyBinding,
|
||||||
|
pub first: HotkeyAction,
|
||||||
|
pub second: HotkeyAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unset_actions_format_as_unset() {
|
||||||
|
assert_eq!(format_binding(None), "unset");
|
||||||
|
assert_eq!(parse_binding("unset"), None);
|
||||||
|
assert_eq!(parse_binding(""), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_binding_is_detected() {
|
||||||
|
let mut map = HotkeyMap::default();
|
||||||
|
map.set_binding(HotkeyAction::ToggleMute, parse_binding("M"));
|
||||||
|
map.set_binding(HotkeyAction::ToggleDeafen, parse_binding("m"));
|
||||||
|
let conflicts = map.conflicts();
|
||||||
|
assert_eq!(conflicts.len(), 1);
|
||||||
|
assert_eq!(conflicts[0].first, HotkeyAction::ToggleMute);
|
||||||
|
assert_eq!(conflicts[0].second, HotkeyAction::ToggleDeafen);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lookup_respects_room_tier() {
|
||||||
|
let mut map = HotkeyMap::default();
|
||||||
|
map.set_binding(HotkeyAction::LeaveRoom, parse_binding("Escape"));
|
||||||
|
let binding = parse_binding("Escape").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
map.lookup_binding(&binding, HotkeyContext { in_call: false }),
|
||||||
|
None,
|
||||||
|
"room-only shortcuts should not fire outside a call"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
map.lookup_binding(&binding, HotkeyContext { in_call: true }),
|
||||||
|
Some(HotkeyAction::LeaveRoom)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_ptt_is_space() {
|
||||||
|
let map = HotkeyMap::default();
|
||||||
|
assert_eq!(
|
||||||
|
format_binding(map.binding(HotkeyAction::PushToTalk)),
|
||||||
|
"Space"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_single_character_case_folds() {
|
||||||
|
assert_eq!(parse_binding("M"), Some(KeyBinding::Character("m".to_string())));
|
||||||
|
assert_eq!(format_binding(parse_binding("m").as_ref()), "M");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -14,12 +15,22 @@ pub mod notify;
|
|||||||
pub mod screenshare;
|
pub mod screenshare;
|
||||||
pub mod sanitize;
|
pub mod sanitize;
|
||||||
pub mod avatar;
|
pub mod avatar;
|
||||||
|
pub mod background;
|
||||||
pub mod recents;
|
pub mod recents;
|
||||||
pub mod discovery;
|
pub mod discovery;
|
||||||
|
pub mod hotkeys;
|
||||||
|
pub mod files;
|
||||||
|
|
||||||
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.
|
||||||
@@ -42,6 +53,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
|
||||||
@@ -51,13 +130,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
// On Windows, suppress the extra console window for release GUI builds while
|
||||||
|
// keeping it in debug builds so stderr/panics stay visible during development.
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
if let Err(e) = peerspeak::app::run_gui() {
|
if let Err(e) = peerspeak::app::run_gui() {
|
||||||
eprintln!("Error running GUI: {:?}", e);
|
eprintln!("Error running GUI: {:?}", e);
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ use iroh_gossip::proto::TopicId;
|
|||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio::sync::mpsc::Receiver;
|
use tokio::sync::mpsc::Receiver;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use tokio_stream::StreamExt;
|
use tokio_stream::StreamExt;
|
||||||
use serde::{Serialize, Deserialize};
|
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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,9 +184,16 @@ fn compute_bootstrap(
|
|||||||
pub enum GossipMessage {
|
pub enum GossipMessage {
|
||||||
Announce(PeerState),
|
Announce(PeerState),
|
||||||
Leave,
|
Leave,
|
||||||
/// A room text-chat message: the author's display name, the text, and a
|
/// A room text-chat message: the author's display name, the text, a
|
||||||
/// sender-stamped millisecond timestamp.
|
/// sender-stamped millisecond timestamp, and an optional file attachment
|
||||||
Chat { name: String, text: String, ts: u64 },
|
/// descriptor (the bytes are fetched off-gossip on the file plane).
|
||||||
|
Chat {
|
||||||
|
name: String,
|
||||||
|
text: String,
|
||||||
|
ts: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
attachment: Option<crate::files::ChatAttachment>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct IrohGossipState {
|
pub struct IrohGossipState {
|
||||||
@@ -142,6 +205,10 @@ pub struct IrohGossipState {
|
|||||||
secret_key: SecretKey,
|
secret_key: SecretKey,
|
||||||
self_state: Arc<Mutex<Option<PeerState>>>,
|
self_state: Arc<Mutex<Option<PeerState>>>,
|
||||||
peers: Arc<Mutex<HashMap<EndpointId, PeerState>>>,
|
peers: Arc<Mutex<HashMap<EndpointId, PeerState>>>,
|
||||||
|
/// Previously verified peers whose live roster entry was removed by a
|
||||||
|
/// transient disconnect. Retained only so a later authenticated `Leave`
|
||||||
|
/// still reaches core and cancels background recovery.
|
||||||
|
disconnected_peers: Arc<Mutex<HashSet<EndpointId>>>,
|
||||||
event_tx: mpsc::Sender<RoomEvent>,
|
event_tx: mpsc::Sender<RoomEvent>,
|
||||||
event_rx: Mutex<Option<mpsc::Receiver<RoomEvent>>>,
|
event_rx: Mutex<Option<mpsc::Receiver<RoomEvent>>>,
|
||||||
active_topic: Mutex<Option<tokio::task::JoinHandle<()>>>,
|
active_topic: Mutex<Option<tokio::task::JoinHandle<()>>>,
|
||||||
@@ -167,6 +234,7 @@ impl IrohGossipState {
|
|||||||
secret_key,
|
secret_key,
|
||||||
self_state: Arc::new(Mutex::new(None)),
|
self_state: Arc::new(Mutex::new(None)),
|
||||||
peers: Arc::new(Mutex::new(HashMap::new())),
|
peers: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
disconnected_peers: Arc::new(Mutex::new(HashSet::new())),
|
||||||
event_tx,
|
event_tx,
|
||||||
event_rx: Mutex::new(Some(event_rx)),
|
event_rx: Mutex::new(Some(event_rx)),
|
||||||
active_topic: Mutex::new(None),
|
active_topic: Mutex::new(None),
|
||||||
@@ -185,11 +253,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;
|
||||||
@@ -225,6 +307,7 @@ impl RoomState for IrohGossipState {
|
|||||||
|
|
||||||
let event_tx = self.event_tx.clone();
|
let event_tx = self.event_tx.clone();
|
||||||
let peers = self.peers.clone();
|
let peers = self.peers.clone();
|
||||||
|
let disconnected_peers = self.disconnected_peers.clone();
|
||||||
let address_lookup = self.address_lookup.clone();
|
let address_lookup = self.address_lookup.clone();
|
||||||
let self_state_clone = self.self_state.clone();
|
let self_state_clone = self.self_state.clone();
|
||||||
let gossip_sender_clone = gossip_sender.clone();
|
let gossip_sender_clone = gossip_sender.clone();
|
||||||
@@ -236,6 +319,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 +369,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 +402,11 @@ 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);
|
||||||
|
disconnected_peers.lock().unwrap().remove(&payload.author);
|
||||||
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,28 +418,51 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
GossipMessage::Leave => {
|
GossipMessage::Leave => {
|
||||||
crate::log_msg(&format!("Gossip peer leave request from author={:?}", payload.author));
|
crate::log_msg(&format!("Gossip peer leave request from author={:?}", payload.author));
|
||||||
let removed = peers.lock().unwrap().remove(&payload.author).is_some();
|
let removed = peers.lock().unwrap().remove(&payload.author).is_some();
|
||||||
if removed {
|
let was_disconnected = disconnected_peers
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.remove(&payload.author);
|
||||||
|
if removed || was_disconnected {
|
||||||
let _ = event_tx.send(RoomEvent::PeerLeft(payload.author)).await;
|
let _ = event_tx.send(RoomEvent::PeerLeft(payload.author)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
GossipMessage::Chat { name, text, ts } => {
|
GossipMessage::Chat { name, text, ts, attachment } => {
|
||||||
crate::log_msg(&format!("Gossip chat from author={:?}", payload.author));
|
crate::log_msg(&format!("Gossip chat from author={:?}", payload.author));
|
||||||
|
// Defensively normalize an untrusted attachment
|
||||||
|
// descriptor: sanitize the filename and drop it
|
||||||
|
// entirely if it declares an out-of-cap size.
|
||||||
|
let attachment = attachment.and_then(|mut a| {
|
||||||
|
if !crate::files::size_within_cap(a.size) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
a.name = crate::files::sanitize_filename(&a.name);
|
||||||
|
Some(a)
|
||||||
|
});
|
||||||
let _ = event_tx.send(RoomEvent::ChatMessage {
|
let _ = event_tx.send(RoomEvent::ChatMessage {
|
||||||
from: payload.author,
|
from: payload.author,
|
||||||
name,
|
name,
|
||||||
text,
|
text,
|
||||||
ts,
|
ts,
|
||||||
|
attachment,
|
||||||
}).await;
|
}).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -370,6 +501,7 @@ impl RoomState for IrohGossipState {
|
|||||||
// cached presence entry; a rejoin re-announces as new.
|
// cached presence entry; a rejoin re-announces as new.
|
||||||
let removed = peers.lock().unwrap().remove(&peer_id).is_some();
|
let removed = peers.lock().unwrap().remove(&peer_id).is_some();
|
||||||
if removed {
|
if removed {
|
||||||
|
disconnected_peers.lock().unwrap().insert(peer_id);
|
||||||
crate::log_msg(&format!("Peer connection lost (NeighborDown): {:?}", peer_id));
|
crate::log_msg(&format!("Peer connection lost (NeighborDown): {:?}", peer_id));
|
||||||
let _ = event_tx.send(RoomEvent::PeerConnectionLost(peer_id)).await;
|
let _ = event_tx.send(RoomEvent::PeerConnectionLost(peer_id)).await;
|
||||||
}
|
}
|
||||||
@@ -390,7 +522,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();
|
||||||
@@ -411,7 +546,48 @@ impl RoomState for IrohGossipState {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_chat(&self, text: String) -> Result<(), NetError> {
|
async fn rebootstrap_peers(&self, peers: Vec<EndpointAddr>) -> Result<(), NetError> {
|
||||||
|
let self_id = self._endpoint.id();
|
||||||
|
let mut peer_ids = Vec::new();
|
||||||
|
for addr in peers {
|
||||||
|
if addr.id == self_id || peer_ids.contains(&addr.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
self.address_lookup.add_endpoint_info(addr.clone());
|
||||||
|
peer_ids.push(addr.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if peer_ids.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone the sender before awaiting: active_sender is a standard mutex and
|
||||||
|
// must never be held across an async gossip operation.
|
||||||
|
let sender = self
|
||||||
|
.active_sender
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| NetError::Other("Not in a room".to_string()))?;
|
||||||
|
|
||||||
|
crate::log_msg(&format!("Rebootstrapping gossip peers: {:?}", peer_ids));
|
||||||
|
sender
|
||||||
|
.join_peers(peer_ids)
|
||||||
|
.await
|
||||||
|
.map_err(|e| NetError::Gossip(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark_peer_disconnected(&self, peer_id: EndpointId) {
|
||||||
|
if self.peers.lock().unwrap().remove(&peer_id).is_some() {
|
||||||
|
self.disconnected_peers.lock().unwrap().insert(peer_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_chat(
|
||||||
|
&self,
|
||||||
|
text: String,
|
||||||
|
attachment: Option<crate::files::ChatAttachment>,
|
||||||
|
) -> Result<(), NetError> {
|
||||||
let name = {
|
let name = {
|
||||||
let guard = self.self_state.lock().unwrap();
|
let guard = self.self_state.lock().unwrap();
|
||||||
match guard.as_ref() {
|
match guard.as_ref() {
|
||||||
@@ -428,7 +604,7 @@ impl RoomState for IrohGossipState {
|
|||||||
&self.secret_key,
|
&self.secret_key,
|
||||||
&topic,
|
&topic,
|
||||||
ts,
|
ts,
|
||||||
GossipMessage::Chat { name, text, ts },
|
GossipMessage::Chat { name, text, ts, attachment },
|
||||||
);
|
);
|
||||||
if let Ok(bytes) = serde_json::to_vec(&payload) {
|
if let Ok(bytes) = serde_json::to_vec(&payload) {
|
||||||
sender.broadcast(bytes.into()).await
|
sender.broadcast(bytes.into()).await
|
||||||
@@ -466,6 +642,7 @@ impl RoomState for IrohGossipState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.peers.lock().unwrap().clear();
|
self.peers.lock().unwrap().clear();
|
||||||
|
self.disconnected_peers.lock().unwrap().clear();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,11 +666,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 +739,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();
|
||||||
@@ -590,13 +766,15 @@ mod tests {
|
|||||||
name: "Alice".to_string(),
|
name: "Alice".to_string(),
|
||||||
text: "Hello".to_string(),
|
text: "Hello".to_string(),
|
||||||
ts: 123456789,
|
ts: 123456789,
|
||||||
|
attachment: None,
|
||||||
};
|
};
|
||||||
let serialized = serde_json::to_string(&original).unwrap();
|
let serialized = serde_json::to_string(&original).unwrap();
|
||||||
let deserialized: GossipMessage = serde_json::from_str(&serialized).unwrap();
|
let deserialized: GossipMessage = serde_json::from_str(&serialized).unwrap();
|
||||||
if let GossipMessage::Chat { name, text, ts } = deserialized {
|
if let GossipMessage::Chat { name, text, ts, attachment } = deserialized {
|
||||||
assert_eq!(name, "Alice");
|
assert_eq!(name, "Alice");
|
||||||
assert_eq!(text, "Hello");
|
assert_eq!(text, "Hello");
|
||||||
assert_eq!(ts, 123456789);
|
assert_eq!(ts, 123456789);
|
||||||
|
assert_eq!(attachment, None);
|
||||||
} else {
|
} else {
|
||||||
panic!("Expected GossipMessage::Chat");
|
panic!("Expected GossipMessage::Chat");
|
||||||
}
|
}
|
||||||
@@ -606,10 +784,11 @@ mod tests {
|
|||||||
name: "".to_string(),
|
name: "".to_string(),
|
||||||
text: "".to_string(),
|
text: "".to_string(),
|
||||||
ts: u64::MAX,
|
ts: u64::MAX,
|
||||||
|
attachment: None,
|
||||||
};
|
};
|
||||||
let serialized_empty = serde_json::to_string(&original_empty).unwrap();
|
let serialized_empty = serde_json::to_string(&original_empty).unwrap();
|
||||||
let deserialized_empty: GossipMessage = serde_json::from_str(&serialized_empty).unwrap();
|
let deserialized_empty: GossipMessage = serde_json::from_str(&serialized_empty).unwrap();
|
||||||
if let GossipMessage::Chat { name, text, ts } = deserialized_empty {
|
if let GossipMessage::Chat { name, text, ts, .. } = deserialized_empty {
|
||||||
assert_eq!(name, "");
|
assert_eq!(name, "");
|
||||||
assert_eq!(text, "");
|
assert_eq!(text, "");
|
||||||
assert_eq!(ts, u64::MAX);
|
assert_eq!(ts, u64::MAX);
|
||||||
@@ -618,6 +797,40 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_gossip_chat_attachment_round_trip_and_back_compat() {
|
||||||
|
let att = crate::files::ChatAttachment {
|
||||||
|
name: "photo.png".to_string(),
|
||||||
|
size: 4096,
|
||||||
|
kind: crate::files::AttachmentKind::Image,
|
||||||
|
id: [42u8; 32],
|
||||||
|
};
|
||||||
|
let original = GossipMessage::Chat {
|
||||||
|
name: "Alice".to_string(),
|
||||||
|
text: "look at this".to_string(),
|
||||||
|
ts: 1,
|
||||||
|
attachment: Some(att.clone()),
|
||||||
|
};
|
||||||
|
let serialized = serde_json::to_string(&original).unwrap();
|
||||||
|
let deserialized: GossipMessage = serde_json::from_str(&serialized).unwrap();
|
||||||
|
if let GossipMessage::Chat { attachment, .. } = deserialized {
|
||||||
|
assert_eq!(attachment, Some(att));
|
||||||
|
} else {
|
||||||
|
panic!("Expected GossipMessage::Chat");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pre-v2 chat payload (no `attachment` field) must still deserialize,
|
||||||
|
// defaulting the attachment to None (serde(default)).
|
||||||
|
let legacy = r#"{"Chat":{"name":"Old","text":"hi","ts":7}}"#;
|
||||||
|
let parsed: GossipMessage = serde_json::from_str(legacy).unwrap();
|
||||||
|
if let GossipMessage::Chat { name, attachment, .. } = parsed {
|
||||||
|
assert_eq!(name, "Old");
|
||||||
|
assert_eq!(attachment, None);
|
||||||
|
} else {
|
||||||
|
panic!("Expected GossipMessage::Chat");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_gossip_payload_chat_round_trip() {
|
fn test_gossip_payload_chat_round_trip() {
|
||||||
let secret = SecretKey::generate();
|
let secret = SecretKey::generate();
|
||||||
@@ -630,6 +843,7 @@ mod tests {
|
|||||||
name: "Bob".to_string(),
|
name: "Bob".to_string(),
|
||||||
text: "Hi there".to_string(),
|
text: "Hi there".to_string(),
|
||||||
ts: 987654321,
|
ts: 987654321,
|
||||||
|
attachment: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -637,7 +851,7 @@ mod tests {
|
|||||||
let deserialized: GossipPayload = serde_json::from_str(&serialized).unwrap();
|
let deserialized: GossipPayload = serde_json::from_str(&serialized).unwrap();
|
||||||
|
|
||||||
assert_eq!(deserialized.author, secret.public());
|
assert_eq!(deserialized.author, secret.public());
|
||||||
if let GossipMessage::Chat { name, text, ts } = deserialized.msg {
|
if let GossipMessage::Chat { name, text, ts, .. } = deserialized.msg {
|
||||||
assert_eq!(name, "Bob");
|
assert_eq!(name, "Bob");
|
||||||
assert_eq!(text, "Hi there");
|
assert_eq!(text, "Hi there");
|
||||||
assert_eq!(ts, 987654321);
|
assert_eq!(ts, 987654321);
|
||||||
@@ -652,10 +866,11 @@ mod tests {
|
|||||||
name: "🎙 User".to_string(),
|
name: "🎙 User".to_string(),
|
||||||
text: "héllo 🎙 世界".to_string(),
|
text: "héllo 🎙 世界".to_string(),
|
||||||
ts: 1717171717,
|
ts: 1717171717,
|
||||||
|
attachment: None,
|
||||||
};
|
};
|
||||||
let serialized = serde_json::to_string(&original).unwrap();
|
let serialized = serde_json::to_string(&original).unwrap();
|
||||||
let deserialized: GossipMessage = serde_json::from_str(&serialized).unwrap();
|
let deserialized: GossipMessage = serde_json::from_str(&serialized).unwrap();
|
||||||
if let GossipMessage::Chat { name, text, ts } = deserialized {
|
if let GossipMessage::Chat { name, text, ts, .. } = deserialized {
|
||||||
assert_eq!(name, "🎙 User");
|
assert_eq!(name, "🎙 User");
|
||||||
assert_eq!(text, "héllo 🎙 世界");
|
assert_eq!(text, "héllo 🎙 世界");
|
||||||
assert_eq!(ts, 1717171717);
|
assert_eq!(ts, 1717171717);
|
||||||
@@ -695,7 +910,7 @@ mod tests {
|
|||||||
let secret = SecretKey::generate();
|
let secret = SecretKey::generate();
|
||||||
let topic = [4u8; 32];
|
let topic = [4u8; 32];
|
||||||
let mut p = sign_gossip(&secret, &topic, 5_000, GossipMessage::Leave);
|
let mut p = sign_gossip(&secret, &topic, 5_000, GossipMessage::Leave);
|
||||||
p.msg = GossipMessage::Chat { name: "x".into(), text: "y".into(), ts: 5_000 };
|
p.msg = GossipMessage::Chat { name: "x".into(), text: "y".into(), ts: 5_000, attachment: None };
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS),
|
verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS),
|
||||||
Err(GossipReject::BadSignature)
|
Err(GossipReject::BadSignature)
|
||||||
@@ -732,5 +947,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, attachment: None };
|
||||||
|
let earlier_chat = GossipMessage::Chat { name: "A".into(), text: "earlier".into(), ts: 100, attachment: None };
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ 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, FILES_ALPN};
|
||||||
|
use crate::files::{AttachmentId, ChatAttachment};
|
||||||
|
|
||||||
/// 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.
|
||||||
@@ -31,6 +32,10 @@ const MAX_BACKOFF: Duration = Duration::from_secs(5);
|
|||||||
/// already means "the peer closed this on purpose."
|
/// already means "the peer closed this on purpose."
|
||||||
const GOODBYE_CODE: u32 = 1;
|
const GOODBYE_CODE: u32 = 1;
|
||||||
|
|
||||||
|
/// Bound on each phase (connect, read) of a chat-attachment fetch, so a slow or
|
||||||
|
/// stalled sender can't hang the fetch indefinitely.
|
||||||
|
const FILE_FETCH_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
/// State shared between the transport, its protocol handler, and every per-peer
|
/// State shared between the transport, its protocol handler, and every per-peer
|
||||||
/// supervisor task. One supervisor owns a peer's whole connection lifecycle.
|
/// supervisor task. One supervisor owns a peer's whole connection lifecycle.
|
||||||
struct Shared {
|
struct Shared {
|
||||||
@@ -56,6 +61,15 @@ 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>>,
|
||||||
|
/// Chat file attachments we're serving to room members this session, keyed by
|
||||||
|
/// the random attachment id. Populated when we send a chat file; read by the
|
||||||
|
/// file protocol handler to answer a member's fetch. Cleared on leave. Each
|
||||||
|
/// blob is already byte-capped at send time.
|
||||||
|
served_files: StdMutex<HashMap<crate::files::AttachmentId, Arc<Vec<u8>>>>,
|
||||||
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 +126,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 +159,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 +392,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));
|
||||||
@@ -346,6 +412,97 @@ impl iroh::protocol::ProtocolHandler for AudioRouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Protocol handler for the file-transfer plane (`FILES_ALPN`). Mirrors
|
||||||
|
/// [`AudioRouter`]: it's persistent on the router and bound to the active
|
||||||
|
/// session's [`Shared`] on join. On an inbound stream it authenticates the peer
|
||||||
|
/// (iroh ALPN handshake gives us `remote_id`), gates on **live room membership**
|
||||||
|
/// (same invariant as audio admission, so a former member can't pull files),
|
||||||
|
/// reads a single 32-byte attachment id, and streams back the matching blob from
|
||||||
|
/// the session serve store — or nothing if the id is unknown.
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct FileRouter {
|
||||||
|
current: Arc<StdMutex<Option<Arc<Shared>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for FileRouter {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("FileRouter").finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileRouter {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Route inbound file fetches to `transport`'s session (called on join).
|
||||||
|
pub fn bind(&self, transport: &IrohTransport) {
|
||||||
|
*self.current.lock().unwrap() = Some(transport.shared.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop serving files until the next [`bind`](Self::bind) (called on leave).
|
||||||
|
pub fn clear(&self) {
|
||||||
|
*self.current.lock().unwrap() = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Max bytes we'll read for a fetch *request* frame. A request is exactly one
|
||||||
|
/// 32-byte id; this small ceiling rejects a peer trying to stream us a huge
|
||||||
|
/// "request" as a cheap DoS.
|
||||||
|
const FILE_REQUEST_MAX: usize = 64;
|
||||||
|
|
||||||
|
impl iroh::protocol::ProtocolHandler for FileRouter {
|
||||||
|
fn accept(
|
||||||
|
&self,
|
||||||
|
connection: Connection,
|
||||||
|
) -> impl std::future::Future<Output = Result<(), iroh::protocol::AcceptError>> + Send {
|
||||||
|
let peer_id = connection.remote_id();
|
||||||
|
let shared = self.current.lock().unwrap().clone();
|
||||||
|
async move {
|
||||||
|
// No active call → nothing to serve.
|
||||||
|
let Some(shared) = shared else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
// Member gating: only current room members may fetch our files. Reuses
|
||||||
|
// the audio admission roster (the authoritative room membership set).
|
||||||
|
if !shared.audio_sender_admitted(peer_id) {
|
||||||
|
crate::log_msg(&format!(
|
||||||
|
"Transport: rejected file fetch from non-member {}",
|
||||||
|
crate::short_id(&peer_id.to_string())
|
||||||
|
));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// Accept one bidirectional stream: read the id, write the bytes.
|
||||||
|
let Ok((mut send, mut recv)) = connection.accept_bi().await else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let Ok(req) = recv.read_to_end(FILE_REQUEST_MAX).await else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let Some(id) = crate::files::parse_request(&req) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let blob = shared.served_files.lock().unwrap().get(&id).cloned();
|
||||||
|
if let Some(blob) = blob {
|
||||||
|
let _ = send.write_all(&blob).await;
|
||||||
|
}
|
||||||
|
// Finish either way: an unknown id closes with an empty body, which
|
||||||
|
// the fetcher reads as a zero-length result and treats as "gone".
|
||||||
|
let _ = send.finish();
|
||||||
|
// CRITICAL: `finish()` only marks the stream's EOF — it does NOT wait
|
||||||
|
// for the written bytes to be delivered and acknowledged. If we return
|
||||||
|
// here the `connection` drops, and its CONNECTION_CLOSE can race ahead
|
||||||
|
// of the still-in-flight stream data, so the fetcher's read aborts with
|
||||||
|
// "connection lost". Wait for the fetcher to receive everything and
|
||||||
|
// close the connection itself (it drops `conn` right after read_to_end);
|
||||||
|
// that close is our signal the transfer landed. Bounded so a fetcher
|
||||||
|
// that vanishes can't pin this task forever.
|
||||||
|
let _ = tokio::time::timeout(FILE_FETCH_TIMEOUT, connection.closed()).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct IrohTransport {
|
pub struct IrohTransport {
|
||||||
shared: Arc<Shared>,
|
shared: Arc<Shared>,
|
||||||
incoming_rx: tokio::sync::Mutex<Option<mpsc::Receiver<(EndpointId, Bytes)>>>,
|
incoming_rx: tokio::sync::Mutex<Option<mpsc::Receiver<(EndpointId, Bytes)>>>,
|
||||||
@@ -369,6 +526,8 @@ 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()),
|
||||||
|
served_files: StdMutex::new(HashMap::new()),
|
||||||
incoming_tx,
|
incoming_tx,
|
||||||
conn_events_tx,
|
conn_events_tx,
|
||||||
});
|
});
|
||||||
@@ -397,11 +556,89 @@ 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();
|
||||||
|
self.shared.served_files.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make `bytes` available to room members under `id` for the rest of this
|
||||||
|
/// session (served by the [`FileRouter`] handler). Called by core when we
|
||||||
|
/// send a chat file. The blob is cleared on leave.
|
||||||
|
pub fn serve_attachment(&self, id: AttachmentId, bytes: Arc<Vec<u8>>) {
|
||||||
|
self.shared.served_files.lock().unwrap().insert(id, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch a chat attachment's bytes from its sender over the file plane. Dials
|
||||||
|
/// the sender on `FILES_ALPN` (preferring a known full address), writes the
|
||||||
|
/// 32-byte id, and reads the response bounded by the descriptor's declared
|
||||||
|
/// size (which the caller has already validated against the global cap). The
|
||||||
|
/// read limit means a malicious sender can't stream us more than advertised.
|
||||||
|
pub async fn fetch_attachment(
|
||||||
|
&self,
|
||||||
|
from: EndpointId,
|
||||||
|
att: &ChatAttachment,
|
||||||
|
) -> Result<Vec<u8>, NetError> {
|
||||||
|
if !crate::files::size_within_cap(att.size) {
|
||||||
|
return Err(NetError::Other("attachment size out of range".to_string()));
|
||||||
|
}
|
||||||
|
let addr = self.shared.addrs.lock().unwrap().get(&from).cloned();
|
||||||
|
let connect = async {
|
||||||
|
match addr {
|
||||||
|
Some(addr) => self.shared.endpoint.connect(addr, FILES_ALPN).await,
|
||||||
|
None => self.shared.endpoint.connect(from, FILES_ALPN).await,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let conn = tokio::time::timeout(FILE_FETCH_TIMEOUT, connect)
|
||||||
|
.await
|
||||||
|
.map_err(|_| NetError::Other("file fetch: connect timed out".to_string()))?
|
||||||
|
.map_err(|e| NetError::Other(format!("file fetch: connect failed: {e}")))?;
|
||||||
|
|
||||||
|
let (mut send, mut recv) = conn
|
||||||
|
.open_bi()
|
||||||
|
.await
|
||||||
|
.map_err(|e| NetError::Other(format!("file fetch: open stream failed: {e}")))?;
|
||||||
|
send.write_all(&att.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| NetError::Other(format!("file fetch: request write failed: {e}")))?;
|
||||||
|
send.finish()
|
||||||
|
.map_err(|e| NetError::Other(format!("file fetch: request finish failed: {e}")))?;
|
||||||
|
|
||||||
|
let read = recv.read_to_end(att.size as usize);
|
||||||
|
let bytes = tokio::time::timeout(FILE_FETCH_TIMEOUT, read)
|
||||||
|
.await
|
||||||
|
.map_err(|_| NetError::Other("file fetch: read timed out".to_string()))?
|
||||||
|
.map_err(|e| NetError::Other(format!("file fetch: read failed: {e}")))?;
|
||||||
|
if bytes.is_empty() {
|
||||||
|
return Err(NetError::Other("file fetch: sender no longer has the file".to_string()));
|
||||||
|
}
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -443,3 +680,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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,7 +57,15 @@ pub enum RoomEvent {
|
|||||||
/// A peer sent a room text-chat message. Carries the sender's id, their
|
/// A peer sent a room text-chat message. Carries the sender's id, their
|
||||||
/// display name (embedded so it shows even without a presence entry), the
|
/// display name (embedded so it shows even without a presence entry), the
|
||||||
/// text, and a sender-stamped millisecond timestamp.
|
/// text, and a sender-stamped millisecond timestamp.
|
||||||
ChatMessage { from: EndpointId, name: String, text: String, ts: u64 },
|
ChatMessage {
|
||||||
|
from: EndpointId,
|
||||||
|
name: String,
|
||||||
|
text: String,
|
||||||
|
ts: u64,
|
||||||
|
/// Optional file attachment descriptor; the bytes are fetched off-gossip
|
||||||
|
/// on the file plane. Already filename-sanitized + size-capped on ingest.
|
||||||
|
attachment: Option<crate::files::ChatAttachment>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transport-level link state for a peer, surfaced so the UI can show when a
|
/// Transport-level link state for a peer, surfaced so the UI can show when a
|
||||||
@@ -194,9 +202,25 @@ pub trait RoomState: Send + Sync {
|
|||||||
/// Updates our local state (e.g. when user mutes/unmutes) and broadcasts it.
|
/// Updates our local state (e.g. when user mutes/unmutes) and broadcasts it.
|
||||||
async fn update_self_state(&self, self_state: PeerState) -> Result<(), NetError>;
|
async fn update_self_state(&self, self_state: PeerState) -> Result<(), NetError>;
|
||||||
|
|
||||||
|
/// Ask the active gossip topic to connect to retained peer addresses without
|
||||||
|
/// leaving or replacing the subscription. This is a recovery primitive only:
|
||||||
|
/// it does not add peers to the authenticated room roster. A peer becomes
|
||||||
|
/// active only after its normal signed `Announce` is received and verified.
|
||||||
|
async fn rebootstrap_peers(&self, peers: Vec<EndpointAddr>) -> Result<(), NetError>;
|
||||||
|
|
||||||
|
/// Remove a peer from the authenticated live roster before background
|
||||||
|
/// recovery. This only revokes membership; a fresh verified `Announce` is
|
||||||
|
/// required to add the peer again.
|
||||||
|
fn mark_peer_disconnected(&self, peer_id: EndpointId);
|
||||||
|
|
||||||
/// Broadcasts a room text-chat message authored by us (our display name is
|
/// Broadcasts a room text-chat message authored by us (our display name is
|
||||||
/// taken from the current self-state).
|
/// taken from the current self-state), optionally carrying a file attachment
|
||||||
async fn send_chat(&self, text: String) -> Result<(), NetError>;
|
/// descriptor whose bytes are served separately on the file plane.
|
||||||
|
async fn send_chat(
|
||||||
|
&self,
|
||||||
|
text: String,
|
||||||
|
attachment: Option<crate::files::ChatAttachment>,
|
||||||
|
) -> Result<(), NetError>;
|
||||||
|
|
||||||
/// Leaves the room and announces departure.
|
/// Leaves the room and announces departure.
|
||||||
async fn leave(&self) -> Result<(), NetError>;
|
async fn leave(&self) -> Result<(), NetError>;
|
||||||
@@ -342,4 +366,3 @@ mod tests {
|
|||||||
assert_eq!(original, deserialized);
|
assert_eq!(original, deserialized);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,12 @@
|
|||||||
//!
|
//!
|
||||||
//! The WAVs are embedded in the binary (`include_bytes!`) so a deployed single
|
//! The WAVs are embedded in the binary (`include_bytes!`) so a deployed single
|
||||||
//! binary is self-contained — no asset directory to ship alongside it. On first
|
//! binary is self-contained — no asset directory to ship alongside it. On first
|
||||||
//! use each sound is written once to a temp file, then played fire-and-forget
|
//! use each sound is written once to a temp file, then played fire-and-forget.
|
||||||
//! via `pw-play` (PipeWire-native; falls back to `paplay`/`aplay`). Playback runs
|
//! Linux uses `pw-play` (PipeWire-native; falls back to `paplay`/`aplay`);
|
||||||
//! on a detached thread that waits on the child, so it never blocks the UI and
|
//! Windows uses PowerShell's `System.Media.SoundPlayer`. Playback runs on a
|
||||||
//! never leaves a zombie. Any failure (no player, no audio) is silent by design —
|
//! detached thread that waits on the child, so it never blocks the UI and never
|
||||||
//! a missing chime should never disrupt a call.
|
//! leaves a zombie. Any failure (no player, no audio) is silent by design — a
|
||||||
|
//! missing chime should never disrupt a call.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -202,8 +203,14 @@ fn cached_path(sound: Sound) -> Option<PathBuf> {
|
|||||||
Some(path)
|
Some(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(any(windows, test))]
|
||||||
|
fn escape_powershell_single_quoted(s: &str) -> String {
|
||||||
|
s.replace('\'', "''")
|
||||||
|
}
|
||||||
|
|
||||||
/// Try each available player in turn, waiting on the first that starts (which
|
/// Try each available player in turn, waiting on the first that starts (which
|
||||||
/// reaps the child). Runs on a detached thread, so the wait is harmless.
|
/// reaps the child). Runs on a detached thread, so the wait is harmless.
|
||||||
|
#[cfg(not(windows))]
|
||||||
fn spawn_player(path: &Path) {
|
fn spawn_player(path: &Path) {
|
||||||
for player in ["pw-play", "paplay", "aplay"] {
|
for player in ["pw-play", "paplay", "aplay"] {
|
||||||
let started = Command::new(player)
|
let started = Command::new(player)
|
||||||
@@ -221,6 +228,23 @@ fn spawn_player(path: &Path) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Play through Windows' built-in WAV player. Runs on a detached thread, so
|
||||||
|
/// `PlaySync()` blocking for the sound duration is fine.
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn spawn_player(path: &Path) {
|
||||||
|
let path = escape_powershell_single_quoted(&path.display().to_string());
|
||||||
|
let command = format!("(New-Object System.Media.SoundPlayer '{path}').PlaySync()");
|
||||||
|
let _ = Command::new("powershell")
|
||||||
|
.arg("-NoProfile")
|
||||||
|
.arg("-NonInteractive")
|
||||||
|
.arg("-Command")
|
||||||
|
.arg(command)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status();
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -234,6 +258,18 @@ mod tests {
|
|||||||
assert!(!should_play(false, false));
|
assert!(!should_play(false, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_powershell_single_quote_escape() {
|
||||||
|
assert_eq!(
|
||||||
|
escape_powershell_single_quoted(r"C:\Users\O'Brien\chime.wav"),
|
||||||
|
r"C:\Users\O''Brien\chime.wav"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
escape_powershell_single_quoted("a'b'c"),
|
||||||
|
"a''b''c"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_sound_indices_unique_and_match_all() {
|
fn test_sound_indices_unique_and_match_all() {
|
||||||
// `index()` must be a 0..COUNT bijection in `ALL` order, or the flag
|
// `index()` must be a 0..COUNT bijection in `ALL` order, or the flag
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
// Online, but the advertised room is unusable — don't offer Join.
|
||||||
|
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 {
|
Some(FriendPresence::InRoom {
|
||||||
name: crate::sanitize::sanitize_name(&r.name),
|
name: crate::sanitize::sanitize_name(&r.name),
|
||||||
ticket: r.ticket.clone(),
|
ticket: r.ticket.clone(),
|
||||||
})
|
})
|
||||||
} else {
|
|
||||||
// Online, but the advertised room is unusable — don't offer Join.
|
|
||||||
Some(FriendPresence::Online)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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):
|
|
||||||
// finish the stream with no bytes so the prober sees an empty (unusable) reply.
|
|
||||||
if let Some(reply) = handler(from) {
|
|
||||||
send.write_all(&encode(&reply)?)
|
send.write_all(&encode(&reply)?)
|
||||||
.await
|
.await
|
||||||
.context("failed to write pong")?;
|
.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,93 @@
|
|||||||
|
//! 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`].
|
||||||
|
///
|
||||||
|
/// v2 (0.3.0): `GossipMessage::Chat` gained an optional file attachment
|
||||||
|
/// (`ChatAttachment`), so a pre-v2 peer can't interpret/serve chat files — bumped
|
||||||
|
/// to fail fast rather than half-work.
|
||||||
|
pub const GOSSIP_PROTO: u32 = 2;
|
||||||
|
/// File-transfer plane version (chat attachment request/stream shape). Bump on
|
||||||
|
/// any change. Mirrored in [`FILES_ALPN`].
|
||||||
|
pub const FILES_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";
|
||||||
|
/// ALPN for the file-transfer plane: `peerspeak/files/<FILES_PROTO>`. Carries
|
||||||
|
/// chat attachment bytes via direct QUIC streams (not gossip).
|
||||||
|
pub const FILES_ALPN: &[u8] = b"peerspeak/files/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-v2";
|
||||||
|
|
||||||
|
/// 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!(FILES_ALPN, format!("peerspeak/files/{FILES_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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,20 @@ use tokio::process::{Child, Command};
|
|||||||
/// points elsewhere.
|
/// points elsewhere.
|
||||||
const PIXELPASS_BIN: &str = "pixelpass";
|
const PIXELPASS_BIN: &str = "pixelpass";
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn pixelpass_path_candidates(dir: &Path) -> [PathBuf; 2] {
|
||||||
|
[dir.join(PIXELPASS_BIN), dir.join("pixelpass.exe")]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn pixelpass_path_candidates(dir: &Path) -> [PathBuf; 1] {
|
||||||
|
[dir.join(PIXELPASS_BIN)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pixelpass endpoint tickets are normally ~140 chars. Leave headroom for format
|
||||||
|
/// growth, but reject unbounded gossip payloads before the UI offers "Watch".
|
||||||
|
const MAX_TICKET_LEN: usize = 512;
|
||||||
|
|
||||||
/// How long to wait for the host to emit its ticket / the viewer to connect
|
/// 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 +122,19 @@ pub fn viewer_args(ticket: &str) -> Vec<String> {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sanitize a peer-advertised pixelpass ticket at the gossip boundary. Peerspeak
|
||||||
|
/// intentionally does not depend on pixelpass/iroh-tickets, so this validates the
|
||||||
|
/// stable CLI ticket envelope we consume: bounded ASCII endpoint tickets beginning
|
||||||
|
/// with `endpoint`. Invalid input becomes `None`, which removes the Watch button.
|
||||||
|
pub fn sanitize_ticket(ticket: String) -> Option<String> {
|
||||||
|
let ticket = ticket.trim();
|
||||||
|
let valid_len = !ticket.is_empty() && ticket.len() <= MAX_TICKET_LEN;
|
||||||
|
let valid_shape = ticket.starts_with("endpoint")
|
||||||
|
&& ticket.len() > "endpoint".len()
|
||||||
|
&& ticket.bytes().all(|b| b.is_ascii_alphanumeric());
|
||||||
|
(valid_len && valid_shape).then(|| ticket.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve the pixelpass binary: an explicit config override (used only if it
|
/// 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
|
||||||
@@ -126,7 +153,7 @@ pub fn pixelpass_path(config_override: Option<&str>) -> Option<PathBuf> {
|
|||||||
}
|
}
|
||||||
let path_var = std::env::var_os("PATH")?;
|
let path_var = std::env::var_os("PATH")?;
|
||||||
std::env::split_paths(&path_var)
|
std::env::split_paths(&path_var)
|
||||||
.map(|dir| dir.join(PIXELPASS_BIN))
|
.flat_map(|dir| pixelpass_path_candidates(&dir))
|
||||||
.find(|c| c.is_file())
|
.find(|c| c.is_file())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,12 +294,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 +384,27 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_ticket_accepts_pixelpass_endpoint_ticket_shape() {
|
||||||
|
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm";
|
||||||
|
assert_eq!(sanitize_ticket(format!(" {ticket}\n")), Some(ticket.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_ticket_rejects_oversized_or_garbage_ticket() {
|
||||||
|
assert_eq!(sanitize_ticket("not-a-ticket".into()), None);
|
||||||
|
assert_eq!(sanitize_ticket(format!("endpoint{}", "a".repeat(MAX_TICKET_LEN))), None);
|
||||||
|
assert_eq!(sanitize_ticket("endpointabc-def".into()), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn event_log_redacts_ticket_values() {
|
||||||
|
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm".to_string();
|
||||||
|
let log = event_for_log(&PixelpassEvent::Ticket(ticket.clone()));
|
||||||
|
assert!(log.contains("endpoint"));
|
||||||
|
assert!(!log.contains(&ticket["endpoint".len() + 8..]));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_ticket() {
|
fn parses_ticket() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -458,4 +523,14 @@ mod tests {
|
|||||||
// only assert it doesn't return the empty path as a match.
|
// only assert it doesn't return the empty path as a match.
|
||||||
assert_ne!(pixelpass_path(Some(" ")).as_deref(), Some(Path::new("")));
|
assert_ne!(pixelpass_path(Some(" ")).as_deref(), Some(Path::new("")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pixelpass_path_candidates_are_platform_specific() {
|
||||||
|
let dir = Path::new("bin");
|
||||||
|
let candidates: Vec<PathBuf> = pixelpass_path_candidates(dir).into_iter().collect();
|
||||||
|
#[cfg(windows)]
|
||||||
|
assert_eq!(candidates, vec![dir.join("pixelpass"), dir.join("pixelpass.exe")]);
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
assert_eq!(candidates, vec![dir.join("pixelpass")]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ pub enum AppTheme {
|
|||||||
GruvboxDark,
|
GruvboxDark,
|
||||||
SolarizedLight,
|
SolarizedLight,
|
||||||
GruvboxLight,
|
GruvboxLight,
|
||||||
|
AyuDark,
|
||||||
|
AyuMirage,
|
||||||
|
AyuLight,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a `Color` from a packed `0xRRGGBB` literal — keeps the palette tables
|
/// Build a `Color` from a packed `0xRRGGBB` literal — keeps the palette tables
|
||||||
@@ -70,7 +73,7 @@ fn hex(c: u32) -> Color {
|
|||||||
|
|
||||||
impl AppTheme {
|
impl AppTheme {
|
||||||
/// Every theme, in picker order.
|
/// Every theme, in picker order.
|
||||||
pub const ALL: [AppTheme; 10] = [
|
pub const ALL: [AppTheme; 13] = [
|
||||||
AppTheme::Mocha,
|
AppTheme::Mocha,
|
||||||
AppTheme::Macchiato,
|
AppTheme::Macchiato,
|
||||||
AppTheme::Frappe,
|
AppTheme::Frappe,
|
||||||
@@ -81,6 +84,9 @@ impl AppTheme {
|
|||||||
AppTheme::GruvboxDark,
|
AppTheme::GruvboxDark,
|
||||||
AppTheme::SolarizedLight,
|
AppTheme::SolarizedLight,
|
||||||
AppTheme::GruvboxLight,
|
AppTheme::GruvboxLight,
|
||||||
|
AppTheme::AyuDark,
|
||||||
|
AppTheme::AyuMirage,
|
||||||
|
AppTheme::AyuLight,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Human-readable name for the picker.
|
/// Human-readable name for the picker.
|
||||||
@@ -96,6 +102,9 @@ impl AppTheme {
|
|||||||
AppTheme::GruvboxDark => "Gruvbox Dark",
|
AppTheme::GruvboxDark => "Gruvbox Dark",
|
||||||
AppTheme::SolarizedLight => "Solarized Light",
|
AppTheme::SolarizedLight => "Solarized Light",
|
||||||
AppTheme::GruvboxLight => "Gruvbox Light",
|
AppTheme::GruvboxLight => "Gruvbox Light",
|
||||||
|
AppTheme::AyuDark => "Ayu Dark",
|
||||||
|
AppTheme::AyuMirage => "Ayu Mirage",
|
||||||
|
AppTheme::AyuLight => "Ayu Light",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +112,10 @@ impl AppTheme {
|
|||||||
pub fn is_dark(self) -> bool {
|
pub fn is_dark(self) -> bool {
|
||||||
!matches!(
|
!matches!(
|
||||||
self,
|
self,
|
||||||
AppTheme::Latte | AppTheme::SolarizedLight | AppTheme::GruvboxLight
|
AppTheme::Latte
|
||||||
|
| AppTheme::SolarizedLight
|
||||||
|
| AppTheme::GruvboxLight
|
||||||
|
| AppTheme::AyuLight
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +132,8 @@ impl AppTheme {
|
|||||||
AppTheme::GruvboxDark => iced::Theme::GruvboxDark,
|
AppTheme::GruvboxDark => iced::Theme::GruvboxDark,
|
||||||
AppTheme::SolarizedLight => iced::Theme::SolarizedLight,
|
AppTheme::SolarizedLight => iced::Theme::SolarizedLight,
|
||||||
AppTheme::GruvboxLight => iced::Theme::GruvboxLight,
|
AppTheme::GruvboxLight => iced::Theme::GruvboxLight,
|
||||||
|
AppTheme::AyuDark | AppTheme::AyuMirage => iced::Theme::TokyoNight,
|
||||||
|
AppTheme::AyuLight => iced::Theme::Light,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +295,52 @@ impl AppTheme {
|
|||||||
green: hex(0x79740e),
|
green: hex(0x79740e),
|
||||||
yellow: hex(0xb57614),
|
yellow: hex(0xb57614),
|
||||||
},
|
},
|
||||||
|
AppTheme::AyuDark => Palette {
|
||||||
|
crust: hex(0x06080a),
|
||||||
|
mantle: hex(0x0b0e14),
|
||||||
|
base: hex(0x0d1017),
|
||||||
|
surface: hex(0x1c222b),
|
||||||
|
overlay: hex(0x565b66),
|
||||||
|
text: hex(0xbfbdb6),
|
||||||
|
subtext: hex(0x9da1a6),
|
||||||
|
blue: hex(0xe6b450),
|
||||||
|
lavender: hex(0x59c2ff),
|
||||||
|
red: hex(0xf07178),
|
||||||
|
maroon: hex(0xff8f40),
|
||||||
|
green: hex(0xaad94c),
|
||||||
|
yellow: hex(0xffb454),
|
||||||
|
},
|
||||||
|
AppTheme::AyuMirage => Palette {
|
||||||
|
crust: hex(0x171b24),
|
||||||
|
mantle: hex(0x1a1f29),
|
||||||
|
base: hex(0x1f2430),
|
||||||
|
surface: hex(0x232834),
|
||||||
|
overlay: hex(0x707a8c),
|
||||||
|
text: hex(0xcccac2),
|
||||||
|
subtext: hex(0xa6abb4),
|
||||||
|
blue: hex(0xffcc66),
|
||||||
|
lavender: hex(0x73d0ff),
|
||||||
|
red: hex(0xf28779),
|
||||||
|
maroon: hex(0xffa759),
|
||||||
|
green: hex(0xd5ff80),
|
||||||
|
yellow: hex(0xffd173),
|
||||||
|
},
|
||||||
|
// Ayu Light's canonical orange is deepened for legibility on white.
|
||||||
|
AppTheme::AyuLight => Palette {
|
||||||
|
crust: hex(0xe6e9ec),
|
||||||
|
mantle: hex(0xf3f4f5),
|
||||||
|
base: hex(0xfcfcfc),
|
||||||
|
surface: hex(0xe8eaed),
|
||||||
|
overlay: hex(0x8a9199),
|
||||||
|
text: hex(0x5c6166),
|
||||||
|
subtext: hex(0x737980),
|
||||||
|
blue: hex(0xc7500e),
|
||||||
|
lavender: hex(0x399ee6),
|
||||||
|
red: hex(0xf07171),
|
||||||
|
maroon: hex(0xfa8d3e),
|
||||||
|
green: hex(0x86b300),
|
||||||
|
yellow: hex(0xff9940),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -371,11 +431,11 @@ mod tests {
|
|||||||
fn all_themes_distinct_and_labeled() {
|
fn all_themes_distinct_and_labeled() {
|
||||||
// ALL covers exactly the variants once, each with a unique non-empty label
|
// ALL covers exactly the variants once, each with a unique non-empty label
|
||||||
// and a distinct base colour (so swatches don't look identical).
|
// and a distinct base colour (so swatches don't look identical).
|
||||||
assert_eq!(AppTheme::ALL.len(), 10);
|
assert_eq!(AppTheme::ALL.len(), 13);
|
||||||
let mut labels: Vec<&str> = AppTheme::ALL.iter().map(|t| t.label()).collect();
|
let mut labels: Vec<&str> = AppTheme::ALL.iter().map(|t| t.label()).collect();
|
||||||
labels.sort_unstable();
|
labels.sort_unstable();
|
||||||
labels.dedup();
|
labels.dedup();
|
||||||
assert_eq!(labels.len(), 10, "labels must be unique + non-empty");
|
assert_eq!(labels.len(), 13, "labels must be unique + non-empty");
|
||||||
assert!(labels.iter().all(|l| !l.is_empty()));
|
assert!(labels.iter().all(|l| !l.is_empty()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
//! End-to-end loopback test for the chat file-transfer plane (`FILES_ALPN`).
|
||||||
|
//!
|
||||||
|
//! Spins up two real iroh endpoints on localhost (relay disabled, addresses
|
||||||
|
//! exchanged directly), registers the production [`FileRouter`] on each, serves a
|
||||||
|
//! multi-megabyte blob on one side, and fetches it from the other through the
|
||||||
|
//! real `serve_attachment`/`fetch_attachment` path.
|
||||||
|
//!
|
||||||
|
//! This is the regression guard for the "file fetch: read failed: connection
|
||||||
|
//! lost" bug: the serve handler used to return (and drop the connection) the
|
||||||
|
//! instant it called `finish()`, so the CONNECTION_CLOSE raced ahead of the
|
||||||
|
//! still-in-flight stream data and the fetcher's `read_to_end` aborted. A blob
|
||||||
|
//! large enough to span many packets makes that race deterministic — the fix
|
||||||
|
//! (waiting on `connection.closed()` before returning) keeps the link up until
|
||||||
|
//! the fetcher has the bytes.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use iroh::address_lookup::memory::MemoryLookup;
|
||||||
|
use iroh::endpoint::presets;
|
||||||
|
use iroh::protocol::Router;
|
||||||
|
use iroh::{Endpoint, RelayMode};
|
||||||
|
|
||||||
|
use peerspeak::files::{ChatAttachment, AttachmentKind};
|
||||||
|
use peerspeak::network::NetworkTransport;
|
||||||
|
use peerspeak::network::iroh_impl::{FileRouter, IrohTransport};
|
||||||
|
use peerspeak::protocol::FILES_ALPN;
|
||||||
|
|
||||||
|
struct Node {
|
||||||
|
endpoint: Endpoint,
|
||||||
|
transport: Arc<IrohTransport>,
|
||||||
|
_router: Router,
|
||||||
|
lookup: MemoryLookup,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn spawn_node() -> Node {
|
||||||
|
let lookup = MemoryLookup::new();
|
||||||
|
let endpoint = Endpoint::builder(presets::Minimal)
|
||||||
|
.secret_key(iroh::SecretKey::generate())
|
||||||
|
.relay_mode(RelayMode::Disabled)
|
||||||
|
.address_lookup(lookup.clone())
|
||||||
|
.bind()
|
||||||
|
.await
|
||||||
|
.expect("bind endpoint");
|
||||||
|
|
||||||
|
let transport = Arc::new(IrohTransport::new(endpoint.clone()));
|
||||||
|
// Mirror production: a persistent FileRouter bound to this session's transport
|
||||||
|
// is what the router accepts inbound file fetches on.
|
||||||
|
let file_router = FileRouter::new();
|
||||||
|
file_router.bind(&transport);
|
||||||
|
let router = Router::builder(endpoint.clone())
|
||||||
|
.accept(FILES_ALPN, file_router)
|
||||||
|
.spawn();
|
||||||
|
|
||||||
|
Node { endpoint, transport, _router: router, lookup }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pseudo-random-ish multi-megabyte payload spanning many QUIC packets, so a
|
||||||
|
/// premature connection close on the serve side reliably corrupts/aborts the read.
|
||||||
|
fn big_blob() -> Vec<u8> {
|
||||||
|
(0..(2 * 1024 * 1024u32))
|
||||||
|
.map(|i| (i.wrapping_mul(2654435761) >> 13) as u8)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn loopback_attachment_round_trips_intact() {
|
||||||
|
let server = spawn_node().await;
|
||||||
|
let client = spawn_node().await;
|
||||||
|
|
||||||
|
// Seed each side with the other's full address so direct dialing works.
|
||||||
|
server.lookup.add_endpoint_info(client.endpoint.addr());
|
||||||
|
client.lookup.add_endpoint_info(server.endpoint.addr());
|
||||||
|
|
||||||
|
let server_id = server.endpoint.id();
|
||||||
|
let client_id = client.endpoint.id();
|
||||||
|
|
||||||
|
// The serve handler gates on room membership (the audio admission roster), so
|
||||||
|
// the server must admit the client before it will answer the fetch.
|
||||||
|
server.transport.admit_audio_sender(client_id);
|
||||||
|
client.transport.admit_audio_sender(server_id);
|
||||||
|
|
||||||
|
// The fetcher dials the retained full address; seed it so fetch_attachment
|
||||||
|
// doesn't have to fall back to a bare-id lookup.
|
||||||
|
client.transport.connect_peer(server.endpoint.addr()).await;
|
||||||
|
|
||||||
|
let blob = big_blob();
|
||||||
|
let id = [42u8; 32];
|
||||||
|
server.transport.serve_attachment(id, Arc::new(blob.clone()));
|
||||||
|
|
||||||
|
let att = ChatAttachment {
|
||||||
|
name: "exterior-landscape.jpg".to_string(),
|
||||||
|
size: blob.len() as u64,
|
||||||
|
kind: AttachmentKind::Image,
|
||||||
|
id,
|
||||||
|
};
|
||||||
|
|
||||||
|
let fetched = tokio::time::timeout(
|
||||||
|
Duration::from_secs(30),
|
||||||
|
client.transport.fetch_attachment(server_id, &att),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("fetch did not time out")
|
||||||
|
.expect("fetch succeeded");
|
||||||
|
|
||||||
|
assert_eq!(fetched.len(), blob.len(), "fetched the full blob");
|
||||||
|
assert_eq!(fetched, blob, "fetched bytes match served bytes exactly");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn loopback_unknown_id_reports_gone() {
|
||||||
|
let server = spawn_node().await;
|
||||||
|
let client = spawn_node().await;
|
||||||
|
|
||||||
|
server.lookup.add_endpoint_info(client.endpoint.addr());
|
||||||
|
client.lookup.add_endpoint_info(server.endpoint.addr());
|
||||||
|
|
||||||
|
let server_id = server.endpoint.id();
|
||||||
|
let client_id = client.endpoint.id();
|
||||||
|
server.transport.admit_audio_sender(client_id);
|
||||||
|
client.transport.admit_audio_sender(server_id);
|
||||||
|
client.transport.connect_peer(server.endpoint.addr()).await;
|
||||||
|
|
||||||
|
// Never served — the handler closes with an empty body and the fetcher must
|
||||||
|
// surface that as an error, not hang or return empty bytes.
|
||||||
|
let att = ChatAttachment {
|
||||||
|
name: "missing.bin".to_string(),
|
||||||
|
size: 4096,
|
||||||
|
kind: AttachmentKind::File,
|
||||||
|
id: [7u8; 32],
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = tokio::time::timeout(
|
||||||
|
Duration::from_secs(30),
|
||||||
|
client.transport.fetch_attachment(server_id, &att),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("fetch did not time out");
|
||||||
|
|
||||||
|
assert!(result.is_err(), "unknown id should error, got {result:?}");
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
//! Phase-0 spike for post-grace gossip recovery.
|
||||||
|
//!
|
||||||
|
//! These tests prove that `GossipSender::join_peers` can restore an existing
|
||||||
|
//! topic subscription after the other peer drops and rejoins without its own
|
||||||
|
//! bootstrap target. The second case disables relays, clears the surviving
|
||||||
|
//! node's lookup, and moves the peer to a fresh endpoint address so only the
|
||||||
|
//! retained full address passed to `rebootstrap_peers` can drive recovery.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use iroh::address_lookup::memory::MemoryLookup;
|
||||||
|
use iroh::endpoint::presets;
|
||||||
|
use iroh::protocol::Router;
|
||||||
|
use iroh::{Endpoint, EndpointAddr, EndpointId, RelayMode, SecretKey};
|
||||||
|
use iroh_gossip::net::Gossip;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
use peerspeak::network::gossip::IrohGossipState;
|
||||||
|
use peerspeak::network::{PeerSpeakTicket, PeerState, RoomEvent, RoomState};
|
||||||
|
|
||||||
|
const EVENT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
struct GossipNode {
|
||||||
|
endpoint: Endpoint,
|
||||||
|
lookup: MemoryLookup,
|
||||||
|
room: Arc<IrohGossipState>,
|
||||||
|
_router: Router,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn spawn_node(secret: SecretKey) -> GossipNode {
|
||||||
|
let lookup = MemoryLookup::new();
|
||||||
|
let endpoint = Endpoint::builder(presets::Minimal)
|
||||||
|
.secret_key(secret.clone())
|
||||||
|
.relay_mode(RelayMode::Disabled)
|
||||||
|
.address_lookup(lookup.clone())
|
||||||
|
.bind()
|
||||||
|
.await
|
||||||
|
.expect("bind gossip endpoint");
|
||||||
|
let gossip = Gossip::builder().spawn(endpoint.clone());
|
||||||
|
let router = Router::builder(endpoint.clone())
|
||||||
|
.accept(iroh_gossip::net::GOSSIP_ALPN, gossip.clone())
|
||||||
|
.spawn();
|
||||||
|
let room = Arc::new(IrohGossipState::new(
|
||||||
|
endpoint.clone(),
|
||||||
|
gossip,
|
||||||
|
lookup.clone(),
|
||||||
|
secret,
|
||||||
|
));
|
||||||
|
|
||||||
|
GossipNode {
|
||||||
|
endpoint,
|
||||||
|
lookup,
|
||||||
|
room,
|
||||||
|
_router: router,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state(name: &str, addr: EndpointAddr) -> PeerState {
|
||||||
|
PeerState {
|
||||||
|
name: name.to_string(),
|
||||||
|
is_muted: false,
|
||||||
|
addr,
|
||||||
|
sharing: None,
|
||||||
|
avatar: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn await_joined(rx: &mut mpsc::Receiver<RoomEvent>, peer_id: EndpointId) -> PeerState {
|
||||||
|
let deadline = tokio::time::Instant::now() + EVENT_TIMEOUT;
|
||||||
|
loop {
|
||||||
|
match tokio::time::timeout_at(deadline, rx.recv()).await {
|
||||||
|
Ok(Some(RoomEvent::PeerJoined(id, peer_state))) if id == peer_id => return peer_state,
|
||||||
|
Ok(Some(_)) => continue,
|
||||||
|
Ok(None) => panic!("room event channel closed while waiting for PeerJoined"),
|
||||||
|
Err(_) => panic!("timed out waiting for PeerJoined({peer_id:?})"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn await_joined_all(rx: &mut mpsc::Receiver<RoomEvent>, peer_ids: &[EndpointId]) {
|
||||||
|
let deadline = tokio::time::Instant::now() + EVENT_TIMEOUT;
|
||||||
|
let mut remaining = peer_ids.to_vec();
|
||||||
|
while !remaining.is_empty() {
|
||||||
|
match tokio::time::timeout_at(deadline, rx.recv()).await {
|
||||||
|
Ok(Some(RoomEvent::PeerJoined(id, _))) => remaining.retain(|wanted| *wanted != id),
|
||||||
|
Ok(Some(_)) => {}
|
||||||
|
Ok(None) => panic!("room event channel closed while waiting for PeerJoined set"),
|
||||||
|
Err(_) => panic!("timed out waiting for PeerJoined set: {remaining:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn await_left(rx: &mut mpsc::Receiver<RoomEvent>, peer_id: EndpointId) {
|
||||||
|
let deadline = tokio::time::Instant::now() + EVENT_TIMEOUT;
|
||||||
|
loop {
|
||||||
|
match tokio::time::timeout_at(deadline, rx.recv()).await {
|
||||||
|
Ok(Some(RoomEvent::PeerLeft(id))) if id == peer_id => return,
|
||||||
|
Ok(Some(_)) => continue,
|
||||||
|
Ok(None) => panic!("room event channel closed while waiting for PeerLeft"),
|
||||||
|
Err(_) => panic!("timed out waiting for PeerLeft({peer_id:?})"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn await_absent(room: &IrohGossipState, peer_id: EndpointId) {
|
||||||
|
let deadline = tokio::time::Instant::now() + EVENT_TIMEOUT;
|
||||||
|
loop {
|
||||||
|
if !room.active_peers().iter().any(|(id, _)| *id == peer_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
tokio::time::Instant::now() < deadline,
|
||||||
|
"timed out waiting for peer to leave the roster"
|
||||||
|
);
|
||||||
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ticket(host_addr: EndpointAddr) -> String {
|
||||||
|
PeerSpeakTicket {
|
||||||
|
host_addr,
|
||||||
|
topic_id: rand::random(),
|
||||||
|
name: "rebootstrap-spike".to_string(),
|
||||||
|
}
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn establish_room(
|
||||||
|
a: &GossipNode,
|
||||||
|
b: &GossipNode,
|
||||||
|
ticket: &str,
|
||||||
|
events_a: &mut mpsc::Receiver<RoomEvent>,
|
||||||
|
) {
|
||||||
|
// B is the ticket host. Its own bootstrap set is empty; A is the only side
|
||||||
|
// that initially dials, which is also how the recovery setup is controlled.
|
||||||
|
b.room
|
||||||
|
.join(ticket, state("Bob", b.endpoint.addr()), vec![])
|
||||||
|
.await
|
||||||
|
.expect("host joins topic");
|
||||||
|
a.room
|
||||||
|
.join(ticket, state("Alice", a.endpoint.addr()), vec![])
|
||||||
|
.await
|
||||||
|
.expect("client joins topic");
|
||||||
|
let joined = await_joined(events_a, b.endpoint.id()).await;
|
||||||
|
assert_eq!(joined.name, "Bob");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rebootstrap_restores_roster_on_existing_subscription() {
|
||||||
|
let a = spawn_node(SecretKey::generate()).await;
|
||||||
|
let b = spawn_node(SecretKey::generate()).await;
|
||||||
|
let ticket = ticket(b.endpoint.addr());
|
||||||
|
let mut events_a = a.room.subscribe_events().await.expect("subscribe A events");
|
||||||
|
|
||||||
|
establish_room(&a, &b, &ticket, &mut events_a).await;
|
||||||
|
|
||||||
|
// Drop only B's topic subscription. A stays subscribed. B then rejoins as
|
||||||
|
// the ticket host, so compute_bootstrap removes self and B has nobody to dial.
|
||||||
|
b.room.leave().await.expect("B leaves topic");
|
||||||
|
await_absent(&a.room, b.endpoint.id()).await;
|
||||||
|
b.room
|
||||||
|
.join(&ticket, state("Bob recovered", b.endpoint.addr()), vec![])
|
||||||
|
.await
|
||||||
|
.expect("B rejoins without bootstrap peers");
|
||||||
|
|
||||||
|
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||||
|
assert!(
|
||||||
|
!a.room
|
||||||
|
.active_peers()
|
||||||
|
.iter()
|
||||||
|
.any(|(id, _)| *id == b.endpoint.id()),
|
||||||
|
"B must not recover before A explicitly re-bootstraps it"
|
||||||
|
);
|
||||||
|
|
||||||
|
a.room
|
||||||
|
.rebootstrap_peers(vec![b.endpoint.addr()])
|
||||||
|
.await
|
||||||
|
.expect("targeted gossip re-bootstrap");
|
||||||
|
|
||||||
|
let recovered = await_joined(&mut events_a, b.endpoint.id()).await;
|
||||||
|
assert_eq!(recovered.name, "Bob recovered");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rebootstrap_uses_retained_full_address_with_empty_lookup() {
|
||||||
|
let a = spawn_node(SecretKey::generate()).await;
|
||||||
|
let b_secret = SecretKey::generate();
|
||||||
|
let b = spawn_node(b_secret.clone()).await;
|
||||||
|
let b_id = b.endpoint.id();
|
||||||
|
let old_b_addr = b.endpoint.addr();
|
||||||
|
let ticket = ticket(old_b_addr.clone());
|
||||||
|
let mut events_a = a.room.subscribe_events().await.expect("subscribe A events");
|
||||||
|
|
||||||
|
establish_room(&a, &b, &ticket, &mut events_a).await;
|
||||||
|
b.room.leave().await.expect("old B leaves topic");
|
||||||
|
await_absent(&a.room, b_id).await;
|
||||||
|
|
||||||
|
// Move the same authenticated identity to a newly-bound direct-only endpoint.
|
||||||
|
// The old cached path is now dead; the new full address is the only valid one.
|
||||||
|
b.endpoint.close().await;
|
||||||
|
drop(b);
|
||||||
|
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||||
|
let b_rebound = spawn_node(b_secret).await;
|
||||||
|
let new_b_addr = b_rebound.endpoint.addr();
|
||||||
|
assert_eq!(new_b_addr.id, b_id, "identity must survive the rebind");
|
||||||
|
assert_ne!(
|
||||||
|
new_b_addr, old_b_addr,
|
||||||
|
"rebound peer must have a fresh address"
|
||||||
|
);
|
||||||
|
|
||||||
|
b_rebound
|
||||||
|
.room
|
||||||
|
.join(&ticket, state("Bob rebound", new_b_addr.clone()), vec![])
|
||||||
|
.await
|
||||||
|
.expect("rebound host joins without bootstrap peers");
|
||||||
|
|
||||||
|
// Remove the stale lookup entry. `rebootstrap_peers` must seed the retained
|
||||||
|
// new full address before asking gossip to join the peer by id.
|
||||||
|
a.lookup.remove_endpoint_info(b_id);
|
||||||
|
assert!(
|
||||||
|
a.lookup.get_endpoint_info(b_id).is_none(),
|
||||||
|
"A lookup starts empty for B"
|
||||||
|
);
|
||||||
|
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||||
|
assert!(
|
||||||
|
!a.room.active_peers().iter().any(|(id, _)| *id == b_id),
|
||||||
|
"rebound B must not be rediscovered without the retained address"
|
||||||
|
);
|
||||||
|
|
||||||
|
a.room
|
||||||
|
.rebootstrap_peers(vec![new_b_addr])
|
||||||
|
.await
|
||||||
|
.expect("retained-address gossip re-bootstrap");
|
||||||
|
assert!(
|
||||||
|
a.lookup.get_endpoint_info(b_id).is_some(),
|
||||||
|
"re-bootstrap must restore B's address to the lookup"
|
||||||
|
);
|
||||||
|
|
||||||
|
let recovered = await_joined(&mut events_a, b_id).await;
|
||||||
|
assert_eq!(recovered.name, "Bob rebound");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn demoted_peer_requires_a_fresh_signed_announce_to_rejoin() {
|
||||||
|
let a = spawn_node(SecretKey::generate()).await;
|
||||||
|
let b = spawn_node(SecretKey::generate()).await;
|
||||||
|
let ticket = ticket(b.endpoint.addr());
|
||||||
|
let mut events_a = a.room.subscribe_events().await.expect("subscribe A events");
|
||||||
|
|
||||||
|
establish_room(&a, &b, &ticket, &mut events_a).await;
|
||||||
|
a.room.mark_peer_disconnected(b.endpoint.id());
|
||||||
|
assert!(
|
||||||
|
!a.room
|
||||||
|
.active_peers()
|
||||||
|
.iter()
|
||||||
|
.any(|(id, _)| *id == b.endpoint.id()),
|
||||||
|
"demotion must revoke live roster membership"
|
||||||
|
);
|
||||||
|
|
||||||
|
b.room
|
||||||
|
.update_self_state(state("Bob authenticated again", b.endpoint.addr()))
|
||||||
|
.await
|
||||||
|
.expect("broadcast fresh signed announce");
|
||||||
|
|
||||||
|
let recovered = await_joined(&mut events_a, b.endpoint.id()).await;
|
||||||
|
assert_eq!(recovered.name, "Bob authenticated again");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn signed_leave_after_demotion_still_emits_peer_left() {
|
||||||
|
let a = spawn_node(SecretKey::generate()).await;
|
||||||
|
let b = spawn_node(SecretKey::generate()).await;
|
||||||
|
let ticket = ticket(b.endpoint.addr());
|
||||||
|
let mut events_a = a.room.subscribe_events().await.expect("subscribe A events");
|
||||||
|
|
||||||
|
establish_room(&a, &b, &ticket, &mut events_a).await;
|
||||||
|
a.room.mark_peer_disconnected(b.endpoint.id());
|
||||||
|
|
||||||
|
b.room
|
||||||
|
.leave()
|
||||||
|
.await
|
||||||
|
.expect("broadcast signed Leave after demotion");
|
||||||
|
await_left(&mut events_a, b.endpoint.id()).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn targeted_rebootstrap_preserves_healthy_peer_in_three_peer_room() {
|
||||||
|
let a = spawn_node(SecretKey::generate()).await;
|
||||||
|
let b = spawn_node(SecretKey::generate()).await;
|
||||||
|
let c_secret = SecretKey::generate();
|
||||||
|
let c = spawn_node(c_secret.clone()).await;
|
||||||
|
let c_id = c.endpoint.id();
|
||||||
|
let ticket = ticket(c.endpoint.addr());
|
||||||
|
let mut events_a = a.room.subscribe_events().await.expect("subscribe A events");
|
||||||
|
let mut events_b = b.room.subscribe_events().await.expect("subscribe B events");
|
||||||
|
|
||||||
|
c.room
|
||||||
|
.join(&ticket, state("Carol", c.endpoint.addr()), vec![])
|
||||||
|
.await
|
||||||
|
.expect("C hosts topic");
|
||||||
|
b.room
|
||||||
|
.join(&ticket, state("Bob", b.endpoint.addr()), vec![])
|
||||||
|
.await
|
||||||
|
.expect("B joins C");
|
||||||
|
await_joined(&mut events_b, c_id).await;
|
||||||
|
a.room
|
||||||
|
.join(&ticket, state("Alice", a.endpoint.addr()), vec![])
|
||||||
|
.await
|
||||||
|
.expect("A joins C");
|
||||||
|
await_joined_all(&mut events_a, &[b.endpoint.id(), c_id]).await;
|
||||||
|
await_joined(&mut events_b, a.endpoint.id()).await;
|
||||||
|
|
||||||
|
// Remove only C. A and B keep their existing topic subscriptions and remain
|
||||||
|
// mutually present while C is rebound to a fresh address.
|
||||||
|
c.endpoint.close().await;
|
||||||
|
drop(c);
|
||||||
|
a.room.mark_peer_disconnected(c_id);
|
||||||
|
b.room.mark_peer_disconnected(c_id);
|
||||||
|
let c_rebound = spawn_node(c_secret).await;
|
||||||
|
let rebound_addr = c_rebound.endpoint.addr();
|
||||||
|
c_rebound
|
||||||
|
.room
|
||||||
|
.join(
|
||||||
|
&ticket,
|
||||||
|
state("Carol recovered", rebound_addr.clone()),
|
||||||
|
vec![],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("rebound C rejoins as host without bootstrap");
|
||||||
|
|
||||||
|
a.room
|
||||||
|
.rebootstrap_peers(vec![rebound_addr])
|
||||||
|
.await
|
||||||
|
.expect("A targets only C for recovery");
|
||||||
|
let recovered = await_joined(&mut events_a, c_id).await;
|
||||||
|
assert_eq!(recovered.name, "Carol recovered");
|
||||||
|
await_joined(&mut events_b, c_id).await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
a.room
|
||||||
|
.active_peers()
|
||||||
|
.iter()
|
||||||
|
.any(|(id, _)| *id == b.endpoint.id()),
|
||||||
|
"healthy B must remain present at A throughout C recovery"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
b.room
|
||||||
|
.active_peers()
|
||||||
|
.iter()
|
||||||
|
.any(|(id, _)| *id == a.endpoint.id()),
|
||||||
|
"healthy A must remain present at B throughout C recovery"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,8 +25,7 @@ use peerspeak::codec::opus_impl::OpusEncoder;
|
|||||||
use peerspeak::core::jitter::{FRAME_SAMPLES, JitterBuffer};
|
use peerspeak::core::jitter::{FRAME_SAMPLES, JitterBuffer};
|
||||||
use peerspeak::network::{ConnEvent, NetworkTransport};
|
use peerspeak::network::{ConnEvent, NetworkTransport};
|
||||||
use peerspeak::network::iroh_impl::{AudioRouter, IrohTransport};
|
use peerspeak::network::iroh_impl::{AudioRouter, IrohTransport};
|
||||||
|
use peerspeak::protocol::AUDIO_ALPN;
|
||||||
const AUDIO_ALPN: &[u8] = b"peerspeak-audio";
|
|
||||||
|
|
||||||
struct Node {
|
struct Node {
|
||||||
endpoint: Endpoint,
|
endpoint: Endpoint,
|
||||||
@@ -169,6 +168,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");
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Cross-compile peerspeak to x86_64-pc-windows-gnu (run inside the peerspeak-win distrobox).
|
||||||
|
# Produces a statically-linked, self-contained .exe (no extra DLLs) for the
|
||||||
|
# Windows installer in packaging/windows/. Requires the rust-src component and
|
||||||
|
# the x86_64-pc-windows-gnu target; invoke with build-std for the static link:
|
||||||
|
# RUSTC_BOOTSTRAP=1 ./win-cross-build.sh -Z build-std=std,panic_abort
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
export CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc
|
||||||
|
export CC_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc
|
||||||
|
export CXX_x86_64_pc_windows_gnu=x86_64-w64-mingw32-g++
|
||||||
|
export AR_x86_64_pc_windows_gnu=x86_64-w64-mingw32-ar
|
||||||
|
# Bundled libopus declares cmake_minimum_required < 3.5; cmake 4.x refuses it.
|
||||||
|
export CMAKE_POLICY_VERSION_MINIMUM=3.5
|
||||||
|
|
||||||
|
cargo build --release --target x86_64-pc-windows-gnu --bin peerspeak "$@"
|
||||||