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 |
@@ -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/*.pkg.tar.*
|
||||
/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"
|
||||
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]]
|
||||
name = "android-activity"
|
||||
version = "0.6.1"
|
||||
@@ -114,12 +148,12 @@ dependencies = [
|
||||
"android-properties",
|
||||
"bitflags 2.11.1",
|
||||
"cc",
|
||||
"jni",
|
||||
"jni 0.22.4",
|
||||
"libc",
|
||||
"log",
|
||||
"ndk",
|
||||
"ndk 0.9.0",
|
||||
"ndk-context",
|
||||
"ndk-sys",
|
||||
"ndk-sys 0.6.0+11769913",
|
||||
"num_enum",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
@@ -712,6 +746,12 @@ dependencies = [
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cesu8"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
||||
|
||||
[[package]]
|
||||
name = "cexpr"
|
||||
version = "0.6.0"
|
||||
@@ -1002,6 +1042,40 @@ dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coreaudio-rs"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation-sys",
|
||||
"coreaudio-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coreaudio-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]]
|
||||
name = "cosmic-text"
|
||||
version = "0.15.0"
|
||||
@@ -1026,6 +1100,59 @@ dependencies = [
|
||||
"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]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -1228,6 +1355,12 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_sample"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.0"
|
||||
@@ -1487,6 +1620,15 @@ version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
@@ -1622,6 +1764,12 @@ dependencies = [
|
||||
"zune-inflate",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "extended"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
@@ -2227,7 +2375,7 @@ dependencies = [
|
||||
"http",
|
||||
"idna",
|
||||
"ipnet",
|
||||
"jni",
|
||||
"jni 0.22.4",
|
||||
"rand 0.10.1",
|
||||
"rustls",
|
||||
"thiserror 2.0.18",
|
||||
@@ -2247,7 +2395,7 @@ dependencies = [
|
||||
"data-encoding",
|
||||
"idna",
|
||||
"ipnet",
|
||||
"jni",
|
||||
"jni 0.22.4",
|
||||
"once_cell",
|
||||
"prefix-trie",
|
||||
"rand 0.10.1",
|
||||
@@ -2270,7 +2418,7 @@ dependencies = [
|
||||
"hickory-proto",
|
||||
"ipconfig",
|
||||
"ipnet",
|
||||
"jni",
|
||||
"jni 0.22.4",
|
||||
"moka",
|
||||
"ndk-context",
|
||||
"once_cell",
|
||||
@@ -2480,6 +2628,7 @@ dependencies = [
|
||||
"iced_core",
|
||||
"log",
|
||||
"rustc-hash 2.1.2",
|
||||
"tokio",
|
||||
"wasm-bindgen-futures",
|
||||
"wasmtimer",
|
||||
]
|
||||
@@ -3102,6 +3251,22 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
|
||||
dependencies = [
|
||||
"cesu8",
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"thiserror 1.0.69",
|
||||
"walkdir",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.22.4"
|
||||
@@ -3463,6 +3628,24 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f"
|
||||
|
||||
[[package]]
|
||||
name = "mach2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mach2"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "malloc_buf"
|
||||
version = "0.0.6"
|
||||
@@ -3596,7 +3779,7 @@ dependencies = [
|
||||
"dispatch",
|
||||
"futures-channel",
|
||||
"futures-lite",
|
||||
"jni",
|
||||
"jni 0.22.4",
|
||||
"ndk-context",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
@@ -3695,6 +3878,20 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"ndk-sys 0.5.0+25.2.9519653",
|
||||
"num_enum",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@@ -3704,7 +3901,7 @@ dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"ndk-sys",
|
||||
"ndk-sys 0.6.0+11769913",
|
||||
"num_enum",
|
||||
"raw-window-handle",
|
||||
"thiserror 1.0.69",
|
||||
@@ -3716,6 +3913,15 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
|
||||
|
||||
[[package]]
|
||||
name = "ndk-sys"
|
||||
version = "0.5.0+25.2.9519653"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691"
|
||||
dependencies = [
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk-sys"
|
||||
version = "0.6.0+11769913"
|
||||
@@ -4127,6 +4333,31 @@ dependencies = [
|
||||
"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]]
|
||||
name = "objc2-cloud-kit"
|
||||
version = "0.2.2"
|
||||
@@ -4162,6 +4393,29 @@ dependencies = [
|
||||
"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]]
|
||||
name = "objc2-core-data"
|
||||
version = "0.2.2"
|
||||
@@ -4466,6 +4720,29 @@ dependencies = [
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oboe"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
|
||||
dependencies = [
|
||||
"jni 0.21.1",
|
||||
"ndk 0.8.0",
|
||||
"ndk-context",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
"oboe-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oboe-sys"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -4594,12 +4871,13 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "peerspeak"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"base64",
|
||||
"bytes",
|
||||
"cpal 0.15.3",
|
||||
"dirs",
|
||||
"iced",
|
||||
"image",
|
||||
@@ -4610,6 +4888,7 @@ dependencies = [
|
||||
"rand 0.10.1",
|
||||
"rfd",
|
||||
"ringbuf",
|
||||
"rodio",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
@@ -5049,6 +5328,16 @@ version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
@@ -5338,12 +5627,34 @@ dependencies = [
|
||||
"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]]
|
||||
name = "roxmltree"
|
||||
version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
||||
|
||||
[[package]]
|
||||
name = "rtrb"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ade083ccbb4bf536df69d1f6432cc23deb7acccff86b183f3923a6fd56a1153"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
@@ -5436,7 +5747,7 @@ checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"jni",
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
@@ -5877,7 +6188,7 @@ dependencies = [
|
||||
"fastrand",
|
||||
"js-sys",
|
||||
"memmap2",
|
||||
"ndk",
|
||||
"ndk 0.9.0",
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
@@ -6007,6 +6318,153 @@ dependencies = [
|
||||
"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]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
@@ -7162,7 +7620,7 @@ dependencies = [
|
||||
"log",
|
||||
"metal",
|
||||
"naga",
|
||||
"ndk-sys",
|
||||
"ndk-sys 0.6.0+11769913",
|
||||
"objc",
|
||||
"once_cell",
|
||||
"ordered-float",
|
||||
@@ -7247,6 +7705,16 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.54.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
|
||||
dependencies = [
|
||||
"windows-core 0.54.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.58.0"
|
||||
@@ -7254,7 +7722,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
|
||||
dependencies = [
|
||||
"windows-core 0.58.0",
|
||||
"windows-targets",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7278,6 +7746,16 @@ dependencies = [
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.54.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
|
||||
dependencies = [
|
||||
"windows-result 0.1.2",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.58.0"
|
||||
@@ -7288,7 +7766,7 @@ dependencies = [
|
||||
"windows-interface 0.58.0",
|
||||
"windows-result 0.2.0",
|
||||
"windows-strings 0.1.0",
|
||||
"windows-targets",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7386,13 +7864,22 @@ dependencies = [
|
||||
"windows-strings 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7411,7 +7898,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
|
||||
dependencies = [
|
||||
"windows-result 0.2.0",
|
||||
"windows-targets",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7423,13 +7910,22 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
|
||||
dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7441,20 +7937,35 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.42.2",
|
||||
"windows_aarch64_msvc 0.42.2",
|
||||
"windows_i686_gnu 0.42.2",
|
||||
"windows_i686_msvc 0.42.2",
|
||||
"windows_x86_64_gnu 0.42.2",
|
||||
"windows_x86_64_gnullvm 0.42.2",
|
||||
"windows_x86_64_msvc 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7466,18 +7977,36 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -7490,24 +8019,48 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -7536,7 +8089,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"libc",
|
||||
"memmap2",
|
||||
"ndk",
|
||||
"ndk 0.9.0",
|
||||
"objc2 0.5.2",
|
||||
"objc2-app-kit 0.2.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
[package]
|
||||
name = "peerspeak"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
edition = "2024"
|
||||
# Application crate, not a crates.io library — refuse `cargo publish` and let
|
||||
# cargo-deny's [licenses.private] skip the missing-license check.
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "peerspeak"
|
||||
@@ -25,23 +28,40 @@ async-trait = "0.1.89"
|
||||
base64 = "0.22.1"
|
||||
bytes = "1.11.1"
|
||||
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
|
||||
# the codec surface small) and a native file picker (xdg-portal backend, no GTK).
|
||||
# the codec surface small). The matching native file picker (`rfd`) is platform-
|
||||
# gated below — its backend differs per OS (xdg-portal on Linux, Win32 on Windows).
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
rfd = { version = "0.17", default-features = false, features = ["xdg-portal"] }
|
||||
iroh = "1.0.0-rc.0"
|
||||
iroh-gossip = "0.99.0"
|
||||
opus = "0.3.1"
|
||||
# v0_3_49 exposes `Buffer::requested()` (the graph's per-cycle quantum), used by
|
||||
# the playback RT callback to fill exactly what the device asks for instead of
|
||||
# pinning the buffer to a hard-coded 1024-frame quantum (crackle on non-1024
|
||||
# hardware). The field has existed in libpipewire since 0.3.49 (2022).
|
||||
pipewire = { version = "0.9", features = ["v0_3_49"] }
|
||||
rand = "0.10.1"
|
||||
ringbuf = "0.5.0"
|
||||
rodio = "0.22.2"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.150"
|
||||
thiserror = "2.0.18"
|
||||
tokio = { version = "1.52.3", features = ["full"] }
|
||||
tokio-stream = "0.1.18"
|
||||
|
||||
# --- Platform-specific dependencies -----------------------------------------
|
||||
# Audio and the native file-picker backends differ per OS. Everything else in the
|
||||
# app talks to the `AudioBackend` trait and the `PlatformAudioBackend` alias (see
|
||||
# `src/audio/mod.rs`), so platform selection is confined to these few lines.
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Linux audio backend. v0_3_49 exposes `Buffer::requested()` (the graph's per-cycle
|
||||
# quantum), used by the playback RT callback to fill exactly what the device asks
|
||||
# for instead of a hard-coded 1024-frame quantum (crackle on non-1024 hardware).
|
||||
# The field has existed in libpipewire since 0.3.49 (2022).
|
||||
pipewire = { version = "0.9", features = ["v0_3_49"] }
|
||||
# Native file picker via the XDG desktop portal (no GTK) on Linux.
|
||||
rfd = { version = "0.17", default-features = false, features = ["xdg-portal"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
# Native file picker using the built-in Win32 dialog backend on Windows.
|
||||
rfd = { version = "0.17", default-features = false }
|
||||
# Windows audio backend: cpal drives WASAPI for capture/playback behind the
|
||||
# AudioBackend trait (src/audio/cpal_impl.rs). The Linux counterpart is pipewire.
|
||||
cpal = "0.15"
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Security Review: `security-scan` branch (PeerSpeak)
|
||||
|
||||
_Date: 2026-06-18_
|
||||
|
||||
**Scope:** Protocol-versioning migration (`src/protocol.rs`, `versioned_topic`,
|
||||
ALPN/domain centralization, gossip topic namespacing) and the `deny.toml`
|
||||
supply-chain policy addition.
|
||||
|
||||
## Result: No high-confidence security vulnerabilities found.
|
||||
|
||||
Each plausible attack surface introduced by this branch was investigated and
|
||||
confirmed safe:
|
||||
|
||||
### 1. `versioned_topic` XOR transform — topic secrecy preserved
|
||||
`src/protocol.rs:46`, used at `src/network/gossip.rs:255`
|
||||
|
||||
The room `topic_id` is a uniformly random 32-byte secret (`rand::random()`,
|
||||
`src/core/mod.rs:1012`) acting as the room capability. XOR-ing it with the public
|
||||
constant `GOSSIP_PROTO.to_le_bytes()` cyclically is **bijective and
|
||||
entropy-preserving** — the result is still uniformly random; no byte becomes
|
||||
predictable and no entropy is lost. The room secret is no more recoverable by an
|
||||
observer than before the change (previously the raw `topic_id` was the on-wire
|
||||
topic; now it's a trivial public XOR of it). Bijectivity also preserves room
|
||||
distinctness, so isolation is not weakened. **Not a vulnerability.**
|
||||
|
||||
### 2. Signature topic-binding — no raw/versioned confusion
|
||||
`src/network/gossip.rs`
|
||||
|
||||
`active_topic_bytes` stores the **raw** `ticket.topic_id` (line 293), and both
|
||||
`sign_gossip` and `verify_gossip` bind against that raw value. Only the
|
||||
*subscribed* swarm topic (line 255) uses the versioned value. There is one swarm
|
||||
per join and every peer signs/verifies against the same raw topic, so no second
|
||||
topic exists to enable a raw↔versioned replay/confusion attack. Code matches
|
||||
VERSIONING.md's claim. **Not a vulnerability.**
|
||||
|
||||
### 3. `GOSSIP_SIG_DOMAIN` — moved verbatim
|
||||
Value identical (`"peerspeak-gossip-v1"`, `src/protocol.rs:34`); cross-version
|
||||
cryptographic domain separation preserved. **Not a vulnerability.**
|
||||
|
||||
### 4. ALPN changes — handshake compatibility only
|
||||
Audio `peerspeak-audio` → `peerspeak/audio/1`, friends `/0` → `/1`. No security
|
||||
check keys off the old ALPN strings (audio admission is gated by live room
|
||||
membership per S8, not the ALPN literal); no residual references to old strings
|
||||
in non-test code. **Not a vulnerability.**
|
||||
|
||||
### 5. `deny.toml`
|
||||
Ignores only two *unmaintained* advisories (`RUSTSEC-2024-0436`,
|
||||
`RUSTSEC-2026-0150`) on compile-time/FFI-only crates — documented, and dependency
|
||||
advisories are out of scope. **Not a vulnerability.**
|
||||
|
||||
The versioning migration is a clean, security-preserving change.
|
||||
@@ -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"?>
|
||||
<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>
|
||||
<linearGradient id="tile" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#1e1e2e"/>
|
||||
<stop offset="1" stop-color="#181825"/>
|
||||
<linearGradient id="tile" x1="32" y1="20" x2="225" y2="239" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#101d42"/>
|
||||
<stop offset="0.5" stop-color="#071225"/>
|
||||
<stop offset="1" stop-color="#160b31"/>
|
||||
</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>
|
||||
|
||||
<!-- Rounded-square tile -->
|
||||
<rect x="20" y="20" width="216" height="216" rx="48" fill="url(#tile)"
|
||||
stroke="#313244" stroke-width="3"/>
|
||||
<!-- A dark stage makes the cyan/violet conversation mark legible at taskbar size. -->
|
||||
<rect x="8" y="8" width="240" height="240" rx="55" fill="url(#tile)"/>
|
||||
<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) -->
|
||||
<g stroke="#45475a" stroke-width="6" stroke-linecap="round" fill="none">
|
||||
<line x1="74" y1="74" x2="128" y2="128"/>
|
||||
<line x1="182" y1="74" x2="128" y2="128"/>
|
||||
<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>
|
||||
<!-- Broad color glow, kept behind the silhouette. -->
|
||||
<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"
|
||||
fill="none" stroke="url(#voice)" stroke-width="25" stroke-linecap="round"
|
||||
opacity="0.5" filter="url(#shadow)"/>
|
||||
|
||||
<!-- P2P mesh: peer nodes -->
|
||||
<g fill="#b4befe">
|
||||
<circle cx="74" cy="74" r="11"/>
|
||||
<circle cx="182" cy="74" r="11"/>
|
||||
<circle cx="74" cy="182" r="11"/>
|
||||
<circle cx="182" cy="182" r="11"/>
|
||||
</g>
|
||||
<!-- The two waveform halves are equal peers and meet at one bright point. -->
|
||||
<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"
|
||||
fill="none" stroke="url(#voice)" stroke-width="17" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<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"
|
||||
fill="none" stroke="url(#voice)" stroke-width="17" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
|
||||
<!-- Microphone (hero) — same geometry as the in-app Mic icon, scaled 6.4x -->
|
||||
<g fill="none" stroke="#89b4fa" stroke-width="13"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="108.8" y="68.8" width="38.4" height="70.4" rx="19.2"/>
|
||||
<path d="M 169.6 123.2 A 41.6 41.6 0 0 0 86.4 123.2"/>
|
||||
<line x1="128" y1="164.8" x2="128" y2="187.2"/>
|
||||
<line x1="105.6" y1="187.2" x2="150.4" y2="187.2"/>
|
||||
</g>
|
||||
<!-- A single flowing connection turns the conversation into PeerSpeak's S-mark. -->
|
||||
<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"
|
||||
fill="none" stroke="#050b1b" stroke-opacity="0.72" stroke-width="33"
|
||||
stroke-linecap="round" stroke-linejoin="round" filter="url(#soft-shadow)"/>
|
||||
<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"
|
||||
fill="none" stroke="url(#voice)" stroke-width="25"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<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>
|
||||
|
||||
|
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>
|
||||
pkgname=peerspeak-git
|
||||
_pkgname=peerspeak
|
||||
pkgver=0.1.0
|
||||
pkgver=0.3.0.r229.g7fb1c96
|
||||
pkgrel=1
|
||||
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -56,12 +56,61 @@ pub trait AudioBackend: Send + Sync {
|
||||
fn stop(&self) -> Result<(), AudioError>;
|
||||
}
|
||||
|
||||
pub mod echo_cancel;
|
||||
pub mod clip_player;
|
||||
pub mod eq;
|
||||
pub mod gate;
|
||||
pub mod limiter;
|
||||
pub mod multitrack;
|
||||
pub mod pan;
|
||||
// Linear resamplers used by the Windows/cpal backend (W4). Platform-neutral and
|
||||
// pure, so it builds (and its tests run) everywhere even though only the cpal
|
||||
// backend wires it in.
|
||||
pub mod resample;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod echo_cancel;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod pipewire_impl;
|
||||
#[cfg(windows)]
|
||||
pub mod cpal_impl;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod pw_cli;
|
||||
pub mod recorder;
|
||||
|
||||
/// A selectable audio device for the input/output pickers. `name` is the stable
|
||||
/// identifier the backend uses to request the device (`target_node`);
|
||||
/// `description` is the human-facing label shown in the UI. The two may be equal
|
||||
/// (cpal/WASAPI) or differ (PipeWire node name vs. description).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AudioDevice {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub is_input: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AudioDevice {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.description)
|
||||
}
|
||||
}
|
||||
|
||||
// Enumerate audio input/output devices for the pickers (sorted by description),
|
||||
// returning the same `AudioDevice` shape regardless of platform: PipeWire
|
||||
// (`pw-cli`) on Linux, cpal/WASAPI on Windows.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use pw_cli::enumerate_audio_devices;
|
||||
#[cfg(windows)]
|
||||
pub use cpal_impl::enumerate_audio_devices;
|
||||
|
||||
/// The audio backend implementation for the current platform.
|
||||
///
|
||||
/// The whole app constructs and threads this alias (via
|
||||
/// `PlatformAudioBackend::new()`) rather than any concrete backend type, so
|
||||
/// platform selection lives entirely here. Both implementations satisfy the
|
||||
/// [`AudioBackend`] trait, which is the only interface the core talks to.
|
||||
///
|
||||
/// - Linux → PipeWire ([`pipewire_impl::PipeWireBackend`]).
|
||||
/// - Windows → cpal/WASAPI ([`cpal_impl::CpalBackend`]).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub type PlatformAudioBackend = pipewire_impl::PipeWireBackend;
|
||||
#[cfg(windows)]
|
||||
pub type PlatformAudioBackend = cpal_impl::CpalBackend;
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
use super::AudioDevice;
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AudioDevice {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub is_input: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AudioDevice {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.description)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enumerate_audio_devices() -> Vec<AudioDevice> {
|
||||
let output = Command::new("pw-cli")
|
||||
.arg("list-objects")
|
||||
|
||||
@@ -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.
|
||||
//!
|
||||
//! Drives a phase-continuous sine tone through the *real* PipeWire playback path
|
||||
//! (`PipeWireBackend::start_playback`), using the *same* fill-paced production
|
||||
//! the production mixer uses (`core/mod.rs`): generate a frame only while the
|
||||
//! Drives a phase-continuous sine tone through the *real* playback path
|
||||
//! (PipeWire on Linux, cpal/WASAPI on Windows), using the *same* fill-paced
|
||||
//! production the production mixer uses (`core/mod.rs`): generate a frame only while the
|
||||
//! playback ring is below `PLAYBACK_TARGET_SAMPLES`, so production tracks the
|
||||
//! PipeWire hardware clock. No network, no microphone — this isolates the local
|
||||
//! output path so we can confirm the clock-paced playout is glitch-free.
|
||||
//! hardware clock. No network, no microphone — this isolates the local output
|
||||
//! path so we can confirm the clock-paced playout is glitch-free.
|
||||
//!
|
||||
//! Use your ears on the tone (any click/pop is a glitch) together with the
|
||||
//! `playout-health:` lines tailed to stdout:
|
||||
@@ -17,105 +17,235 @@
|
||||
//!
|
||||
//! Run: cargo run --bin audio_probe -- [freq_hz] [seconds] [target_node]
|
||||
//! e.g. cargo run --release --bin audio_probe -- 440 30
|
||||
//!
|
||||
//! This probe exercises the platform playback backend directly: PipeWire on Linux
|
||||
//! and cpal/WASAPI on Windows. Other targets use a stub that explains the limitation.
|
||||
|
||||
use std::io::{BufRead, BufReader, Seek, SeekFrom};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use peerspeak::audio::AudioBackend;
|
||||
use peerspeak::audio::pipewire_impl::PipeWireBackend;
|
||||
use peerspeak::core::jitter::FRAME_SAMPLES; // 960 mono frames = 20ms @ 48kHz
|
||||
|
||||
const SAMPLE_RATE: f32 = 48_000.0;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let freq: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(440.0);
|
||||
let secs: u64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(30);
|
||||
let target_node: Option<String> = args.next();
|
||||
|
||||
// The playout-health logger is quiet in normal operation (it only logs
|
||||
// glitches); ask it for the full once-per-second heartbeat so the probe can
|
||||
// show the steady-state numbers.
|
||||
// SAFETY: set before any playback thread starts, so no concurrent env read.
|
||||
unsafe { std::env::set_var("PEERSPEAK_AUDIO_VERBOSE", "1") };
|
||||
|
||||
println!("audio_probe: {freq} Hz tone for {secs}s through the real playback path.");
|
||||
println!("Listen for clicks/pops; watch the playout-health lines below.\n");
|
||||
|
||||
// Tail the app log (where playout-health lines land) to stdout in the
|
||||
// background so it's all in one terminal.
|
||||
spawn_log_tailer();
|
||||
|
||||
let backend = PipeWireBackend::new();
|
||||
let (tx, rx) = mpsc::channel::<Vec<i16>>();
|
||||
let ring_fill = Arc::new(AtomicUsize::new(0));
|
||||
if let Err(e) = backend.start_playback(rx, target_node, ring_fill.clone()) {
|
||||
eprintln!("failed to start playback: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase-continuous sine, generated one 20ms frame at a time, fill-paced
|
||||
// exactly like the production mixer: only produce while the ring is below
|
||||
// target, so production tracks the PipeWire hardware clock.
|
||||
use std::sync::atomic::Ordering;
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(secs);
|
||||
let mut n: u64 = 0; // running sample index keeps phase continuous across frames
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
if ring_fill.load(Ordering::Relaxed) >= peerspeak::audio::PLAYBACK_TARGET_SAMPLES {
|
||||
tokio::time::sleep(Duration::from_millis(2)).await;
|
||||
continue;
|
||||
}
|
||||
let mut frame = Vec::with_capacity(FRAME_SAMPLES * peerspeak::audio::PLAYBACK_CHANNELS);
|
||||
for _ in 0..FRAME_SAMPLES {
|
||||
let t = n as f32 / SAMPLE_RATE;
|
||||
// 0.25 amplitude: clearly audible but not harsh.
|
||||
let sample = (0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16;
|
||||
// Stereo playback bus: duplicate the probe tone to L/R.
|
||||
frame.push(sample);
|
||||
frame.push(sample);
|
||||
n += 1;
|
||||
}
|
||||
if tx.send(frame).is_err() {
|
||||
eprintln!("playback channel closed early");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Let the ring drain, then stop.
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
let _ = backend.stop();
|
||||
println!("\naudio_probe: done.");
|
||||
#[cfg(target_os = "linux")]
|
||||
fn main() {
|
||||
unix_probe::run();
|
||||
}
|
||||
|
||||
/// Open the app log, seek to the end, and echo new lines (the `playout-health:`
|
||||
/// reports) to stdout once they appear.
|
||||
fn spawn_log_tailer() {
|
||||
let path = peerspeak::log_file_path();
|
||||
std::thread::spawn(move || {
|
||||
// Wait for the file to exist (first log_msg creates it).
|
||||
let file = loop {
|
||||
if let Ok(f) = std::fs::File::open(&path) {
|
||||
break f;
|
||||
#[cfg(windows)]
|
||||
fn main() {
|
||||
win_probe::run();
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", windows)))]
|
||||
fn main() {
|
||||
eprintln!(
|
||||
"audio_probe is only supported on Linux and Windows builds (it drives the platform playback backend directly)."
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod unix_probe {
|
||||
use std::io::{BufRead, BufReader, Seek, SeekFrom};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use peerspeak::audio::AudioBackend;
|
||||
use peerspeak::audio::pipewire_impl::PipeWireBackend;
|
||||
use peerspeak::core::jitter::FRAME_SAMPLES; // 960 mono frames = 20ms @ 48kHz
|
||||
|
||||
const SAMPLE_RATE: f32 = 48_000.0;
|
||||
|
||||
#[tokio::main]
|
||||
pub async fn run() {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let freq: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(440.0);
|
||||
let secs: u64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(30);
|
||||
let target_node: Option<String> = args.next();
|
||||
|
||||
// The playout-health logger is quiet in normal operation (it only logs
|
||||
// glitches); ask it for the full once-per-second heartbeat so the probe can
|
||||
// show the steady-state numbers.
|
||||
// SAFETY: set before any playback thread starts, so no concurrent env read.
|
||||
unsafe { std::env::set_var("PEERSPEAK_AUDIO_VERBOSE", "1") };
|
||||
|
||||
println!("audio_probe: {freq} Hz tone for {secs}s through the real playback path.");
|
||||
println!("Listen for clicks/pops; watch the playout-health lines below.\n");
|
||||
|
||||
// Tail the app log (where playout-health lines land) to stdout in the
|
||||
// background so it's all in one terminal.
|
||||
spawn_log_tailer();
|
||||
|
||||
let backend = PipeWireBackend::new();
|
||||
let (tx, rx) = mpsc::channel::<Vec<i16>>();
|
||||
let ring_fill = Arc::new(AtomicUsize::new(0));
|
||||
if let Err(e) = backend.start_playback(rx, target_node, ring_fill.clone()) {
|
||||
eprintln!("failed to start playback: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase-continuous sine, generated one 20ms frame at a time, fill-paced
|
||||
// exactly like the production mixer: only produce while the ring is below
|
||||
// target, so production tracks the PipeWire hardware clock.
|
||||
use std::sync::atomic::Ordering;
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(secs);
|
||||
let mut n: u64 = 0; // running sample index keeps phase continuous across frames
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
if ring_fill.load(Ordering::Relaxed) >= peerspeak::audio::PLAYBACK_TARGET_SAMPLES {
|
||||
tokio::time::sleep(Duration::from_millis(2)).await;
|
||||
continue;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
};
|
||||
let mut reader = BufReader::new(file);
|
||||
let _ = reader.seek(SeekFrom::End(0));
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
match reader.read_line(&mut line) {
|
||||
Ok(0) => std::thread::sleep(Duration::from_millis(150)),
|
||||
Ok(_) => {
|
||||
if line.contains("playout-health:") {
|
||||
print!("{line}");
|
||||
}
|
||||
let mut frame = Vec::with_capacity(FRAME_SAMPLES * peerspeak::audio::PLAYBACK_CHANNELS);
|
||||
for _ in 0..FRAME_SAMPLES {
|
||||
let t = n as f32 / SAMPLE_RATE;
|
||||
// 0.25 amplitude: clearly audible but not harsh.
|
||||
let sample =
|
||||
(0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16;
|
||||
// Stereo playback bus: duplicate the probe tone to L/R.
|
||||
frame.push(sample);
|
||||
frame.push(sample);
|
||||
n += 1;
|
||||
}
|
||||
if tx.send(frame).is_err() {
|
||||
eprintln!("playback channel closed early");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Let the ring drain, then stop.
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
let _ = backend.stop();
|
||||
println!("\naudio_probe: done.");
|
||||
}
|
||||
|
||||
/// Open the app log, seek to the end, and echo new lines (the `playout-health:`
|
||||
/// reports) to stdout once they appear.
|
||||
fn spawn_log_tailer() {
|
||||
let path = peerspeak::log_file_path();
|
||||
std::thread::spawn(move || {
|
||||
// Wait for the file to exist (first log_msg creates it).
|
||||
let file = loop {
|
||||
if let Ok(f) = std::fs::File::open(&path) {
|
||||
break f;
|
||||
}
|
||||
Err(_) => std::thread::sleep(Duration::from_millis(150)),
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
};
|
||||
let mut reader = BufReader::new(file);
|
||||
let _ = reader.seek(SeekFrom::End(0));
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
match reader.read_line(&mut line) {
|
||||
Ok(0) => std::thread::sleep(Duration::from_millis(150)),
|
||||
Ok(_) => {
|
||||
if line.contains("playout-health:") {
|
||||
print!("{line}");
|
||||
}
|
||||
}
|
||||
Err(_) => std::thread::sleep(Duration::from_millis(150)),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod win_probe {
|
||||
use std::io::{BufRead, BufReader, Seek, SeekFrom};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use peerspeak::audio::AudioBackend;
|
||||
use peerspeak::audio::cpal_impl::CpalBackend;
|
||||
use peerspeak::core::jitter::FRAME_SAMPLES; // 960 mono frames = 20ms @ 48kHz
|
||||
|
||||
const SAMPLE_RATE: f32 = 48_000.0;
|
||||
|
||||
#[tokio::main]
|
||||
pub async fn run() {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let freq: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(440.0);
|
||||
let secs: u64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(30);
|
||||
let target_node: Option<String> = args.next();
|
||||
|
||||
// The playout-health logger is quiet in normal operation (it only logs
|
||||
// glitches); ask it for the full once-per-second heartbeat so the probe can
|
||||
// show the steady-state numbers.
|
||||
// SAFETY: set before any playback thread starts, so no concurrent env read.
|
||||
unsafe { std::env::set_var("PEERSPEAK_AUDIO_VERBOSE", "1") };
|
||||
|
||||
println!("audio_probe: {freq} Hz tone for {secs}s through the real playback path.");
|
||||
println!("Listen for clicks/pops; watch the playout-health lines below.\n");
|
||||
|
||||
// Tail the app log (where playout-health lines land) to stdout in the
|
||||
// background so it's all in one terminal.
|
||||
spawn_log_tailer();
|
||||
|
||||
let backend = CpalBackend::new();
|
||||
let (tx, rx) = mpsc::channel::<Vec<i16>>();
|
||||
let ring_fill = Arc::new(AtomicUsize::new(0));
|
||||
if let Err(e) = backend.start_playback(rx, target_node, ring_fill.clone()) {
|
||||
eprintln!("failed to start playback: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase-continuous sine, generated one 20ms frame at a time, fill-paced
|
||||
// exactly like the production mixer: only produce while the ring is below
|
||||
// target, so production tracks the cpal/WASAPI hardware clock.
|
||||
use std::sync::atomic::Ordering;
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(secs);
|
||||
let mut n: u64 = 0; // running sample index keeps phase continuous across frames
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
if ring_fill.load(Ordering::Relaxed) >= peerspeak::audio::PLAYBACK_TARGET_SAMPLES {
|
||||
tokio::time::sleep(Duration::from_millis(2)).await;
|
||||
continue;
|
||||
}
|
||||
let mut frame = Vec::with_capacity(FRAME_SAMPLES * peerspeak::audio::PLAYBACK_CHANNELS);
|
||||
for _ in 0..FRAME_SAMPLES {
|
||||
let t = n as f32 / SAMPLE_RATE;
|
||||
// 0.25 amplitude: clearly audible but not harsh.
|
||||
let sample =
|
||||
(0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16;
|
||||
// Stereo playback bus: duplicate the probe tone to L/R.
|
||||
frame.push(sample);
|
||||
frame.push(sample);
|
||||
n += 1;
|
||||
}
|
||||
if tx.send(frame).is_err() {
|
||||
eprintln!("playback channel closed early");
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Let the ring drain, then stop.
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
let _ = backend.stop();
|
||||
println!("\naudio_probe: done.");
|
||||
}
|
||||
|
||||
/// Open the app log, seek to the end, and echo new lines (the `playout-health:`
|
||||
/// reports) to stdout once they appear.
|
||||
fn spawn_log_tailer() {
|
||||
let path = peerspeak::log_file_path();
|
||||
std::thread::spawn(move || {
|
||||
// Wait for the file to exist (first log_msg creates it).
|
||||
let file = loop {
|
||||
if let Ok(f) = std::fs::File::open(&path) {
|
||||
break f;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
};
|
||||
let mut reader = BufReader::new(file);
|
||||
let _ = reader.seek(SeekFrom::End(0));
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
match reader.read_line(&mut line) {
|
||||
Ok(0) => std::thread::sleep(Duration::from_millis(150)),
|
||||
Ok(_) => {
|
||||
if line.contains("playout-health:") {
|
||||
print!("{line}");
|
||||
}
|
||||
}
|
||||
Err(_) => std::thread::sleep(Duration::from_millis(150)),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +106,10 @@ fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_background_dim() -> f32 {
|
||||
crate::background::DEFAULT_DIM
|
||||
}
|
||||
|
||||
fn default_volume() -> f32 {
|
||||
1.0
|
||||
}
|
||||
@@ -185,6 +189,16 @@ pub struct AppConfig {
|
||||
/// Our chosen avatar (W4): monogram fallback or a bundled preset.
|
||||
#[serde(default)]
|
||||
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).
|
||||
#[serde(default)]
|
||||
pub recording_mode: RecordingMode,
|
||||
@@ -241,6 +255,15 @@ pub struct AppConfig {
|
||||
/// 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,
|
||||
@@ -280,6 +303,8 @@ impl Default for AppConfig {
|
||||
room_layout: RoomLayout::default(),
|
||||
theme: AppTheme::default(),
|
||||
avatar: crate::avatar::Avatar::default(),
|
||||
background: None,
|
||||
background_dim: default_background_dim(),
|
||||
recording_mode: RecordingMode::default(),
|
||||
custom_sound_self_join: None,
|
||||
custom_sound_peer_join: None,
|
||||
@@ -301,6 +326,8 @@ impl Default for AppConfig {
|
||||
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_height: default_window_height(),
|
||||
@@ -348,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 {
|
||||
if let Some(path) = Self::config_path()
|
||||
&& let Ok(contents) = fs::read_to_string(&path)
|
||||
@@ -430,6 +468,8 @@ mod tests {
|
||||
// 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
|
||||
|
||||
@@ -26,6 +26,10 @@ pub enum CoreCommand {
|
||||
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
|
||||
/// still show) but not mixed into our output.
|
||||
SetPeerMuted(EndpointId, bool),
|
||||
@@ -49,6 +53,14 @@ pub enum CoreCommand {
|
||||
SetRecordingMode(RecordingMode),
|
||||
/// Broadcast a room text-chat message. No-op when not in a call.
|
||||
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`).
|
||||
/// Sent at startup so screen-share can resolve the binary.
|
||||
SetPixelpassPath(Option<String>),
|
||||
@@ -85,6 +97,9 @@ pub enum UiEvent {
|
||||
RoomLeft,
|
||||
PeerJoined { id: EndpointId, state: PeerState },
|
||||
PeerLeft { id: EndpointId },
|
||||
/// The fixed reconnect grace expired and bounded background gossip recovery
|
||||
/// has started. This is non-terminal and must not play the failure chime.
|
||||
PeerRecoveryStarted { id: EndpointId },
|
||||
PeerConnectionFailed { id: EndpointId },
|
||||
PeerUpdated { id: EndpointId, state: PeerState },
|
||||
/// Audio link to a peer is being (re)established — show a connecting state.
|
||||
@@ -102,7 +117,13 @@ pub enum UiEvent {
|
||||
/// 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
|
||||
/// 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".
|
||||
ScreenShareStarted,
|
||||
/// Our own screen share stopped (or failed to start).
|
||||
@@ -124,11 +145,10 @@ pub enum UiEvent {
|
||||
/// joinable gathering (with a one-click ticket). Emitted by the outbound ping
|
||||
/// scheduler; absence of a recent event = treat as offline.
|
||||
FriendPresence { id: EndpointId, presence: FriendPresence },
|
||||
/// The Discoverable time-box elapsed (W7 P6): the core auto-reverted our presence
|
||||
/// posture to the carried `mode` (always `Normal`) and stopped publishing. The
|
||||
/// GUI must mirror + persist this so its presence picker stops showing
|
||||
/// Discoverable. Distinct from a user-driven change so the GUI knows to update
|
||||
/// without having issued the command itself.
|
||||
/// Core corrected the committed presence posture. Usually the Discoverable
|
||||
/// time-box elapsed and the core auto-reverted to `Normal`; on discovery apply
|
||||
/// failure, this carries the previous truthful mode. The GUI must mirror +
|
||||
/// persist this so its presence picker matches the endpoint's discovery state.
|
||||
PresenceModeReverted { mode: PresenceMode },
|
||||
/// Core finished orderly app shutdown and the GUI can exit.
|
||||
ShutdownComplete,
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
pub mod messages;
|
||||
pub mod jitter;
|
||||
mod recovery;
|
||||
|
||||
use crate::audio::{AudioBackend, pipewire_impl::PipeWireBackend};
|
||||
use crate::audio::{AudioBackend, PlatformAudioBackend};
|
||||
use crate::audio::eq::{Eq, EqSettings};
|
||||
use crate::codec::{AudioEncoder, opus_impl::OpusEncoder};
|
||||
use crate::core::jitter::{JitterBuffer, FRAME_SAMPLES};
|
||||
use crate::network::{
|
||||
NetworkTransport, RoomState, PeerState, RoomEvent, ConnEvent, PeerSpeakTicket,
|
||||
iroh_impl::{IrohTransport, AudioRouter},
|
||||
iroh_impl::{IrohTransport, AudioRouter, FileRouter},
|
||||
gossip::IrohGossipState,
|
||||
};
|
||||
use crate::core::messages::{CoreCommand, UiEvent};
|
||||
use crate::core::recovery::RecoveryCoordinator;
|
||||
|
||||
use crate::config::{NetworkMode, RecordingMode};
|
||||
use crate::presence::PresenceMode;
|
||||
use crate::audio::multitrack::MultitrackRecorder;
|
||||
use iroh::{Endpoint, EndpointAddr, EndpointId, RelayMode, SecretKey, endpoint::presets, protocol::Router};
|
||||
use iroh_gossip::net::Gossip;
|
||||
@@ -69,10 +72,27 @@ const RECONNECT_GRACE: Duration = Duration::from_secs(45);
|
||||
/// 48 kHz / 60 ms frame) while bounding malicious datagram copy/decode churn.
|
||||
const MAX_OPUS_PAYLOAD: usize = 4000;
|
||||
|
||||
/// If the Discoverable time-box tries to revert but discovery service reconfiguration
|
||||
/// fails, retry soon while keeping the UI in the still-possible publishing state.
|
||||
const DISCOVERY_REVERT_RETRY: Duration = Duration::from_secs(60);
|
||||
|
||||
fn audio_datagram_len_ok(len: usize) -> bool {
|
||||
(4..=4 + MAX_OPUS_PAYLOAD).contains(&len)
|
||||
}
|
||||
|
||||
fn arm_discovery_retry(
|
||||
discovery_deadline: &mut Option<tokio::time::Instant>,
|
||||
now: tokio::time::Instant,
|
||||
) {
|
||||
let retry_deadline = now + DISCOVERY_REVERT_RETRY;
|
||||
if discovery_deadline
|
||||
.map(|current| current > retry_deadline)
|
||||
.unwrap_or(true)
|
||||
{
|
||||
*discovery_deadline = Some(retry_deadline);
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-peer reconnect grace timers (see [`RECONNECT_GRACE`]). Shared between the
|
||||
/// room-event task (which arms one on a transient drop and cancels it on a
|
||||
/// gossip rejoin) and the conn-event task (which cancels it when the audio link
|
||||
@@ -84,6 +104,39 @@ type GraceTimers = Arc<std::sync::Mutex<HashMap<EndpointId, tokio::task::JoinHan
|
||||
/// Scrubbed whenever a peer is evicted or leaves so a later rejoin starts clean.
|
||||
type SeenConnected = Arc<std::sync::Mutex<HashSet<EndpointId>>>;
|
||||
|
||||
type KnownPeers =
|
||||
Arc<std::sync::Mutex<HashMap<String, HashMap<EndpointId, EndpointAddr>>>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct RecoveryContext {
|
||||
coordinator: RecoveryCoordinator,
|
||||
room_state: Arc<IrohGossipState>,
|
||||
known_peers: KnownPeers,
|
||||
ticket: String,
|
||||
}
|
||||
|
||||
impl RecoveryContext {
|
||||
fn retained_addr(&self, peer_id: &EndpointId) -> Option<EndpointAddr> {
|
||||
self.known_peers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&self.ticket)
|
||||
.and_then(|peers| peers.get(peer_id))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn cancel(&self, peer_id: EndpointId) {
|
||||
self.coordinator.cancel(peer_id);
|
||||
}
|
||||
|
||||
fn forget(&self, peer_id: EndpointId) {
|
||||
if let Some(peers) = self.known_peers.lock().unwrap().get_mut(&self.ticket) {
|
||||
peers.remove(&peer_id);
|
||||
}
|
||||
self.coordinator.cancel(peer_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel and forget a peer's pending grace timer, if any. No-op if none is armed.
|
||||
fn cancel_grace_timer(timers: &GraceTimers, peer_id: &EndpointId) {
|
||||
if let Some(handle) = timers.lock().unwrap().remove(peer_id) {
|
||||
@@ -98,12 +151,17 @@ fn cancel_grace_timer(timers: &GraceTimers, peer_id: &EndpointId) {
|
||||
/// link repeatedly resetting the clock and dodging eviction forever. On firing it
|
||||
/// also scrubs the peer from `seen_connected` so a later rejoin isn't treated as a
|
||||
/// reconnect on its initial dial.
|
||||
struct GraceExpiry<'a> {
|
||||
transport: &'a Arc<IrohTransport>,
|
||||
jitter: &'a Arc<Mutex<HashMap<EndpointId, JitterBuffer>>>,
|
||||
ui_tx: &'a mpsc::Sender<UiEvent>,
|
||||
recovery: Option<&'a RecoveryContext>,
|
||||
}
|
||||
|
||||
fn arm_grace_timer(
|
||||
timers: &GraceTimers,
|
||||
seen_connected: &SeenConnected,
|
||||
transport: &Arc<IrohTransport>,
|
||||
jitter: &Arc<Mutex<HashMap<EndpointId, JitterBuffer>>>,
|
||||
ui_tx: &mpsc::Sender<UiEvent>,
|
||||
expiry: GraceExpiry<'_>,
|
||||
grace: Duration,
|
||||
peer_id: EndpointId,
|
||||
) {
|
||||
@@ -111,14 +169,29 @@ fn arm_grace_timer(
|
||||
if timers_guard.contains_key(&peer_id) {
|
||||
return;
|
||||
}
|
||||
let transport_evict = transport.clone();
|
||||
let jitter_evict = jitter.clone();
|
||||
let ui_evict = ui_tx.clone();
|
||||
let transport_evict = expiry.transport.clone();
|
||||
let jitter_evict = expiry.jitter.clone();
|
||||
let ui_evict = expiry.ui_tx.clone();
|
||||
let timers_evict = timers.clone();
|
||||
let seen_evict = seen_connected.clone();
|
||||
let recovery_evict = expiry.recovery.cloned();
|
||||
let handle = tokio::spawn(async move {
|
||||
tokio::time::sleep(grace).await;
|
||||
crate::log_msg(&format!("Reconnect grace expired; evicting peer {:?}", peer_id));
|
||||
crate::log_msg(&format!("Reconnect grace expired for peer {:?}", peer_id));
|
||||
|
||||
if let Some(recovery) = &recovery_evict
|
||||
&& !recovery.coordinator.begin(peer_id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
transport_evict.remove_audio_sender(peer_id);
|
||||
if let Some(recovery) = &recovery_evict {
|
||||
// Revoke roster authority before the first await in teardown. A
|
||||
// verified Announce racing after this point is then a PeerJoined and
|
||||
// cancels recovery instead of being erased after it was accepted.
|
||||
recovery.room_state.mark_peer_disconnected(peer_id);
|
||||
}
|
||||
transport_evict.disconnect_peer(peer_id).await;
|
||||
jitter_evict.lock().await.remove(&peer_id);
|
||||
// Scrub our internal state *before* announcing the eviction, so anything
|
||||
@@ -127,7 +200,38 @@ fn arm_grace_timer(
|
||||
// reconnect.
|
||||
timers_evict.lock().unwrap().remove(&peer_id);
|
||||
seen_evict.lock().unwrap().remove(&peer_id);
|
||||
let _ = ui_evict.send(UiEvent::PeerConnectionFailed { id: peer_id }).await;
|
||||
|
||||
let Some(recovery) = recovery_evict else {
|
||||
let _ = ui_evict.send(UiEvent::PeerConnectionFailed { id: peer_id }).await;
|
||||
return;
|
||||
};
|
||||
if !recovery.coordinator.is_active(&peer_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(addr) = recovery.retained_addr(&peer_id) else {
|
||||
crate::log_msg(&format!(
|
||||
"Cannot recover peer {:?}: no retained authenticated address",
|
||||
peer_id
|
||||
));
|
||||
recovery.cancel(peer_id);
|
||||
let _ = ui_evict.send(UiEvent::PeerConnectionFailed { id: peer_id }).await;
|
||||
return;
|
||||
};
|
||||
|
||||
match recovery.coordinator.activate(peer_id, addr) {
|
||||
Ok(true) => {
|
||||
let _ = ui_evict.send(UiEvent::PeerRecoveryStarted { id: peer_id }).await;
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(()) => {
|
||||
crate::log_msg(&format!(
|
||||
"Cannot recover peer {:?}: recovery coordinator unavailable",
|
||||
peer_id
|
||||
));
|
||||
let _ = ui_evict.send(UiEvent::PeerConnectionFailed { id: peer_id }).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
timers_guard.insert(peer_id, handle);
|
||||
}
|
||||
@@ -218,7 +322,7 @@ fn run_mic_monitor(
|
||||
/// Stops a standalone mic monitor if one is running. MUST NOT be called while a
|
||||
/// room session is active — `backend.stop()` would also tear down the call's
|
||||
/// capture/playback. Monitor and session are mutually exclusive by construction.
|
||||
fn stop_mic_monitor(backend: &PipeWireBackend, monitor: Option<MicMonitor>) {
|
||||
fn stop_mic_monitor(backend: &PlatformAudioBackend, monitor: Option<MicMonitor>) {
|
||||
if let Some(m) = monitor {
|
||||
let _ = backend.stop();
|
||||
let _ = m.thread.join();
|
||||
@@ -290,6 +394,7 @@ pub struct ConnEventHandler {
|
||||
seen_connected: SeenConnected,
|
||||
transport: Arc<IrohTransport>,
|
||||
jitter: Arc<Mutex<HashMap<EndpointId, JitterBuffer>>>,
|
||||
recovery: Option<RecoveryContext>,
|
||||
grace: Duration,
|
||||
}
|
||||
|
||||
@@ -307,6 +412,7 @@ impl ConnEventHandler {
|
||||
seen_connected,
|
||||
transport,
|
||||
jitter,
|
||||
recovery: None,
|
||||
grace: RECONNECT_GRACE,
|
||||
}
|
||||
}
|
||||
@@ -317,6 +423,11 @@ impl ConnEventHandler {
|
||||
self
|
||||
}
|
||||
|
||||
fn with_recovery(mut self, recovery: RecoveryContext) -> Self {
|
||||
self.recovery = Some(recovery);
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn handle(&self, event: ConnEvent) {
|
||||
match event {
|
||||
ConnEvent::Connecting(id) => {
|
||||
@@ -330,9 +441,12 @@ impl ConnEventHandler {
|
||||
arm_grace_timer(
|
||||
&self.grace_timers,
|
||||
&self.seen_connected,
|
||||
&self.transport,
|
||||
&self.jitter,
|
||||
&self.ui_tx,
|
||||
GraceExpiry {
|
||||
transport: &self.transport,
|
||||
jitter: &self.jitter,
|
||||
ui_tx: &self.ui_tx,
|
||||
recovery: self.recovery.as_ref(),
|
||||
},
|
||||
self.grace,
|
||||
id,
|
||||
);
|
||||
@@ -340,6 +454,15 @@ impl ConnEventHandler {
|
||||
let _ = self.ui_tx.send(UiEvent::PeerConnecting { id }).await;
|
||||
}
|
||||
ConnEvent::Connected(id) => {
|
||||
// A transport event cannot readmit a grace-expired peer. Ignore a
|
||||
// stale/racing link until authenticated gossip emits PeerJoined.
|
||||
if self
|
||||
.recovery
|
||||
.as_ref()
|
||||
.is_some_and(|recovery| recovery.coordinator.is_active(&id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
// The audio link came back — the peer recovered within the grace
|
||||
// window, so cancel its eviction.
|
||||
cancel_grace_timer(&self.grace_timers, &id);
|
||||
@@ -352,6 +475,10 @@ impl ConnEventHandler {
|
||||
// until the grace timer or the slow gossip Leave.
|
||||
cancel_grace_timer(&self.grace_timers, &id);
|
||||
self.seen_connected.lock().unwrap().remove(&id);
|
||||
if let Some(recovery) = &self.recovery {
|
||||
recovery.forget(id);
|
||||
}
|
||||
self.transport.remove_audio_sender(id);
|
||||
self.transport.disconnect_peer(id).await;
|
||||
self.jitter.lock().await.remove(&id);
|
||||
let _ = self.ui_tx.send(UiEvent::PeerLeft { id }).await;
|
||||
@@ -367,9 +494,11 @@ struct ActiveSession {
|
||||
mixer_task: tokio::task::JoinHandle<()>,
|
||||
event_task: tokio::task::JoinHandle<()>,
|
||||
conn_event_task: tokio::task::JoinHandle<()>,
|
||||
recovery_task: tokio::task::JoinHandle<()>,
|
||||
grace_timers: GraceTimers,
|
||||
transport: Arc<IrohTransport>,
|
||||
/// Loaded PipeWire echo-cancel module (if enabled); unloads on drop.
|
||||
#[cfg(target_os = "linux")]
|
||||
echo_cancel: Option<crate::audio::echo_cancel::EchoCancelGuard>,
|
||||
/// Our pixelpass screen-share host child while sharing (`kill_on_drop`, so it
|
||||
/// also dies if the session is dropped without an explicit stop).
|
||||
@@ -380,7 +509,7 @@ struct ActiveSession {
|
||||
}
|
||||
|
||||
impl ActiveSession {
|
||||
async fn shutdown(mut self, audio_backend: Arc<PipeWireBackend>) {
|
||||
async fn shutdown(mut self, audio_backend: Arc<PlatformAudioBackend>) {
|
||||
crate::log_msg("ActiveSession::shutdown started");
|
||||
// Tear down any screen-share children first so the host stops streaming
|
||||
// promptly (kill_on_drop is the backstop, but kill explicitly so viewers
|
||||
@@ -400,6 +529,7 @@ impl ActiveSession {
|
||||
for (_, handle) in self.grace_timers.lock().unwrap().drain() {
|
||||
handle.abort();
|
||||
}
|
||||
self.recovery_task.abort();
|
||||
crate::log_msg("Aborted tasks");
|
||||
|
||||
let audio_backend_clone = audio_backend.clone();
|
||||
@@ -411,6 +541,7 @@ impl ActiveSession {
|
||||
|
||||
// Unload the echo-cancel module now that the audio streams releasing its
|
||||
// virtual nodes have stopped. (Dropping the guard runs `pactl unload`.)
|
||||
#[cfg(target_os = "linux")]
|
||||
drop(self.echo_cancel);
|
||||
|
||||
crate::log_msg("Leaving room...");
|
||||
@@ -447,6 +578,9 @@ struct NetStack {
|
||||
/// The persistent inbound-audio handler on `router`; per-join we bind the
|
||||
/// active session's transport into it, and clear it on leave.
|
||||
audio_router: AudioRouter,
|
||||
/// The persistent chat-file-transfer handler on `router`; bound/cleared in
|
||||
/// lock-step with `audio_router` (same session lifecycle).
|
||||
file_router: FileRouter,
|
||||
/// In-memory address book (ticket + gossip fed), shared with every session.
|
||||
memory_lookup: iroh::address_lookup::memory::MemoryLookup,
|
||||
}
|
||||
@@ -466,12 +600,11 @@ impl NetStack {
|
||||
/// `DnsAddressLookup`, mirroring the `N0` preset) is added when `plan.resolver`; the
|
||||
/// n0 DNS *publisher* (`PkarrPublisher`) when `plan.publisher`.
|
||||
///
|
||||
/// Idempotent and reversible: it clears the whole service set and reinstalls exactly
|
||||
/// what the plan wants, so flipping `publisher` off simply drops the publisher (its
|
||||
/// republish task ends when the last clone is dropped, and the already-published
|
||||
/// record TTL-expires within ~30s) without an endpoint rebuild and without disturbing
|
||||
/// resolution. The brief clear→re-add window is a few synchronous calls; presence
|
||||
/// toggles are rare, so a concurrent dial racing it is not a practical concern.
|
||||
/// Idempotent and reversible: it builds the replacement services first, then clears
|
||||
/// the service set and reinstalls exactly what the plan wants. Flipping `publisher`
|
||||
/// off drops the publisher (its republish task ends when the last clone is dropped,
|
||||
/// and the already-published record TTL-expires within ~30s) without an endpoint
|
||||
/// rebuild and without disturbing resolution.
|
||||
fn apply_discovery(
|
||||
endpoint: &Endpoint,
|
||||
memory_lookup: &iroh::address_lookup::memory::MemoryLookup,
|
||||
@@ -482,16 +615,34 @@ fn apply_discovery(
|
||||
pkarr::{PkarrPublisher, PkarrResolver},
|
||||
};
|
||||
let services = endpoint.address_lookup()?;
|
||||
let pkarr_resolver = if plan.resolver {
|
||||
Some(PkarrResolver::n0_dns().into_address_lookup(endpoint)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let dns_resolver = if plan.resolver {
|
||||
Some(DnsAddressLookup::n0_dns().into_address_lookup(endpoint)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let publisher = if plan.publisher {
|
||||
Some(PkarrPublisher::n0_dns().into_address_lookup(endpoint)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
services.clear();
|
||||
// Always keep the local, server-free lookup (this is what ticket/gossip dialing
|
||||
// depends on — it must survive every posture, including DirectOnly).
|
||||
services.add(memory_lookup.clone());
|
||||
if plan.resolver {
|
||||
services.add(PkarrResolver::n0_dns().into_address_lookup(endpoint)?);
|
||||
services.add(DnsAddressLookup::n0_dns().into_address_lookup(endpoint)?);
|
||||
if let Some(pkarr_resolver) = pkarr_resolver {
|
||||
services.add(pkarr_resolver);
|
||||
}
|
||||
if plan.publisher {
|
||||
services.add(PkarrPublisher::n0_dns().into_address_lookup(endpoint)?);
|
||||
if let Some(dns_resolver) = dns_resolver {
|
||||
services.add(dns_resolver);
|
||||
}
|
||||
if let Some(publisher) = publisher {
|
||||
services.add(publisher);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -541,13 +692,15 @@ async fn build_net_stack(
|
||||
.spawn(endpoint.clone());
|
||||
|
||||
let audio_router = AudioRouter::new();
|
||||
let file_router = FileRouter::new();
|
||||
// The friends presence listener (W7 B2) rides this same persistent router as a
|
||||
// third ALPN — it MUST be a handler here, not a standalone accept loop, since
|
||||
// the router owns endpoint.accept(). Policy (who we answer / what room we
|
||||
// report) is injected via `friends_handler`.
|
||||
let router = Router::builder(endpoint.clone())
|
||||
.accept(iroh_gossip::net::GOSSIP_ALPN, gossip.clone())
|
||||
.accept(b"peerspeak-audio", audio_router.clone())
|
||||
.accept(crate::protocol::AUDIO_ALPN, audio_router.clone())
|
||||
.accept(crate::protocol::FILES_ALPN, file_router.clone())
|
||||
.accept(
|
||||
crate::presence_net::FRIENDS_ALPN,
|
||||
crate::presence_net::FriendsProtocol::new(friends_handler),
|
||||
@@ -559,10 +712,49 @@ async fn build_net_stack(
|
||||
gossip,
|
||||
router,
|
||||
audio_router,
|
||||
file_router,
|
||||
memory_lookup,
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetch a chat attachment's bytes from `from` over the file plane in a detached
|
||||
/// task, then report the result to the UI via [`UiEvent::AttachmentReady`] /
|
||||
/// [`UiEvent::AttachmentFailed`] keyed by the attachment id. For images
|
||||
/// (`is_image`) the bytes are defensively re-validated (decodable + within pixel
|
||||
/// limits) before being handed to the renderer; an "Image" that doesn't decode is
|
||||
/// reported as a failure rather than rendered.
|
||||
fn spawn_attachment_fetch(
|
||||
transport: Arc<IrohTransport>,
|
||||
ui_tx: mpsc::Sender<UiEvent>,
|
||||
from: EndpointId,
|
||||
att: crate::files::ChatAttachment,
|
||||
is_image: bool,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
match transport.fetch_attachment(from, &att).await {
|
||||
Ok(data) => {
|
||||
if is_image && crate::files::validate_image_bytes(&data).is_none() {
|
||||
let _ = ui_tx
|
||||
.send(UiEvent::AttachmentFailed {
|
||||
id: att.id,
|
||||
error: "received image failed to decode".to_string(),
|
||||
})
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
let _ = ui_tx
|
||||
.send(UiEvent::AttachmentReady { id: att.id, data })
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = ui_tx
|
||||
.send(UiEvent::AttachmentFailed { id: att.id, error: e.to_string() })
|
||||
.await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Finalize and clear the active recording, if any, emitting `RecordingStopped`.
|
||||
/// No-op when not recording. Called on stop, room leave, and room switch so a
|
||||
/// recording is always closed cleanly (its WAV size fields patched).
|
||||
@@ -691,10 +883,9 @@ async fn run_core_loop(
|
||||
// first room's peers — the old single-set version cleared them on any ticket
|
||||
// change, so an A→B→A bounce stranded the rejoiner with an empty bootstrap.
|
||||
// Inner map keyed by peer id so updates refresh the address.
|
||||
let known_peers: Arc<std::sync::Mutex<HashMap<String, HashMap<EndpointId, EndpointAddr>>>> =
|
||||
Arc::new(std::sync::Mutex::new(HashMap::new()));
|
||||
let known_peers: KnownPeers = Arc::new(std::sync::Mutex::new(HashMap::new()));
|
||||
|
||||
let audio_backend = Arc::new(PipeWireBackend::new());
|
||||
let audio_backend = Arc::new(PlatformAudioBackend::new());
|
||||
|
||||
let is_muted = Arc::new(AtomicBool::new(false));
|
||||
let is_deafened = Arc::new(AtomicBool::new(false));
|
||||
@@ -722,6 +913,8 @@ async fn run_core_loop(
|
||||
let peer_volumes = Arc::new(Mutex::new(HashMap::<EndpointId, f32>::new()));
|
||||
let peer_eq = Arc::new(Mutex::new(HashMap::<EndpointId, EqSettings>::new()));
|
||||
let peer_pan = Arc::new(Mutex::new(HashMap::<EndpointId, f32>::new()));
|
||||
// Listener-side per-peer noise-gate thresholds (normalized RMS, 0.0 = off).
|
||||
let peer_gate = Arc::new(Mutex::new(HashMap::<EndpointId, f32>::new()));
|
||||
// Peers locally muted by us: decoded for level metering but not mixed.
|
||||
let locally_muted = Arc::new(Mutex::new(HashSet::<EndpointId>::new()));
|
||||
let mut current_name = "Anonymous".to_string();
|
||||
@@ -851,22 +1044,64 @@ async fn run_core_loop(
|
||||
// W7 P6 time-box: Discoverable auto-reverts to Normal after DISCOVERY_TIMEBOX
|
||||
// so a publish beacon never stands indefinitely. The branch is disabled
|
||||
// (`if` guard) unless a deadline is armed; `unwrap_or_else` is unreachable
|
||||
// belt-and-braces. On fire: stop publishing, drop to Normal, tell the GUI.
|
||||
// belt-and-braces. On fire: stop publishing first, then commit Normal only
|
||||
// if the endpoint's discovery services accepted the non-publishing plan.
|
||||
_ = tokio::time::sleep_until(
|
||||
discovery_deadline.unwrap_or_else(tokio::time::Instant::now),
|
||||
), if discovery_deadline.is_some() => {
|
||||
discovery_deadline = None;
|
||||
*presence_mode.lock().unwrap() = crate::presence::PresenceMode::Normal;
|
||||
let plan = crate::discovery::lookup_plan(network_mode, false);
|
||||
if let Err(e) = apply_discovery(&net.endpoint, &net.memory_lookup, plan) {
|
||||
crate::log_msg(&format!("discovery: time-box revert failed: {e:#}"));
|
||||
let previous_mode = *presence_mode.lock().unwrap();
|
||||
if previous_mode != PresenceMode::Discoverable {
|
||||
discovery_deadline = None;
|
||||
continue;
|
||||
}
|
||||
|
||||
let requested_mode = PresenceMode::Normal;
|
||||
let now = tokio::time::Instant::now();
|
||||
let plan = crate::discovery::lookup_plan(
|
||||
network_mode,
|
||||
requested_mode.publishes_to_discovery(),
|
||||
);
|
||||
let apply_result = apply_discovery(&net.endpoint, &net.memory_lookup, plan);
|
||||
let (committed_mode, transition_error) =
|
||||
crate::discovery::resolve_presence_transition(
|
||||
previous_mode,
|
||||
requested_mode,
|
||||
apply_result.is_ok(),
|
||||
);
|
||||
*presence_mode.lock().unwrap() = committed_mode;
|
||||
discovery_deadline = if committed_mode == PresenceMode::Discoverable {
|
||||
Some(now + DISCOVERY_REVERT_RETRY)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
match apply_result {
|
||||
Ok(()) => {
|
||||
crate::log_msg(
|
||||
"discovery: Discoverable time-box elapsed → reverting to Normal",
|
||||
);
|
||||
let _ = ui_tx
|
||||
.send(UiEvent::PresenceModeReverted {
|
||||
mode: PresenceMode::Normal,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
crate::log_msg(&format!("discovery: time-box revert failed: {e:#}"));
|
||||
if committed_mode != requested_mode {
|
||||
let _ = ui_tx
|
||||
.send(UiEvent::PresenceModeReverted {
|
||||
mode: committed_mode,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
if let Some(message) = transition_error {
|
||||
let _ = ui_tx
|
||||
.send(UiEvent::Error(format!("{message} ({e:#})")))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::log_msg("discovery: Discoverable time-box elapsed → reverting to Normal");
|
||||
let _ = ui_tx
|
||||
.send(UiEvent::PresenceModeReverted {
|
||||
mode: crate::presence::PresenceMode::Normal,
|
||||
})
|
||||
.await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -880,6 +1115,7 @@ async fn run_core_loop(
|
||||
if let Some(session) = active_session.take() {
|
||||
session.shutdown(audio_backend.clone()).await;
|
||||
net.audio_router.clear();
|
||||
net.file_router.clear();
|
||||
}
|
||||
*current_room.lock().unwrap() = None;
|
||||
|
||||
@@ -902,6 +1138,7 @@ async fn run_core_loop(
|
||||
crate::log_msg("Shutting down existing active session");
|
||||
session.shutdown(audio_backend.clone()).await;
|
||||
net.audio_router.clear();
|
||||
net.file_router.clear();
|
||||
}
|
||||
|
||||
// If a network-mode / identity change was deferred while a call was
|
||||
@@ -958,6 +1195,7 @@ async fn run_core_loop(
|
||||
// NetStack; the session just subscribes its topic below.
|
||||
let transport = Arc::new(IrohTransport::new(endpoint.clone()));
|
||||
net.audio_router.bind(&transport);
|
||||
net.file_router.bind(&transport);
|
||||
|
||||
let room_state = Arc::new(IrohGossipState::new(
|
||||
endpoint.clone(),
|
||||
@@ -1002,6 +1240,7 @@ async fn run_core_loop(
|
||||
crate::log_msg(&format!("Error room_state.join failed: {:?}", e));
|
||||
let _ = ui_tx.send(UiEvent::Error(format!("Failed to join room: {}", e))).await;
|
||||
net.audio_router.clear();
|
||||
net.file_router.clear();
|
||||
continue;
|
||||
}
|
||||
crate::log_msg("Joined room successfully via room_state");
|
||||
@@ -1016,7 +1255,9 @@ async fn run_core_loop(
|
||||
// The guard unloads the module on drop — including the early-return
|
||||
// paths below, since it's a local until moved into the session. On
|
||||
// any failure, warn and fall back to the direct devices.
|
||||
#[cfg(target_os = "linux")]
|
||||
let mut echo_cancel_guard = None;
|
||||
#[cfg(target_os = "linux")]
|
||||
let (capture_target, playback_target) = if echo_cancellation {
|
||||
match crate::audio::echo_cancel::enable(
|
||||
input_device.as_deref(),
|
||||
@@ -1043,11 +1284,16 @@ async fn run_core_loop(
|
||||
} else {
|
||||
(input_device.clone(), output_device.clone())
|
||||
};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let _ = echo_cancellation;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let (capture_target, playback_target) = (input_device.clone(), output_device.clone());
|
||||
|
||||
if let Err(e) = audio_backend.start_capture(capture_tx, capture_target) {
|
||||
let _ = ui_tx.send(UiEvent::Error(format!("Failed to start capture: {}", e))).await;
|
||||
let _ = room_state.leave().await;
|
||||
net.audio_router.clear();
|
||||
net.file_router.clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1060,6 +1306,7 @@ async fn run_core_loop(
|
||||
let _ = audio_backend.stop();
|
||||
let _ = room_state.leave().await;
|
||||
net.audio_router.clear();
|
||||
net.file_router.clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1162,6 +1409,9 @@ async fn run_core_loop(
|
||||
};
|
||||
|
||||
while let Some((from_peer, bytes)) = datagram_rx.recv().await {
|
||||
if !transport_recv.audio_sender_admitted(from_peer) {
|
||||
continue;
|
||||
}
|
||||
if !audio_datagram_len_ok(bytes.len()) {
|
||||
// Malformed (< sequence header) or oversized Opus payload.
|
||||
continue;
|
||||
@@ -1199,6 +1449,7 @@ async fn run_core_loop(
|
||||
let peer_volumes_mixer = peer_volumes.clone();
|
||||
let peer_eq_mixer = peer_eq.clone();
|
||||
let peer_pan_mixer = peer_pan.clone();
|
||||
let peer_gate_mixer = peer_gate.clone();
|
||||
let locally_muted_mixer = locally_muted.clone();
|
||||
let output_gain_mixer = output_gain.clone();
|
||||
let ui_tx_mixer = ui_tx.clone();
|
||||
@@ -1215,6 +1466,11 @@ async fn run_core_loop(
|
||||
// Per-peer EQ filter state. Settings are live-cloned each
|
||||
// cycle; state is rebuilt only when a peer's EQ changes.
|
||||
let mut peer_eqs: HashMap<EndpointId, Eq> = HashMap::new();
|
||||
// Per-peer noise-gate envelope state. The threshold is passed
|
||||
// per frame (live slider), so the gate is never rebuilt — only
|
||||
// created once per peer and dropped when the peer leaves.
|
||||
let mut peer_noise_gates: HashMap<EndpointId, crate::audio::gate::NoiseGate> =
|
||||
HashMap::new();
|
||||
// When the ring is at/above target we have nothing to do; nap
|
||||
// briefly and re-check. Short enough (relative to the ~60ms
|
||||
// target and ~21ms device quantum) that we always refill well
|
||||
@@ -1240,6 +1496,7 @@ async fn run_core_loop(
|
||||
let current_volumes = peer_volumes_mixer.lock().await.clone();
|
||||
let current_eq = peer_eq_mixer.lock().await.clone();
|
||||
let current_pans = peer_pan_mixer.lock().await.clone();
|
||||
let current_gates = peer_gate_mixer.lock().await.clone();
|
||||
let muted_peers = locally_muted_mixer.lock().await.clone();
|
||||
let mut peer_frames: Vec<(Vec<i16>, f32)> = Vec::new();
|
||||
let mut peers_seen = HashSet::new();
|
||||
@@ -1265,6 +1522,26 @@ async fn run_core_loop(
|
||||
stems.push((peer_id, frame.clone()));
|
||||
}
|
||||
|
||||
// Listener-side per-peer noise gate, applied to the
|
||||
// raw decoded frame (after the clean stem tap, before
|
||||
// volume/EQ) so the threshold tracks the peer's true
|
||||
// signal level regardless of our volume setting. The
|
||||
// gate's "should transmit" return is irrelevant here —
|
||||
// we only attenuate. Threshold 0 = off; the gate is
|
||||
// created lazily and dropped when disabled.
|
||||
let gate_threshold =
|
||||
current_gates.get(&peer_id).copied().unwrap_or(0.0);
|
||||
if gate_threshold > 0.0 {
|
||||
peer_noise_gates
|
||||
.entry(peer_id)
|
||||
.or_insert_with(|| {
|
||||
crate::audio::gate::NoiseGate::new(48_000)
|
||||
})
|
||||
.process(&mut frame, gate_threshold);
|
||||
} else {
|
||||
peer_noise_gates.remove(&peer_id);
|
||||
}
|
||||
|
||||
let vol = current_volumes.get(&peer_id).copied().unwrap_or(1.0);
|
||||
apply_volume(&mut frame, vol);
|
||||
|
||||
@@ -1309,6 +1586,8 @@ async fn run_core_loop(
|
||||
}
|
||||
}
|
||||
peer_eqs.retain(|id, _| peers_seen.contains(id) || current_eq.contains_key(id));
|
||||
peer_noise_gates
|
||||
.retain(|id, _| peers_seen.contains(id) || current_gates.contains_key(id));
|
||||
|
||||
// Lossless i32 sum, then the limiter applies the master
|
||||
// output gain (in f32, so a boost past the ceiling is
|
||||
@@ -1384,6 +1663,15 @@ async fn run_core_loop(
|
||||
// The ticket of the room this event loop serves, so peer add/remove
|
||||
// updates the right per-ticket bucket in `known_peers` (A8 archive).
|
||||
let ticket_events = ticket_str.clone();
|
||||
let (recovery_coordinator, recovery_task) =
|
||||
RecoveryCoordinator::spawn(room_state.clone());
|
||||
let recovery_context = RecoveryContext {
|
||||
coordinator: recovery_coordinator,
|
||||
room_state: room_state.clone(),
|
||||
known_peers: known_peers.clone(),
|
||||
ticket: ticket_str.clone(),
|
||||
};
|
||||
let recovery_events = recovery_context.clone();
|
||||
// Friends store + ui sender, so a connected peer who is a friend has
|
||||
// their saved address auto-healed (W7) — populates `last_addr` so the
|
||||
// presence scheduler can reach them later.
|
||||
@@ -1396,6 +1684,8 @@ async fn run_core_loop(
|
||||
// A (re)join means the peer is back — cancel any
|
||||
// pending reconnect grace timer before re-adding it.
|
||||
cancel_grace_timer(&grace_timers_events, &peer_id);
|
||||
recovery_events.cancel(peer_id);
|
||||
transport_events.admit_audio_sender(peer_id);
|
||||
// Establish the audio connection as soon as the peer
|
||||
// is known (the transport dedupes the full-mesh race).
|
||||
// Hand over the full address so reconnects can dial
|
||||
@@ -1438,15 +1728,10 @@ async fn run_core_loop(
|
||||
// Graceful leave — evict immediately.
|
||||
cancel_grace_timer(&grace_timers_events, &peer_id);
|
||||
seen_connected_events.lock().unwrap().remove(&peer_id);
|
||||
// Graceful leave: drop them as a rejoin dial target
|
||||
// for this room (a transient PeerConnectionLost
|
||||
// deliberately does NOT, so we can still re-dial a
|
||||
// peer who's still up).
|
||||
if let Some(peers) =
|
||||
known_peers_events.lock().unwrap().get_mut(&ticket_events)
|
||||
{
|
||||
peers.remove(&peer_id);
|
||||
}
|
||||
// A signed Leave cancels background recovery and
|
||||
// drops the retained target. Transient loss keeps it.
|
||||
recovery_events.forget(peer_id);
|
||||
transport_events.remove_audio_sender(peer_id);
|
||||
transport_events.disconnect_peer(peer_id).await;
|
||||
jitter_events.lock().await.remove(&peer_id);
|
||||
let _ = ui_tx_events.send(UiEvent::PeerLeft { id: peer_id }).await;
|
||||
@@ -1459,6 +1744,8 @@ async fn run_core_loop(
|
||||
// it. Idempotent: an ordinary mute/unmute update just
|
||||
// re-records the same address.
|
||||
cancel_grace_timer(&grace_timers_events, &peer_id);
|
||||
recovery_events.cancel(peer_id);
|
||||
transport_events.admit_audio_sender(peer_id);
|
||||
transport_events.connect_peer(state.addr.clone()).await;
|
||||
// Auto-heal a friend's saved address (W7) on the
|
||||
// re-announce too — this is the path that catches a
|
||||
@@ -1485,11 +1772,27 @@ async fn run_core_loop(
|
||||
.insert(peer_id, state.addr.clone());
|
||||
let _ = ui_tx_events.send(UiEvent::PeerUpdated { id: peer_id, state }).await;
|
||||
}
|
||||
RoomEvent::ChatMessage { from, name, text, .. } => {
|
||||
RoomEvent::ChatMessage { from, name, text, ts: _, attachment } => {
|
||||
// Auto-fetch image attachments so they render inline
|
||||
// without a click; non-image files wait for an explicit
|
||||
// FetchAttachment (the "Save" chip). The descriptor was
|
||||
// already filename-sanitized + size-capped on ingest.
|
||||
if let Some(att) = attachment.clone()
|
||||
&& att.kind == crate::files::AttachmentKind::Image
|
||||
{
|
||||
spawn_attachment_fetch(
|
||||
transport_events.clone(),
|
||||
ui_tx_events.clone(),
|
||||
from,
|
||||
att,
|
||||
true,
|
||||
);
|
||||
}
|
||||
let _ = ui_tx_events.send(UiEvent::ChatMessage {
|
||||
from: from.to_string(),
|
||||
name,
|
||||
text,
|
||||
attachment,
|
||||
}).await;
|
||||
}
|
||||
RoomEvent::PeerConnectionLost(peer_id) => {
|
||||
@@ -1500,13 +1803,17 @@ async fn run_core_loop(
|
||||
// hasn't recovered within RECONNECT_GRACE. A gossip
|
||||
// rejoin (PeerJoined/PeerUpdated) or a transport
|
||||
// reconnect (ConnEvent::Connected) cancels it first.
|
||||
transport_events.keep_audio_sender_for_reconnect_grace(peer_id);
|
||||
let _ = ui_tx_events.send(UiEvent::PeerConnecting { id: peer_id }).await;
|
||||
arm_grace_timer(
|
||||
&grace_timers_events,
|
||||
&seen_connected_events,
|
||||
&transport_events,
|
||||
&jitter_events,
|
||||
&ui_tx_events,
|
||||
GraceExpiry {
|
||||
transport: &transport_events,
|
||||
jitter: &jitter_events,
|
||||
ui_tx: &ui_tx_events,
|
||||
recovery: Some(&recovery_events),
|
||||
},
|
||||
RECONNECT_GRACE,
|
||||
peer_id,
|
||||
);
|
||||
@@ -1530,7 +1837,8 @@ async fn run_core_loop(
|
||||
seen_connected.clone(),
|
||||
transport.clone(),
|
||||
jitter.clone(),
|
||||
);
|
||||
)
|
||||
.with_recovery(recovery_context);
|
||||
let conn_event_task = tokio::spawn(async move {
|
||||
while let Some(event) = conn_events.recv().await {
|
||||
conn_handler.handle(event).await;
|
||||
@@ -1544,8 +1852,10 @@ async fn run_core_loop(
|
||||
mixer_task,
|
||||
event_task,
|
||||
conn_event_task,
|
||||
recovery_task,
|
||||
grace_timers,
|
||||
transport: transport.clone(),
|
||||
#[cfg(target_os = "linux")]
|
||||
echo_cancel: echo_cancel_guard,
|
||||
screenshare_host: None,
|
||||
screenshare_viewers: Vec::new(),
|
||||
@@ -1585,6 +1895,7 @@ async fn run_core_loop(
|
||||
session.shutdown(audio_backend.clone()).await;
|
||||
// Stop routing inbound audio links — the endpoint/router stay up.
|
||||
net.audio_router.clear();
|
||||
net.file_router.clear();
|
||||
// No longer in a gathering — friends who ping see us as just online.
|
||||
*current_room.lock().unwrap() = None;
|
||||
let _ = ui_tx.send(UiEvent::RoomLeft).await;
|
||||
@@ -1671,6 +1982,16 @@ async fn run_core_loop(
|
||||
}
|
||||
}
|
||||
|
||||
CoreCommand::SetPeerGate(peer_id, threshold) => {
|
||||
let threshold = threshold.clamp(0.0, 1.0);
|
||||
let mut guard = peer_gate.lock().await;
|
||||
if threshold <= 0.0 {
|
||||
guard.remove(&peer_id);
|
||||
} else {
|
||||
guard.insert(peer_id, threshold);
|
||||
}
|
||||
}
|
||||
|
||||
CoreCommand::SetPeerMuted(peer_id, muted) => {
|
||||
let mut guard = locally_muted.lock().await;
|
||||
if muted {
|
||||
@@ -1721,17 +2042,26 @@ async fn run_core_loop(
|
||||
}
|
||||
|
||||
CoreCommand::SetNetworkMode(mode) => {
|
||||
network_mode = mode;
|
||||
// Rebuild the persistent stack to the new posture immediately if
|
||||
// idle; if a call is active, defer to the next Leave/Join so the
|
||||
// live call isn't disrupted (preserves "applies on next join").
|
||||
if active_session.is_none() {
|
||||
let lookup = net.memory_lookup.clone();
|
||||
net.shutdown().await;
|
||||
let publish = presence_mode.lock().unwrap().publishes_to_discovery();
|
||||
net = build_net_stack(secret_key.clone(), network_mode, lookup, friends_handler.clone(), publish).await?;
|
||||
} else {
|
||||
net_rebuild_pending = true;
|
||||
// Skip when the posture is unchanged. The GUI re-sends the saved
|
||||
// network mode as part of its startup config-sync, and that mode
|
||||
// usually already matches the freshly-built stack — rebuilding the
|
||||
// iroh endpoint for an identical posture just churns the network
|
||||
// and adds a needless ~1s teardown+rebuild bounce at every launch
|
||||
// (seen on both Linux and Windows/Wine). A real change still
|
||||
// rebuilds exactly as before.
|
||||
if mode != network_mode {
|
||||
network_mode = mode;
|
||||
// Rebuild the persistent stack to the new posture immediately if
|
||||
// idle; if a call is active, defer to the next Leave/Join so the
|
||||
// live call isn't disrupted (preserves "applies on next join").
|
||||
if active_session.is_none() {
|
||||
let lookup = net.memory_lookup.clone();
|
||||
net.shutdown().await;
|
||||
let publish = presence_mode.lock().unwrap().publishes_to_discovery();
|
||||
net = build_net_stack(secret_key.clone(), network_mode, lookup, friends_handler.clone(), publish).await?;
|
||||
} else {
|
||||
net_rebuild_pending = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1798,22 +2128,60 @@ async fn run_core_loop(
|
||||
}
|
||||
|
||||
CoreCommand::SetPresenceMode(mode) => {
|
||||
*presence_mode.lock().unwrap() = mode;
|
||||
// W7 P6: re-apply n0 DNS discovery for the new posture (publish on iff
|
||||
// Discoverable). Runtime — no endpoint rebuild; clears + reinstalls the
|
||||
// address-lookup services. The resolver stays on regardless so we can
|
||||
// still look up moved friends.
|
||||
let plan = crate::discovery::lookup_plan(network_mode, mode.publishes_to_discovery());
|
||||
if let Err(e) = apply_discovery(&net.endpoint, &net.memory_lookup, plan) {
|
||||
crate::log_msg(&format!("discovery: apply failed: {e:#}"));
|
||||
let previous_mode = *presence_mode.lock().unwrap();
|
||||
let now = tokio::time::Instant::now();
|
||||
|
||||
if previous_mode == mode {
|
||||
// Same-mode requests are no-ops for discovery wiring, but keep the
|
||||
// existing UX: re-selecting Discoverable restarts the clock.
|
||||
discovery_deadline = if mode == PresenceMode::Discoverable {
|
||||
Some(now + crate::discovery::DISCOVERY_TIMEBOX)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
continue;
|
||||
}
|
||||
// Arm (Discoverable) or cancel (any other posture) the auto-revert
|
||||
// time-box. Re-selecting Discoverable restarts the clock.
|
||||
discovery_deadline = if mode == crate::presence::PresenceMode::Discoverable {
|
||||
Some(tokio::time::Instant::now() + crate::discovery::DISCOVERY_TIMEBOX)
|
||||
|
||||
// W7 P6/S11: re-apply n0 DNS discovery for the requested posture
|
||||
// first, then commit the presence mode only if the endpoint accepted
|
||||
// that discovery plan. This keeps the UI truthful when dropping the
|
||||
// publisher fails.
|
||||
let plan =
|
||||
crate::discovery::lookup_plan(network_mode, mode.publishes_to_discovery());
|
||||
let apply_result = apply_discovery(&net.endpoint, &net.memory_lookup, plan);
|
||||
let (committed_mode, transition_error) =
|
||||
crate::discovery::resolve_presence_transition(
|
||||
previous_mode,
|
||||
mode,
|
||||
apply_result.is_ok(),
|
||||
);
|
||||
*presence_mode.lock().unwrap() = committed_mode;
|
||||
|
||||
if committed_mode == PresenceMode::Discoverable {
|
||||
if apply_result.is_ok() && mode == PresenceMode::Discoverable {
|
||||
discovery_deadline = Some(now + crate::discovery::DISCOVERY_TIMEBOX);
|
||||
} else {
|
||||
arm_discovery_retry(&mut discovery_deadline, now);
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
discovery_deadline = None;
|
||||
}
|
||||
|
||||
if let Err(e) = apply_result {
|
||||
crate::log_msg(&format!("discovery: apply failed: {e:#}"));
|
||||
if committed_mode != mode {
|
||||
let _ = ui_tx
|
||||
.send(UiEvent::PresenceModeReverted {
|
||||
mode: committed_mode,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
if let Some(message) = transition_error {
|
||||
let _ = ui_tx
|
||||
.send(UiEvent::Error(format!("{message} ({e:#})")))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CoreCommand::SetRecordingMode(mode) => {
|
||||
@@ -1907,12 +2275,38 @@ async fn run_core_loop(
|
||||
|
||||
CoreCommand::SendChat(text) => {
|
||||
if let Some(session) = &active_session
|
||||
&& let Err(e) = session.room_state.send_chat(text).await
|
||||
&& let Err(e) = session.room_state.send_chat(text, None).await
|
||||
{
|
||||
crate::log_msg(&format!("Failed to send chat: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
CoreCommand::SendChatFile { text, attachment, data } => {
|
||||
if let Some(session) = &active_session {
|
||||
// Make the bytes fetchable by room members, then broadcast the
|
||||
// descriptor alongside the (possibly empty) caption text.
|
||||
session
|
||||
.transport
|
||||
.serve_attachment(attachment.id, Arc::new(data));
|
||||
if let Err(e) = session.room_state.send_chat(text, Some(attachment)).await {
|
||||
crate::log_msg(&format!("Failed to send chat file: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CoreCommand::FetchAttachment { from, attachment } => {
|
||||
if let Some(session) = &active_session {
|
||||
let is_image = attachment.kind == crate::files::AttachmentKind::Image;
|
||||
spawn_attachment_fetch(
|
||||
session.transport.clone(),
|
||||
ui_tx.clone(),
|
||||
from,
|
||||
attachment,
|
||||
is_image,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CoreCommand::SetPixelpassPath(path) => {
|
||||
pixelpass_override = path.filter(|p| !p.trim().is_empty());
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
//! - **Resolving is always allowed on relay-capable modes** — a stationary friend
|
||||
//! (typically in `Normal`) must be able to look up a friend who moved networks. A
|
||||
//! resolve is a DNS query to n0 that publishes nothing; it only fires when a saved
|
||||
//! address is stale and the dial falls through to discovery.
|
||||
//! resolve is a DNS query to n0 that publishes nothing, but still exposes query
|
||||
//! timing/source metadata to n0; it only fires when a saved address is stale and
|
||||
//! the dial falls through to discovery.
|
||||
//! - **Publishing is gated on `Discoverable`** and asymmetric: only the mover
|
||||
//! publishes their address to n0 DNS; everyone else just looks it up.
|
||||
//! - **Stopping publishing removes the local publisher service**; iroh does not
|
||||
//! expose an explicit unpublish call here, so already-published pkarr records can
|
||||
//! linger until their default ~30s TTL expires.
|
||||
//! - **`DirectOnly` is the explicit no-server posture** — neither resolve nor publish
|
||||
//! ever touches n0 there, regardless of the Discoverable toggle.
|
||||
|
||||
use crate::config::NetworkMode;
|
||||
use crate::presence::PresenceMode;
|
||||
use std::time::Duration;
|
||||
|
||||
/// How long `Discoverable` stays on before auto-reverting to `Normal`. Discovery is
|
||||
@@ -46,12 +51,43 @@ pub fn lookup_plan(network_mode: NetworkMode, want_publish: bool) -> LookupPlan
|
||||
match network_mode {
|
||||
// The explicit serverless posture: no n0 contact at all, even to resolve.
|
||||
// A Discoverable toggle here is intentionally inert.
|
||||
NetworkMode::DirectOnly => LookupPlan { resolver: false, publisher: false },
|
||||
NetworkMode::DirectOnly => LookupPlan {
|
||||
resolver: false,
|
||||
publisher: false,
|
||||
},
|
||||
// Relay-capable: always resolve (so a stationary friend can find a mover);
|
||||
// publish only when the user opted into Discoverable.
|
||||
NetworkMode::RelayNoDiscovery | NetworkMode::N0Full => {
|
||||
LookupPlan { resolver: true, publisher: want_publish }
|
||||
}
|
||||
NetworkMode::RelayNoDiscovery | NetworkMode::N0Full => LookupPlan {
|
||||
resolver: true,
|
||||
publisher: want_publish,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide which presence mode may be committed after attempting to apply discovery
|
||||
/// services for `requested`.
|
||||
///
|
||||
/// On failure, keep the previous mode: it is the only locally truthful state because
|
||||
/// the endpoint's discovery services may still reflect the old posture. Same-mode
|
||||
/// requests are no-ops from a presence-truth perspective and do not surface an error.
|
||||
pub fn resolve_presence_transition(
|
||||
previous: PresenceMode,
|
||||
requested: PresenceMode,
|
||||
apply_ok: bool,
|
||||
) -> (PresenceMode, Option<String>) {
|
||||
if previous == requested {
|
||||
return (previous, None);
|
||||
}
|
||||
|
||||
if apply_ok {
|
||||
(requested, None)
|
||||
} else {
|
||||
(
|
||||
previous,
|
||||
Some(format!(
|
||||
"Couldn't update discovery mode; keeping {previous}."
|
||||
)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,12 +100,18 @@ mod tests {
|
||||
for mode in [NetworkMode::RelayNoDiscovery, NetworkMode::N0Full] {
|
||||
assert_eq!(
|
||||
lookup_plan(mode, false),
|
||||
LookupPlan { resolver: true, publisher: false },
|
||||
LookupPlan {
|
||||
resolver: true,
|
||||
publisher: false
|
||||
},
|
||||
"{mode:?}: resolve always on, no publish when not Discoverable"
|
||||
);
|
||||
assert_eq!(
|
||||
lookup_plan(mode, true),
|
||||
LookupPlan { resolver: true, publisher: true },
|
||||
LookupPlan {
|
||||
resolver: true,
|
||||
publisher: true
|
||||
},
|
||||
"{mode:?}: Discoverable adds publish on top of resolve"
|
||||
);
|
||||
}
|
||||
@@ -79,12 +121,18 @@ mod tests {
|
||||
fn direct_only_never_touches_n0_even_when_discoverable() {
|
||||
assert_eq!(
|
||||
lookup_plan(NetworkMode::DirectOnly, false),
|
||||
LookupPlan { resolver: false, publisher: false }
|
||||
LookupPlan {
|
||||
resolver: false,
|
||||
publisher: false
|
||||
}
|
||||
);
|
||||
// The serverless posture overrides the Discoverable request entirely.
|
||||
assert_eq!(
|
||||
lookup_plan(NetworkMode::DirectOnly, true),
|
||||
LookupPlan { resolver: false, publisher: false }
|
||||
LookupPlan {
|
||||
resolver: false,
|
||||
publisher: false
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,4 +140,42 @@ mod tests {
|
||||
fn timebox_is_thirty_minutes() {
|
||||
assert_eq!(DISCOVERY_TIMEBOX, Duration::from_secs(1800));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_transition_commits_requested_mode_after_successful_apply() {
|
||||
assert_eq!(
|
||||
resolve_presence_transition(PresenceMode::Normal, PresenceMode::Discoverable, true),
|
||||
(PresenceMode::Discoverable, None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_transition_keeps_previous_mode_when_apply_fails() {
|
||||
let (mode, err) =
|
||||
resolve_presence_transition(PresenceMode::Normal, PresenceMode::Discoverable, false);
|
||||
|
||||
assert_eq!(mode, PresenceMode::Normal);
|
||||
assert!(err.unwrap().contains("keeping Normal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_transition_keeps_discoverable_when_off_transition_fails() {
|
||||
let (mode, err) =
|
||||
resolve_presence_transition(PresenceMode::Discoverable, PresenceMode::Normal, false);
|
||||
|
||||
assert_eq!(mode, PresenceMode::Discoverable);
|
||||
assert!(err.unwrap().contains("keeping Discoverable"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_transition_same_mode_is_noop_without_error() {
|
||||
assert_eq!(
|
||||
resolve_presence_transition(
|
||||
PresenceMode::Discoverable,
|
||||
PresenceMode::Discoverable,
|
||||
false
|
||||
),
|
||||
(PresenceMode::Discoverable, None)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ pub mod audio;
|
||||
pub mod codec;
|
||||
pub mod dsp;
|
||||
pub mod network;
|
||||
pub mod protocol;
|
||||
pub mod core;
|
||||
pub mod app;
|
||||
pub mod config;
|
||||
@@ -14,15 +15,20 @@ pub mod notify;
|
||||
pub mod screenshare;
|
||||
pub mod sanitize;
|
||||
pub mod avatar;
|
||||
pub mod background;
|
||||
pub mod recents;
|
||||
pub mod discovery;
|
||||
pub mod hotkeys;
|
||||
pub mod files;
|
||||
|
||||
use std::fs::File;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
const LOG_MAX_BYTES: u64 = 5 * 1024 * 1024;
|
||||
// Owner-only log permissions are a Unix concept (mode bits); on Windows the log
|
||||
// inherits the directory's default ACL. Only referenced under `cfg(unix)`.
|
||||
#[cfg(unix)]
|
||||
const LOG_MODE: u32 = 0o600;
|
||||
|
||||
/// Resolves the log file path once: `$XDG_STATE_HOME/peerspeak/peerspeak.log`
|
||||
@@ -83,8 +89,6 @@ fn prepare_log_file(path: &Path) -> std::io::Result<File> {
|
||||
}
|
||||
|
||||
fn prepare_log_file_with_limit(path: &Path, max_bytes: u64) -> std::io::Result<File> {
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
@@ -97,12 +101,23 @@ fn prepare_log_file_with_limit(path: &Path, max_bytes: u64) -> std::io::Result<F
|
||||
}
|
||||
}
|
||||
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.mode(LOG_MODE)
|
||||
.open(path)?;
|
||||
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(LOG_MODE));
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -125,6 +140,7 @@ pub fn log_msg(msg: &str) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
fn temp_log_dir() -> PathBuf {
|
||||
@@ -144,6 +160,9 @@ mod tests {
|
||||
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();
|
||||
|
||||
@@ -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() {
|
||||
if let Err(e) = peerspeak::app::run_gui() {
|
||||
eprintln!("Error running GUI: {:?}", e);
|
||||
|
||||
@@ -5,14 +5,14 @@ use iroh_gossip::proto::TopicId;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use async_trait::async_trait;
|
||||
use tokio_stream::StreamExt;
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
/// Domain-separation tag mixed into every signed gossip payload so a signature
|
||||
/// can never be lifted out of this protocol/version into another context.
|
||||
const GOSSIP_SIG_DOMAIN: &str = "peerspeak-gossip-v1";
|
||||
use crate::protocol::GOSSIP_SIG_DOMAIN;
|
||||
|
||||
/// How far a payload's sender-stamped timestamp may differ from local time
|
||||
/// before it's rejected as stale (replayed) or implausibly future. Bounds the
|
||||
@@ -184,9 +184,16 @@ fn compute_bootstrap(
|
||||
pub enum GossipMessage {
|
||||
Announce(PeerState),
|
||||
Leave,
|
||||
/// A room text-chat message: the author's display name, the text, and a
|
||||
/// sender-stamped millisecond timestamp.
|
||||
Chat { name: String, text: String, ts: u64 },
|
||||
/// A room text-chat message: the author's display name, the text, a
|
||||
/// sender-stamped millisecond timestamp, and an optional file attachment
|
||||
/// 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 {
|
||||
@@ -198,6 +205,10 @@ pub struct IrohGossipState {
|
||||
secret_key: SecretKey,
|
||||
self_state: Arc<Mutex<Option<PeerState>>>,
|
||||
peers: Arc<Mutex<HashMap<EndpointId, PeerState>>>,
|
||||
/// Previously verified peers whose live roster entry was removed by a
|
||||
/// transient disconnect. Retained only so a later authenticated `Leave`
|
||||
/// still reaches core and cancels background recovery.
|
||||
disconnected_peers: Arc<Mutex<HashSet<EndpointId>>>,
|
||||
event_tx: mpsc::Sender<RoomEvent>,
|
||||
event_rx: Mutex<Option<mpsc::Receiver<RoomEvent>>>,
|
||||
active_topic: Mutex<Option<tokio::task::JoinHandle<()>>>,
|
||||
@@ -223,6 +234,7 @@ impl IrohGossipState {
|
||||
secret_key,
|
||||
self_state: Arc::new(Mutex::new(None)),
|
||||
peers: Arc::new(Mutex::new(HashMap::new())),
|
||||
disconnected_peers: Arc::new(Mutex::new(HashSet::new())),
|
||||
event_tx,
|
||||
event_rx: Mutex::new(Some(event_rx)),
|
||||
active_topic: Mutex::new(None),
|
||||
@@ -248,7 +260,11 @@ impl RoomState for IrohGossipState {
|
||||
crate::redact_for_log(ticket_str)
|
||||
));
|
||||
let ticket = ticket_str.parse::<PeerSpeakTicket>()?;
|
||||
let topic_id = TopicId::from_bytes(ticket.topic_id);
|
||||
// Version-namespace the subscribed topic (VERSIONING.md): peers on a
|
||||
// different gossip protocol version derive a different topic from the same
|
||||
// ticket and never share a swarm. The raw ticket.topic_id stays the room
|
||||
// identity (and what signatures bind, below).
|
||||
let topic_id = TopicId::from_bytes(crate::protocol::versioned_topic(ticket.topic_id));
|
||||
|
||||
crate::log_msg(&format!(
|
||||
"Parsed ticket. host_id={}, host_addrs={}, topic={}",
|
||||
@@ -291,6 +307,7 @@ impl RoomState for IrohGossipState {
|
||||
|
||||
let event_tx = self.event_tx.clone();
|
||||
let peers = self.peers.clone();
|
||||
let disconnected_peers = self.disconnected_peers.clone();
|
||||
let address_lookup = self.address_lookup.clone();
|
||||
let self_state_clone = self.self_state.clone();
|
||||
let gossip_sender_clone = gossip_sender.clone();
|
||||
@@ -389,6 +406,7 @@ impl RoomState for IrohGossipState {
|
||||
// peer-supplied: cap/validate once at ingest
|
||||
// so invalid offers never render a Watch button.
|
||||
state.sharing = state.sharing.and_then(crate::screenshare::sanitize_ticket);
|
||||
disconnected_peers.lock().unwrap().remove(&payload.author);
|
||||
let (is_new, state_changed) = {
|
||||
let mut peer_map = peers.lock().unwrap();
|
||||
let is_new = !peer_map.contains_key(&payload.author);
|
||||
@@ -419,17 +437,32 @@ impl RoomState for IrohGossipState {
|
||||
GossipMessage::Leave => {
|
||||
crate::log_msg(&format!("Gossip peer leave request from author={:?}", payload.author));
|
||||
let removed = peers.lock().unwrap().remove(&payload.author).is_some();
|
||||
if removed {
|
||||
let was_disconnected = disconnected_peers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&payload.author);
|
||||
if removed || was_disconnected {
|
||||
let _ = event_tx.send(RoomEvent::PeerLeft(payload.author)).await;
|
||||
}
|
||||
}
|
||||
GossipMessage::Chat { name, text, ts } => {
|
||||
GossipMessage::Chat { name, text, ts, attachment } => {
|
||||
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 {
|
||||
from: payload.author,
|
||||
name,
|
||||
text,
|
||||
ts,
|
||||
attachment,
|
||||
}).await;
|
||||
}
|
||||
}
|
||||
@@ -468,6 +501,7 @@ impl RoomState for IrohGossipState {
|
||||
// cached presence entry; a rejoin re-announces as new.
|
||||
let removed = peers.lock().unwrap().remove(&peer_id).is_some();
|
||||
if removed {
|
||||
disconnected_peers.lock().unwrap().insert(peer_id);
|
||||
crate::log_msg(&format!("Peer connection lost (NeighborDown): {:?}", peer_id));
|
||||
let _ = event_tx.send(RoomEvent::PeerConnectionLost(peer_id)).await;
|
||||
}
|
||||
@@ -512,7 +546,48 @@ impl RoomState for IrohGossipState {
|
||||
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 guard = self.self_state.lock().unwrap();
|
||||
match guard.as_ref() {
|
||||
@@ -529,7 +604,7 @@ impl RoomState for IrohGossipState {
|
||||
&self.secret_key,
|
||||
&topic,
|
||||
ts,
|
||||
GossipMessage::Chat { name, text, ts },
|
||||
GossipMessage::Chat { name, text, ts, attachment },
|
||||
);
|
||||
if let Ok(bytes) = serde_json::to_vec(&payload) {
|
||||
sender.broadcast(bytes.into()).await
|
||||
@@ -567,6 +642,7 @@ impl RoomState for IrohGossipState {
|
||||
}
|
||||
|
||||
self.peers.lock().unwrap().clear();
|
||||
self.disconnected_peers.lock().unwrap().clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -690,13 +766,15 @@ mod tests {
|
||||
name: "Alice".to_string(),
|
||||
text: "Hello".to_string(),
|
||||
ts: 123456789,
|
||||
attachment: None,
|
||||
};
|
||||
let serialized = serde_json::to_string(&original).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!(text, "Hello");
|
||||
assert_eq!(ts, 123456789);
|
||||
assert_eq!(attachment, None);
|
||||
} else {
|
||||
panic!("Expected GossipMessage::Chat");
|
||||
}
|
||||
@@ -706,10 +784,11 @@ mod tests {
|
||||
name: "".to_string(),
|
||||
text: "".to_string(),
|
||||
ts: u64::MAX,
|
||||
attachment: None,
|
||||
};
|
||||
let serialized_empty = serde_json::to_string(&original_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!(text, "");
|
||||
assert_eq!(ts, u64::MAX);
|
||||
@@ -718,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]
|
||||
fn test_gossip_payload_chat_round_trip() {
|
||||
let secret = SecretKey::generate();
|
||||
@@ -730,6 +843,7 @@ mod tests {
|
||||
name: "Bob".to_string(),
|
||||
text: "Hi there".to_string(),
|
||||
ts: 987654321,
|
||||
attachment: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -737,7 +851,7 @@ mod tests {
|
||||
let deserialized: GossipPayload = serde_json::from_str(&serialized).unwrap();
|
||||
|
||||
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!(text, "Hi there");
|
||||
assert_eq!(ts, 987654321);
|
||||
@@ -752,10 +866,11 @@ mod tests {
|
||||
name: "🎙 User".to_string(),
|
||||
text: "héllo 🎙 世界".to_string(),
|
||||
ts: 1717171717,
|
||||
attachment: None,
|
||||
};
|
||||
let serialized = serde_json::to_string(&original).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!(text, "héllo 🎙 世界");
|
||||
assert_eq!(ts, 1717171717);
|
||||
@@ -795,7 +910,7 @@ mod tests {
|
||||
let secret = SecretKey::generate();
|
||||
let topic = [4u8; 32];
|
||||
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!(
|
||||
verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS),
|
||||
Err(GossipReject::BadSignature)
|
||||
@@ -868,8 +983,8 @@ mod tests {
|
||||
fn state_mutation_replay_gate_leaves_chat_ordering_untouched() {
|
||||
let author = fresh_id();
|
||||
let mut seen = HashMap::new();
|
||||
let later_chat = GossipMessage::Chat { name: "A".into(), text: "later".into(), ts: 200 };
|
||||
let earlier_chat = GossipMessage::Chat { name: "A".into(), text: "earlier".into(), ts: 100 };
|
||||
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));
|
||||
|
||||
@@ -5,11 +5,12 @@ use bytes::Bytes;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::Duration;
|
||||
use async_trait::async_trait;
|
||||
|
||||
const AUDIO_ALPN: &[u8] = b"peerspeak-audio";
|
||||
use crate::protocol::{AUDIO_ALPN, FILES_ALPN};
|
||||
use crate::files::{AttachmentId, ChatAttachment};
|
||||
|
||||
/// 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.
|
||||
@@ -31,6 +32,10 @@ const MAX_BACKOFF: Duration = Duration::from_secs(5);
|
||||
/// already means "the peer closed this on purpose."
|
||||
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
|
||||
/// supervisor task. One supervisor owns a peer's whole connection lifecycle.
|
||||
struct Shared {
|
||||
@@ -56,6 +61,15 @@ struct Shared {
|
||||
/// supervisor inserts its connection when the link comes up and removes it
|
||||
/// when the link dies.
|
||||
live_conns: StdMutex<HashMap<EndpointId, Connection>>,
|
||||
/// Core-owned audio admission snapshot for this room session. It mirrors the
|
||||
/// verified gossip roster plus peers still inside reconnect grace; transport
|
||||
/// connections alone never mutate this set.
|
||||
admitted_audio: StdMutex<HashSet<EndpointId>>,
|
||||
/// 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)>,
|
||||
/// Best-effort link-state notifications for the UI (connecting / connected).
|
||||
conn_events_tx: mpsc::Sender<ConnEvent>,
|
||||
@@ -112,6 +126,16 @@ impl Shared {
|
||||
crate::log_msg(&format!("Transport: stopped supervising peer {:?}", peer_id));
|
||||
}
|
||||
}
|
||||
|
||||
fn audio_sender_admitted(&self, peer_id: EndpointId) -> bool {
|
||||
let roster = self.admitted_audio.lock().unwrap();
|
||||
audio_sender_admitted(peer_id, &roster)
|
||||
}
|
||||
|
||||
fn apply_audio_admission(&self, peer_id: EndpointId, event: AudioAdmissionEvent) {
|
||||
let mut roster = self.admitted_audio.lock().unwrap();
|
||||
apply_audio_admission_event(&mut roster, peer_id, event);
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a peer's live-link wait woke up.
|
||||
@@ -135,6 +159,41 @@ fn is_graceful_leave(err: &ConnectionError) -> bool {
|
||||
matches!(err, ConnectionError::ApplicationClosed(frame) if frame.error_code == VarInt::from_u32(GOODBYE_CODE))
|
||||
}
|
||||
|
||||
/// Pure S8 membership decision: iroh already authenticated `remote` as the
|
||||
/// connection's endpoint id, so audio admission is exactly live roster membership.
|
||||
pub(crate) fn audio_sender_admitted(remote: EndpointId, roster: &HashSet<EndpointId>) -> bool {
|
||||
roster.contains(&remote)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum AudioAdmissionEvent {
|
||||
/// A signed gossip Announce/Update says the peer is in the live room roster.
|
||||
RosterPresent,
|
||||
/// Gossip reported a transient drop; keep admission during reconnect grace.
|
||||
TransientDropGrace,
|
||||
/// Graceful leave, transport Left eviction, or reconnect-grace expiry.
|
||||
Remove,
|
||||
}
|
||||
|
||||
pub(crate) fn apply_audio_admission_event(
|
||||
roster: &mut HashSet<EndpointId>,
|
||||
peer_id: EndpointId,
|
||||
event: AudioAdmissionEvent,
|
||||
) {
|
||||
match event {
|
||||
AudioAdmissionEvent::RosterPresent => {
|
||||
roster.insert(peer_id);
|
||||
}
|
||||
AudioAdmissionEvent::TransientDropGrace => {
|
||||
// Grace is not an authority to add membership; it only preserves an
|
||||
// already-admitted peer until either rejoin or grace expiry.
|
||||
}
|
||||
AudioAdmissionEvent::Remove => {
|
||||
roster.remove(&peer_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns a single peer's connection lifecycle for as long as the peer is in the
|
||||
/// room: obtain a link, run the send/read loops, and on loss obtain a new one —
|
||||
/// with capped backoff on the dialing side. The deterministic-initiator rule
|
||||
@@ -333,10 +392,17 @@ impl iroh::protocol::ProtocolHandler for AudioRouter {
|
||||
if shared.self_id.to_string() < peer_id.to_string() {
|
||||
return Ok(());
|
||||
}
|
||||
if !shared.audio_sender_admitted(peer_id) {
|
||||
crate::log_msg(&format!(
|
||||
"Transport: rejected inbound audio from non-member {}",
|
||||
crate::short_id(&peer_id.to_string())
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
// Route the connection to this peer's supervisor (creating it if the
|
||||
// inbound link beat the gossip join event). try_send keeps the
|
||||
// protocol handler from ever blocking; a full queue only happens if
|
||||
// links are churning, and the supervisor will get the next one.
|
||||
// inbound link arrives after the signed gossip Announce admitted it).
|
||||
// try_send keeps the protocol handler from ever blocking; a full queue
|
||||
// only happens if links are churning, and the supervisor gets the next one.
|
||||
let inbound_tx = shared.ensure_supervisor(peer_id).await;
|
||||
if inbound_tx.try_send(connection).is_err() {
|
||||
crate::log_msg(&format!("Transport: dropped inbound link from {:?} (queue full)", peer_id));
|
||||
@@ -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 {
|
||||
shared: Arc<Shared>,
|
||||
incoming_rx: tokio::sync::Mutex<Option<mpsc::Receiver<(EndpointId, Bytes)>>>,
|
||||
@@ -369,6 +526,8 @@ impl IrohTransport {
|
||||
addrs: StdMutex::new(HashMap::new()),
|
||||
peers: tokio::sync::Mutex::new(HashMap::new()),
|
||||
live_conns: StdMutex::new(HashMap::new()),
|
||||
admitted_audio: StdMutex::new(HashSet::new()),
|
||||
served_files: StdMutex::new(HashMap::new()),
|
||||
incoming_tx,
|
||||
conn_events_tx,
|
||||
});
|
||||
@@ -397,11 +556,89 @@ impl IrohTransport {
|
||||
}
|
||||
self.shared.senders.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
|
||||
// shuts the endpoint/router down (the `conns` clones are still alive
|
||||
// here, so the endpoint can still transmit them).
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
}
|
||||
|
||||
/// Admit a peer to this session's audio plane. Core calls this from verified
|
||||
/// gossip roster events; the transport never derives membership on its own.
|
||||
pub fn admit_audio_sender(&self, peer_id: EndpointId) {
|
||||
self.shared
|
||||
.apply_audio_admission(peer_id, AudioAdmissionEvent::RosterPresent);
|
||||
}
|
||||
|
||||
/// Preserve an already-admitted peer through the reconnect grace window.
|
||||
pub fn keep_audio_sender_for_reconnect_grace(&self, peer_id: EndpointId) {
|
||||
self.shared
|
||||
.apply_audio_admission(peer_id, AudioAdmissionEvent::TransientDropGrace);
|
||||
}
|
||||
|
||||
/// Remove a peer from audio admission before tearing down transport/jitter state.
|
||||
pub fn remove_audio_sender(&self, peer_id: EndpointId) {
|
||||
self.shared
|
||||
.apply_audio_admission(peer_id, AudioAdmissionEvent::Remove);
|
||||
}
|
||||
|
||||
pub fn audio_sender_admitted(&self, peer_id: EndpointId) -> bool {
|
||||
self.shared.audio_sender_admitted(peer_id)
|
||||
}
|
||||
|
||||
/// 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]
|
||||
@@ -443,3 +680,85 @@ impl NetworkTransport for IrohTransport {
|
||||
.ok_or_else(|| NetError::Other("Connection events already subscribed".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use iroh::SecretKey;
|
||||
|
||||
fn endpoint_id() -> EndpointId {
|
||||
SecretKey::generate().public()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_sender_admission_accepts_roster_member() {
|
||||
let member = endpoint_id();
|
||||
let roster = HashSet::from([member]);
|
||||
|
||||
assert!(audio_sender_admitted(member, &roster));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_sender_admission_rejects_unknown_sender() {
|
||||
let member = endpoint_id();
|
||||
let stranger = endpoint_id();
|
||||
let roster = HashSet::from([member]);
|
||||
|
||||
assert!(!audio_sender_admitted(stranger, &roster));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_sender_admission_rejects_former_member_after_roster_removal() {
|
||||
let former = endpoint_id();
|
||||
let mut roster = HashSet::from([former]);
|
||||
assert!(audio_sender_admitted(former, &roster));
|
||||
|
||||
roster.remove(&former);
|
||||
|
||||
assert!(!audio_sender_admitted(former, &roster));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_sender_admission_waits_for_mid_join_announce() {
|
||||
let joining_peer = endpoint_id();
|
||||
let mut roster = HashSet::new();
|
||||
|
||||
assert!(!audio_sender_admitted(joining_peer, &roster));
|
||||
|
||||
roster.insert(joining_peer);
|
||||
|
||||
assert!(audio_sender_admitted(joining_peer, &roster));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_admission_lifecycle_keeps_peer_through_transient_grace() {
|
||||
let peer = endpoint_id();
|
||||
let mut roster = HashSet::new();
|
||||
|
||||
apply_audio_admission_event(&mut roster, peer, AudioAdmissionEvent::RosterPresent);
|
||||
assert!(audio_sender_admitted(peer, &roster));
|
||||
|
||||
apply_audio_admission_event(&mut roster, peer, AudioAdmissionEvent::TransientDropGrace);
|
||||
assert!(audio_sender_admitted(peer, &roster));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_admission_lifecycle_does_not_add_unknown_peer_on_grace_event() {
|
||||
let peer = endpoint_id();
|
||||
let mut roster = HashSet::new();
|
||||
|
||||
apply_audio_admission_event(&mut roster, peer, AudioAdmissionEvent::TransientDropGrace);
|
||||
|
||||
assert!(!audio_sender_admitted(peer, &roster));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_admission_lifecycle_removes_peer_on_leave_or_grace_expiry() {
|
||||
let peer = endpoint_id();
|
||||
let mut roster = HashSet::from([peer]);
|
||||
|
||||
apply_audio_admission_event(&mut roster, peer, AudioAdmissionEvent::Remove);
|
||||
|
||||
assert!(!audio_sender_admitted(peer, &roster));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,15 @@ pub enum RoomEvent {
|
||||
/// 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
|
||||
/// 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
|
||||
@@ -194,9 +202,25 @@ pub trait RoomState: Send + Sync {
|
||||
/// Updates our local state (e.g. when user mutes/unmutes) and broadcasts it.
|
||||
async fn update_self_state(&self, self_state: PeerState) -> Result<(), NetError>;
|
||||
|
||||
/// Ask the active gossip topic to connect to retained peer addresses without
|
||||
/// leaving or replacing the subscription. This is a recovery primitive only:
|
||||
/// it does not add peers to the authenticated room roster. A peer becomes
|
||||
/// active only after its normal signed `Announce` is received and verified.
|
||||
async fn rebootstrap_peers(&self, peers: Vec<EndpointAddr>) -> Result<(), NetError>;
|
||||
|
||||
/// Remove a peer from the authenticated live roster before background
|
||||
/// recovery. This only revokes membership; a fresh verified `Announce` is
|
||||
/// required to add the peer again.
|
||||
fn mark_peer_disconnected(&self, peer_id: EndpointId);
|
||||
|
||||
/// Broadcasts a room text-chat message authored by us (our display name is
|
||||
/// taken from the current self-state).
|
||||
async fn send_chat(&self, text: String) -> Result<(), NetError>;
|
||||
/// taken from the current self-state), optionally carrying a file attachment
|
||||
/// 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.
|
||||
async fn leave(&self) -> Result<(), NetError>;
|
||||
@@ -342,4 +366,3 @@ mod tests {
|
||||
assert_eq!(original, deserialized);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
//!
|
||||
//! The WAVs are embedded in the binary (`include_bytes!`) so a deployed single
|
||||
//! binary is self-contained — no asset directory to ship alongside it. On first
|
||||
//! use each sound is written once to a temp file, then played fire-and-forget
|
||||
//! via `pw-play` (PipeWire-native; falls back to `paplay`/`aplay`). Playback runs
|
||||
//! on a detached thread that waits on the child, so it never blocks the UI and
|
||||
//! never leaves a zombie. Any failure (no player, no audio) is silent by design —
|
||||
//! a missing chime should never disrupt a call.
|
||||
//! use each sound is written once to a temp file, then played fire-and-forget.
|
||||
//! Linux uses `pw-play` (PipeWire-native; falls back to `paplay`/`aplay`);
|
||||
//! Windows uses PowerShell's `System.Media.SoundPlayer`. Playback runs on a
|
||||
//! detached thread that waits on the child, so it never blocks the UI and never
|
||||
//! leaves a zombie. Any failure (no player, no audio) is silent by design — a
|
||||
//! missing chime should never disrupt a call.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -202,8 +203,14 @@ fn cached_path(sound: Sound) -> Option<PathBuf> {
|
||||
Some(path)
|
||||
}
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
fn escape_powershell_single_quoted(s: &str) -> String {
|
||||
s.replace('\'', "''")
|
||||
}
|
||||
|
||||
/// Try each available player in turn, waiting on the first that starts (which
|
||||
/// reaps the child). Runs on a detached thread, so the wait is harmless.
|
||||
#[cfg(not(windows))]
|
||||
fn spawn_player(path: &Path) {
|
||||
for player in ["pw-play", "paplay", "aplay"] {
|
||||
let started = Command::new(player)
|
||||
@@ -221,6 +228,23 @@ fn spawn_player(path: &Path) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Play through Windows' built-in WAV player. Runs on a detached thread, so
|
||||
/// `PlaySync()` blocking for the sound duration is fine.
|
||||
#[cfg(windows)]
|
||||
fn spawn_player(path: &Path) {
|
||||
let path = escape_powershell_single_quoted(&path.display().to_string());
|
||||
let command = format!("(New-Object System.Media.SoundPlayer '{path}').PlaySync()");
|
||||
let _ = Command::new("powershell")
|
||||
.arg("-NoProfile")
|
||||
.arg("-NonInteractive")
|
||||
.arg("-Command")
|
||||
.arg(command)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -234,6 +258,18 @@ mod tests {
|
||||
assert!(!should_play(false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_powershell_single_quote_escape() {
|
||||
assert_eq!(
|
||||
escape_powershell_single_quoted(r"C:\Users\O'Brien\chime.wav"),
|
||||
r"C:\Users\O''Brien\chime.wav"
|
||||
);
|
||||
assert_eq!(
|
||||
escape_powershell_single_quoted("a'b'c"),
|
||||
"a''b''c"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sound_indices_unique_and_match_all() {
|
||||
// `index()` must be a 0..COUNT bijection in `ALL` order, or the flag
|
||||
|
||||
@@ -31,7 +31,7 @@ use std::time::Duration;
|
||||
|
||||
/// ALPN for the friends presence/control plane. Separate from the audio/gossip
|
||||
/// ALPNs so a control dial never lands on a bare room endpoint and vice versa.
|
||||
pub const FRIENDS_ALPN: &[u8] = b"peerspeak/friends/0";
|
||||
pub const FRIENDS_ALPN: &[u8] = crate::protocol::FRIENDS_ALPN;
|
||||
|
||||
/// Upper bound on a single control message — generous for a Pong carrying a
|
||||
/// member ticket (~300 chars), but rejects a peer trying to make us buffer a
|
||||
|
||||
@@ -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,16 @@ use tokio::process::{Child, Command};
|
||||
/// points elsewhere.
|
||||
const PIXELPASS_BIN: &str = "pixelpass";
|
||||
|
||||
#[cfg(windows)]
|
||||
fn pixelpass_path_candidates(dir: &Path) -> [PathBuf; 2] {
|
||||
[dir.join(PIXELPASS_BIN), dir.join("pixelpass.exe")]
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn pixelpass_path_candidates(dir: &Path) -> [PathBuf; 1] {
|
||||
[dir.join(PIXELPASS_BIN)]
|
||||
}
|
||||
|
||||
/// Pixelpass endpoint tickets are normally ~140 chars. Leave headroom for format
|
||||
/// growth, but reject unbounded gossip payloads before the UI offers "Watch".
|
||||
const MAX_TICKET_LEN: usize = 512;
|
||||
@@ -143,7 +153,7 @@ pub fn pixelpass_path(config_override: Option<&str>) -> Option<PathBuf> {
|
||||
}
|
||||
let path_var = std::env::var_os("PATH")?;
|
||||
std::env::split_paths(&path_var)
|
||||
.map(|dir| dir.join(PIXELPASS_BIN))
|
||||
.flat_map(|dir| pixelpass_path_candidates(&dir))
|
||||
.find(|c| c.is_file())
|
||||
}
|
||||
|
||||
@@ -513,4 +523,14 @@ mod tests {
|
||||
// only assert it doesn't return the empty path as a match.
|
||||
assert_ne!(pixelpass_path(Some(" ")).as_deref(), Some(Path::new("")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixelpass_path_candidates_are_platform_specific() {
|
||||
let dir = Path::new("bin");
|
||||
let candidates: Vec<PathBuf> = pixelpass_path_candidates(dir).into_iter().collect();
|
||||
#[cfg(windows)]
|
||||
assert_eq!(candidates, vec![dir.join("pixelpass"), dir.join("pixelpass.exe")]);
|
||||
#[cfg(not(windows))]
|
||||
assert_eq!(candidates, vec![dir.join("pixelpass")]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,9 @@ pub enum AppTheme {
|
||||
GruvboxDark,
|
||||
SolarizedLight,
|
||||
GruvboxLight,
|
||||
AyuDark,
|
||||
AyuMirage,
|
||||
AyuLight,
|
||||
}
|
||||
|
||||
/// Build a `Color` from a packed `0xRRGGBB` literal — keeps the palette tables
|
||||
@@ -70,7 +73,7 @@ fn hex(c: u32) -> Color {
|
||||
|
||||
impl AppTheme {
|
||||
/// Every theme, in picker order.
|
||||
pub const ALL: [AppTheme; 10] = [
|
||||
pub const ALL: [AppTheme; 13] = [
|
||||
AppTheme::Mocha,
|
||||
AppTheme::Macchiato,
|
||||
AppTheme::Frappe,
|
||||
@@ -81,6 +84,9 @@ impl AppTheme {
|
||||
AppTheme::GruvboxDark,
|
||||
AppTheme::SolarizedLight,
|
||||
AppTheme::GruvboxLight,
|
||||
AppTheme::AyuDark,
|
||||
AppTheme::AyuMirage,
|
||||
AppTheme::AyuLight,
|
||||
];
|
||||
|
||||
/// Human-readable name for the picker.
|
||||
@@ -96,6 +102,9 @@ impl AppTheme {
|
||||
AppTheme::GruvboxDark => "Gruvbox Dark",
|
||||
AppTheme::SolarizedLight => "Solarized 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 {
|
||||
!matches!(
|
||||
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::SolarizedLight => iced::Theme::SolarizedLight,
|
||||
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),
|
||||
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() {
|
||||
// ALL covers exactly the variants once, each with a unique non-empty label
|
||||
// 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();
|
||||
labels.sort_unstable();
|
||||
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()));
|
||||
}
|
||||
|
||||
|
||||
@@ -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::network::{ConnEvent, NetworkTransport};
|
||||
use peerspeak::network::iroh_impl::{AudioRouter, IrohTransport};
|
||||
|
||||
const AUDIO_ALPN: &[u8] = b"peerspeak-audio";
|
||||
use peerspeak::protocol::AUDIO_ALPN;
|
||||
|
||||
struct Node {
|
||||
endpoint: Endpoint,
|
||||
@@ -169,6 +168,9 @@ async fn loopback_sequenced_audio_reaches_peer_and_decodes() {
|
||||
b.lookup.add_endpoint_info(a.endpoint.addr());
|
||||
|
||||
let a_id = a.endpoint.id();
|
||||
let b_id = b.endpoint.id();
|
||||
a.transport.admit_audio_sender(b_id);
|
||||
b.transport.admit_audio_sender(a_id);
|
||||
|
||||
// Subscribe to incoming datagrams on B before any are sent.
|
||||
let mut b_rx = b.transport.receive_datagrams().await.expect("subscribe B");
|
||||
|
||||
@@ -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 "$@"
|
||||