Compare commits
38
Commits
ci-cargo-deny
...
v0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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,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/
|
||||
|
||||
Generated
+269
-25
@@ -105,6 +105,28 @@ version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "alsa"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
|
||||
dependencies = [
|
||||
"alsa-sys",
|
||||
"bitflags 2.11.1",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alsa-sys"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android-activity"
|
||||
version = "0.6.1"
|
||||
@@ -114,12 +136,12 @@ dependencies = [
|
||||
"android-properties",
|
||||
"bitflags 2.11.1",
|
||||
"cc",
|
||||
"jni",
|
||||
"jni 0.22.4",
|
||||
"libc",
|
||||
"log",
|
||||
"ndk",
|
||||
"ndk 0.9.0",
|
||||
"ndk-context",
|
||||
"ndk-sys",
|
||||
"ndk-sys 0.6.0+11769913",
|
||||
"num_enum",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
@@ -712,6 +734,12 @@ dependencies = [
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cesu8"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
||||
|
||||
[[package]]
|
||||
name = "cexpr"
|
||||
version = "0.6.0"
|
||||
@@ -1002,6 +1030,26 @@ dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coreaudio-rs"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation-sys",
|
||||
"coreaudio-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coreaudio-sys"
|
||||
version = "0.2.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cosmic-text"
|
||||
version = "0.15.0"
|
||||
@@ -1026,6 +1074,29 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpal"
|
||||
version = "0.15.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
|
||||
dependencies = [
|
||||
"alsa",
|
||||
"core-foundation-sys",
|
||||
"coreaudio-rs",
|
||||
"dasp_sample",
|
||||
"jni 0.21.1",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"mach2",
|
||||
"ndk 0.8.0",
|
||||
"ndk-context",
|
||||
"oboe",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows 0.54.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -1228,6 +1299,12 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_sample"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.0"
|
||||
@@ -2227,7 +2304,7 @@ dependencies = [
|
||||
"http",
|
||||
"idna",
|
||||
"ipnet",
|
||||
"jni",
|
||||
"jni 0.22.4",
|
||||
"rand 0.10.1",
|
||||
"rustls",
|
||||
"thiserror 2.0.18",
|
||||
@@ -2247,7 +2324,7 @@ dependencies = [
|
||||
"data-encoding",
|
||||
"idna",
|
||||
"ipnet",
|
||||
"jni",
|
||||
"jni 0.22.4",
|
||||
"once_cell",
|
||||
"prefix-trie",
|
||||
"rand 0.10.1",
|
||||
@@ -2270,7 +2347,7 @@ dependencies = [
|
||||
"hickory-proto",
|
||||
"ipconfig",
|
||||
"ipnet",
|
||||
"jni",
|
||||
"jni 0.22.4",
|
||||
"moka",
|
||||
"ndk-context",
|
||||
"once_cell",
|
||||
@@ -3102,6 +3179,22 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
|
||||
dependencies = [
|
||||
"cesu8",
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"thiserror 1.0.69",
|
||||
"walkdir",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.22.4"
|
||||
@@ -3463,6 +3556,15 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f"
|
||||
|
||||
[[package]]
|
||||
name = "mach2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "malloc_buf"
|
||||
version = "0.0.6"
|
||||
@@ -3596,7 +3698,7 @@ dependencies = [
|
||||
"dispatch",
|
||||
"futures-channel",
|
||||
"futures-lite",
|
||||
"jni",
|
||||
"jni 0.22.4",
|
||||
"ndk-context",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
@@ -3695,6 +3797,20 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"ndk-sys 0.5.0+25.2.9519653",
|
||||
"num_enum",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@@ -3704,7 +3820,7 @@ dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"ndk-sys",
|
||||
"ndk-sys 0.6.0+11769913",
|
||||
"num_enum",
|
||||
"raw-window-handle",
|
||||
"thiserror 1.0.69",
|
||||
@@ -3716,6 +3832,15 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
|
||||
|
||||
[[package]]
|
||||
name = "ndk-sys"
|
||||
version = "0.5.0+25.2.9519653"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691"
|
||||
dependencies = [
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk-sys"
|
||||
version = "0.6.0+11769913"
|
||||
@@ -4466,6 +4591,29 @@ dependencies = [
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oboe"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
|
||||
dependencies = [
|
||||
"jni 0.21.1",
|
||||
"ndk 0.8.0",
|
||||
"ndk-context",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
"oboe-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oboe-sys"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -4594,12 +4742,13 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "peerspeak"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"base64",
|
||||
"bytes",
|
||||
"cpal",
|
||||
"dirs",
|
||||
"iced",
|
||||
"image",
|
||||
@@ -5436,7 +5585,7 @@ checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"jni",
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
@@ -5877,7 +6026,7 @@ dependencies = [
|
||||
"fastrand",
|
||||
"js-sys",
|
||||
"memmap2",
|
||||
"ndk",
|
||||
"ndk 0.9.0",
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
@@ -7162,7 +7311,7 @@ dependencies = [
|
||||
"log",
|
||||
"metal",
|
||||
"naga",
|
||||
"ndk-sys",
|
||||
"ndk-sys 0.6.0+11769913",
|
||||
"objc",
|
||||
"once_cell",
|
||||
"ordered-float",
|
||||
@@ -7247,6 +7396,16 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.54.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
|
||||
dependencies = [
|
||||
"windows-core 0.54.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.58.0"
|
||||
@@ -7254,7 +7413,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
|
||||
dependencies = [
|
||||
"windows-core 0.58.0",
|
||||
"windows-targets",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7278,6 +7437,16 @@ dependencies = [
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.54.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
|
||||
dependencies = [
|
||||
"windows-result 0.1.2",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.58.0"
|
||||
@@ -7288,7 +7457,7 @@ dependencies = [
|
||||
"windows-interface 0.58.0",
|
||||
"windows-result 0.2.0",
|
||||
"windows-strings 0.1.0",
|
||||
"windows-targets",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7386,13 +7555,22 @@ dependencies = [
|
||||
"windows-strings 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7411,7 +7589,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
|
||||
dependencies = [
|
||||
"windows-result 0.2.0",
|
||||
"windows-targets",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7423,13 +7601,22 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
|
||||
dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7441,20 +7628,35 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.42.2",
|
||||
"windows_aarch64_msvc 0.42.2",
|
||||
"windows_i686_gnu 0.42.2",
|
||||
"windows_i686_msvc 0.42.2",
|
||||
"windows_x86_64_gnu 0.42.2",
|
||||
"windows_x86_64_gnullvm 0.42.2",
|
||||
"windows_x86_64_msvc 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7466,18 +7668,36 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -7490,24 +7710,48 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -7536,7 +7780,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"libc",
|
||||
"memmap2",
|
||||
"ndk",
|
||||
"ndk 0.9.0",
|
||||
"objc2 0.5.2",
|
||||
"objc2-app-kit 0.2.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
|
||||
+24
-8
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "peerspeak"
|
||||
version = "0.2.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.
|
||||
@@ -30,17 +30,12 @@ bytes = "1.11.1"
|
||||
dirs = "6.0.0"
|
||||
iced = { version = "0.14.0", features = ["canvas", "image"] }
|
||||
# W4 custom avatars: decode/resize an arbitrary user image (png/jpeg only to keep
|
||||
# the codec surface small) and a native file picker (xdg-portal backend, no GTK).
|
||||
# the codec surface small). The matching native file picker (`rfd`) is platform-
|
||||
# gated below — its backend differs per OS (xdg-portal on Linux, Win32 on Windows).
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
rfd = { version = "0.17", default-features = false, features = ["xdg-portal"] }
|
||||
iroh = "1.0.0-rc.0"
|
||||
iroh-gossip = "0.99.0"
|
||||
opus = "0.3.1"
|
||||
# v0_3_49 exposes `Buffer::requested()` (the graph's per-cycle quantum), used by
|
||||
# the playback RT callback to fill exactly what the device asks for instead of
|
||||
# pinning the buffer to a hard-coded 1024-frame quantum (crackle on non-1024
|
||||
# hardware). The field has existed in libpipewire since 0.3.49 (2022).
|
||||
pipewire = { version = "0.9", features = ["v0_3_49"] }
|
||||
rand = "0.10.1"
|
||||
ringbuf = "0.5.0"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
@@ -48,3 +43,24 @@ serde_json = "1.0.150"
|
||||
thiserror = "2.0.18"
|
||||
tokio = { version = "1.52.3", features = ["full"] }
|
||||
tokio-stream = "0.1.18"
|
||||
|
||||
# --- Platform-specific dependencies -----------------------------------------
|
||||
# Audio and the native file-picker backends differ per OS. Everything else in the
|
||||
# app talks to the `AudioBackend` trait and the `PlatformAudioBackend` alias (see
|
||||
# `src/audio/mod.rs`), so platform selection is confined to these few lines.
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Linux audio backend. v0_3_49 exposes `Buffer::requested()` (the graph's per-cycle
|
||||
# quantum), used by the playback RT callback to fill exactly what the device asks
|
||||
# for instead of a hard-coded 1024-frame quantum (crackle on non-1024 hardware).
|
||||
# The field has existed in libpipewire since 0.3.49 (2022).
|
||||
pipewire = { version = "0.9", features = ["v0_3_49"] }
|
||||
# Native file picker via the XDG desktop portal (no GTK) on Linux.
|
||||
rfd = { version = "0.17", default-features = false, features = ["xdg-portal"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
# Native file picker using the built-in Win32 dialog backend on Windows.
|
||||
rfd = { version = "0.17", default-features = false }
|
||||
# Windows audio backend: cpal drives WASAPI for capture/playback behind the
|
||||
# AudioBackend trait (src/audio/cpal_impl.rs). The Linux counterpart is pipewire.
|
||||
cpal = "0.15"
|
||||
|
||||
@@ -0,0 +1,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
-1
@@ -1,7 +1,7 @@
|
||||
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
|
||||
pkgname=peerspeak-git
|
||||
_pkgname=peerspeak
|
||||
pkgver=0.1.0
|
||||
pkgver=0.2.0.r218.gcbba4b6
|
||||
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.
|
||||
Binary file not shown.
|
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"
|
||||
+637
-47
@@ -2,7 +2,7 @@ use crate::core::{CoreController, messages::{CoreCommand, UiEvent}};
|
||||
use crate::network::PeerState;
|
||||
use crate::notify::{self, Sound};
|
||||
use crate::audio::eq::{EqSettings, EQ_GAIN_DB_MAX, EQ_GAIN_DB_MIN};
|
||||
use crate::audio::pw_cli::{AudioDevice, enumerate_audio_devices};
|
||||
use crate::audio::{AudioDevice, enumerate_audio_devices};
|
||||
use crate::config::{AppConfig, NetworkMode, RecordingMode, RoomLayout};
|
||||
use crate::hotkeys::{format_binding, HotkeyAction, HotkeyContext, KeyBinding};
|
||||
use crate::presence::PresenceMode;
|
||||
@@ -114,6 +114,19 @@ struct ChatEntry {
|
||||
/// Sender's node id string, used to key their avatar colour (W4). `None` only
|
||||
/// for any future system-generated lines.
|
||||
from: Option<String>,
|
||||
/// Optional file attachment descriptor. The bytes (if fetched) live in
|
||||
/// `AppState.attachment_data` keyed by `attachment.id`; the entry only holds
|
||||
/// the descriptor so history stays cheap.
|
||||
attachment: Option<crate::files::ChatAttachment>,
|
||||
}
|
||||
|
||||
/// Fetch state of a chat attachment's bytes (session-only).
|
||||
#[derive(Debug, Clone)]
|
||||
enum AttachmentState {
|
||||
/// Bytes in hand (image decoded-valid, or a file ready to save).
|
||||
Ready(Vec<u8>),
|
||||
/// Fetch or decode failed; carries a short reason for the UI.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Cap on retained chat history so a long call can't grow it without bound.
|
||||
@@ -208,6 +221,7 @@ pub enum AppMessage {
|
||||
ClearHotkey(HotkeyAction),
|
||||
PeerVolumeChanged(EndpointId, f32),
|
||||
PeerPanChanged(EndpointId, f32),
|
||||
PeerGateChanged(EndpointId, f32),
|
||||
PeerEqChanged(EndpointId, EqBand, f32),
|
||||
/// Toggle local mute of a peer (silence them just for us).
|
||||
TogglePeerMute(EndpointId),
|
||||
@@ -263,6 +277,12 @@ pub enum AppMessage {
|
||||
ToggleRecording,
|
||||
/// Live edits to the chat input line.
|
||||
ChatInputChanged(String),
|
||||
/// Open the native picker to attach a file to the chat.
|
||||
PickAttachmentFile,
|
||||
/// Result of the attach picker: (filename, bytes), or None if cancelled.
|
||||
AttachmentFilePicked(Option<(String, Vec<u8>)>),
|
||||
/// Save (downloading first if needed) a received attachment to disk.
|
||||
SaveAttachment(crate::files::AttachmentId),
|
||||
/// Send the current chat input line (Enter or the Send button).
|
||||
ChatSubmit,
|
||||
/// Open a clicked chat link in the system browser (A13).
|
||||
@@ -290,6 +310,15 @@ pub enum AppMessage {
|
||||
/// Result of the avatar file picker: the chosen file's raw bytes, or `None`
|
||||
/// if the user cancelled.
|
||||
AvatarFilePicked(Option<Vec<u8>>),
|
||||
/// Open the native file picker to choose a custom UI background image (W16).
|
||||
PickBackgroundFile,
|
||||
/// Result of the background file picker: the chosen file's raw bytes, or
|
||||
/// `None` if the user cancelled.
|
||||
BackgroundFilePicked(Option<Vec<u8>>),
|
||||
/// Clear the custom background, reverting to the theme background (W16).
|
||||
RemoveBackground,
|
||||
/// Set the background legibility scrim strength (0.0..=1.0) (W16).
|
||||
SetBackgroundDim(f32),
|
||||
/// Toggle the Chat drawer open/closed (drawer layout).
|
||||
ToggleDrawerChat,
|
||||
/// Start/stop sharing our own screen (spawns/kills a pixelpass host).
|
||||
@@ -335,8 +364,11 @@ pub struct AppState {
|
||||
selected_input: Option<AudioDevice>,
|
||||
selected_output: Option<AudioDevice>,
|
||||
config: AppConfig,
|
||||
/// Decoded bytes of the custom background image (W16), cached so `view()`
|
||||
/// doesn't read the file from disk on every redraw. Loaded on startup and
|
||||
/// refreshed when the background is changed/removed. `None` = no custom bg.
|
||||
background_image: Option<bytes::Bytes>,
|
||||
peers: HashMap<EndpointId, PeerState>,
|
||||
peer_volumes: HashMap<EndpointId, f32>,
|
||||
audio_levels: HashMap<EndpointId, f32>,
|
||||
/// Peers we've locally muted (their audio isn't mixed into our output).
|
||||
locally_muted: HashSet<EndpointId>,
|
||||
@@ -349,6 +381,15 @@ pub struct AppState {
|
||||
/// Room text-chat history (newest last) and the pending input line.
|
||||
chat_messages: Vec<ChatEntry>,
|
||||
chat_input: String,
|
||||
/// Fetched/failed state for chat attachments, keyed by attachment id.
|
||||
/// Session-only (cleared on leave); never persisted.
|
||||
attachment_data: HashMap<crate::files::AttachmentId, AttachmentState>,
|
||||
/// Cached iced image handles for ready image attachments, keyed by id, so we
|
||||
/// don't re-upload to the GPU every redraw (the e917c53 avatar flicker fix).
|
||||
image_handle_cache: HashMap<crate::files::AttachmentId, iced::widget::image::Handle>,
|
||||
/// Attachment ids the user asked to save before the bytes arrived; when the
|
||||
/// fetch completes a save dialog is opened for them.
|
||||
pending_saves: std::collections::HashSet<crate::files::AttachmentId>,
|
||||
/// Last known window size, tracked so divider clamps stay valid on resize.
|
||||
/// (The divider positions themselves are persisted in `config`.)
|
||||
window_size: Size,
|
||||
@@ -461,6 +502,16 @@ impl Default for AppState {
|
||||
let _ = controller.send(CoreCommand::SetPeerPan(id, *pan));
|
||||
}
|
||||
}
|
||||
for (peer, volume) in &config.peer_volume {
|
||||
if let Ok(id) = peer.parse::<EndpointId>() {
|
||||
let _ = controller.send(CoreCommand::SetPeerVolume(id, *volume));
|
||||
}
|
||||
}
|
||||
for (peer, threshold) in &config.peer_gate {
|
||||
if let Ok(id) = peer.parse::<EndpointId>() {
|
||||
let _ = controller.send(CoreCommand::SetPeerGate(id, *threshold));
|
||||
}
|
||||
}
|
||||
let pixelpass_available =
|
||||
crate::screenshare::is_available(config.pixelpass_path.as_deref());
|
||||
let all_devices = enumerate_audio_devices();
|
||||
@@ -470,6 +521,7 @@ impl Default for AppState {
|
||||
let selected_input = input_devices.iter().find(|d| d.name == config.input_device).cloned();
|
||||
let selected_output = output_devices.iter().find(|d| d.name == config.output_device).cloned();
|
||||
|
||||
let background_image = load_background_bytes(&config);
|
||||
|
||||
Self {
|
||||
// Pre-fill the nickname with the last one used (or "Peer" by default).
|
||||
@@ -489,14 +541,17 @@ impl Default for AppState {
|
||||
selected_input,
|
||||
selected_output,
|
||||
config,
|
||||
background_image,
|
||||
peers: HashMap::new(),
|
||||
peer_volumes: HashMap::new(),
|
||||
audio_levels: HashMap::new(),
|
||||
locally_muted: HashSet::new(),
|
||||
call_started: None,
|
||||
recording: false,
|
||||
recording_started: None,
|
||||
chat_messages: Vec::new(),
|
||||
attachment_data: HashMap::new(),
|
||||
image_handle_cache: HashMap::new(),
|
||||
pending_saves: std::collections::HashSet::new(),
|
||||
chat_input: String::new(),
|
||||
window_size: Size::new(ww, wh),
|
||||
layout_picker_open: false,
|
||||
@@ -533,6 +588,15 @@ fn theme(state: &AppState) -> Theme {
|
||||
state.config.theme.base_theme()
|
||||
}
|
||||
|
||||
/// Read the custom background PNG (W16) from disk into memory, if one is set and
|
||||
/// readable. Called once on startup and whenever the background changes, so the
|
||||
/// per-frame `view()` never touches the filesystem. A missing/unreadable file
|
||||
/// silently yields `None` (the UI falls back to the theme background).
|
||||
fn load_background_bytes(config: &AppConfig) -> Option<bytes::Bytes> {
|
||||
let path = config.background.as_deref()?;
|
||||
std::fs::read(path).ok().map(bytes::Bytes::from)
|
||||
}
|
||||
|
||||
pub fn run_gui() -> iced::Result {
|
||||
// Restore the last window size (saved on close). Position is restored too,
|
||||
// but only on X11 — Wayland's xdg-shell gives clients no way to set their own
|
||||
@@ -540,7 +604,7 @@ pub fn run_gui() -> iced::Result {
|
||||
let saved = AppConfig::load();
|
||||
let init_size = iced::Size::new(saved.window_width, saved.window_height);
|
||||
let init_position = initial_window_position(saved.window_x, saved.window_y, is_wayland());
|
||||
iced::application(AppState::default, update, view)
|
||||
iced::application(AppState::default, update, view_with_background)
|
||||
.title("PeerSpeak P2P Voice Chat")
|
||||
.theme(theme)
|
||||
.subscription(subscription)
|
||||
@@ -553,11 +617,9 @@ pub fn run_gui() -> iced::Result {
|
||||
// the icon from the .desktop file matched by app_id instead).
|
||||
icon: window_icon(),
|
||||
// app_id must match the .desktop basename so Wayland compositors
|
||||
// (e.g. KWin) attach our launcher icon to the window.
|
||||
platform_specific: iced::window::settings::PlatformSpecific {
|
||||
application_id: "peerspeak".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
// (e.g. KWin) attach our launcher icon to the window. The field is
|
||||
// Linux-only in iced (X11/Wayland); see platform_specific_settings().
|
||||
platform_specific: platform_specific_settings(),
|
||||
// We save the final size ourselves on CloseRequested, then exit.
|
||||
exit_on_close_request: false,
|
||||
..Default::default()
|
||||
@@ -565,6 +627,22 @@ pub fn run_gui() -> iced::Result {
|
||||
.run()
|
||||
}
|
||||
|
||||
/// Window `PlatformSpecific` settings. `application_id` (used by X11/Wayland to
|
||||
/// match our `.desktop` launcher icon) only exists in iced on Linux, so it is
|
||||
/// set there and left at defaults on Windows.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn platform_specific_settings() -> iced::window::settings::PlatformSpecific {
|
||||
iced::window::settings::PlatformSpecific {
|
||||
application_id: "peerspeak".to_string(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn platform_specific_settings() -> iced::window::settings::PlatformSpecific {
|
||||
iced::window::settings::PlatformSpecific::default()
|
||||
}
|
||||
|
||||
/// Build the window icon from an embedded 128×128 straight-RGBA blob rendered
|
||||
/// from `assets/icons/peerspeak.svg`. Using `from_rgba` (always available) keeps
|
||||
/// us off iced's heavy `image` feature — the blob is raw pixels, no decoder.
|
||||
@@ -706,6 +784,34 @@ fn set_peer_pan_config(config: &mut AppConfig, id: EndpointId, pan: f32) -> f32
|
||||
pan
|
||||
}
|
||||
|
||||
/// Store the per-peer listener volume, clamped to the slider range. Unity gain
|
||||
/// (`1.0`) is the implicit default, so an at-unity entry is removed rather than
|
||||
/// stored to keep the config tidy. Returns the clamped value.
|
||||
fn set_peer_volume_config(config: &mut AppConfig, id: EndpointId, volume: f32) -> f32 {
|
||||
let volume = volume.clamp(0.0, 2.0);
|
||||
let key = id.to_string();
|
||||
if (volume - 1.0).abs() <= 0.001 {
|
||||
config.peer_volume.remove(&key);
|
||||
} else {
|
||||
config.peer_volume.insert(key, volume);
|
||||
}
|
||||
volume
|
||||
}
|
||||
|
||||
/// Store the per-peer listener noise-gate threshold, clamped to the slider
|
||||
/// range. `0.0` means the gate is off, so an at-zero entry is removed rather
|
||||
/// than stored. Returns the clamped value.
|
||||
fn set_peer_gate_config(config: &mut AppConfig, id: EndpointId, threshold: f32) -> f32 {
|
||||
let threshold = threshold.clamp(0.0, METER_MAX);
|
||||
let key = id.to_string();
|
||||
if threshold <= 0.0 {
|
||||
config.peer_gate.remove(&key);
|
||||
} else {
|
||||
config.peer_gate.insert(key, threshold);
|
||||
}
|
||||
threshold
|
||||
}
|
||||
|
||||
fn peer_eq_settings(config: &AppConfig, id: &EndpointId) -> EqSettings {
|
||||
config
|
||||
.peer_eq
|
||||
@@ -874,6 +980,10 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
state.ever_connected.remove(&id);
|
||||
notify::play(Sound::PeerLeave, state.config.custom_sound_peer_leave.as_deref());
|
||||
}
|
||||
// Core-only recovery phase: presentation for this state lands in
|
||||
// the separate UI follow-up. In particular, do not play the
|
||||
// terminal ReconnectFailed chime here.
|
||||
UiEvent::PeerRecoveryStarted { .. } => {}
|
||||
UiEvent::PeerConnectionFailed { id } => {
|
||||
state.peers.remove(&id);
|
||||
state.audio_levels.remove(&id);
|
||||
@@ -917,19 +1027,45 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
state.recording_started = None;
|
||||
state.status_message = format!("Saved recording → {path}");
|
||||
}
|
||||
UiEvent::ChatMessage { from, name, text } => {
|
||||
UiEvent::ChatMessage { from, name, text, attachment } => {
|
||||
// Incoming peer content is untrusted — sanitize name + text.
|
||||
// (The attachment filename was already sanitized in core.)
|
||||
let text = sanitize_chat(&text);
|
||||
if !text.is_empty() {
|
||||
// Keep the message if it has visible text OR an attachment (an
|
||||
// image with no caption is still a real message).
|
||||
if !text.is_empty() || attachment.is_some() {
|
||||
let name = sanitize_chat(&name);
|
||||
push_chat(&mut state.chat_messages, ChatEntry {
|
||||
name,
|
||||
text,
|
||||
mine: false,
|
||||
from: Some(from),
|
||||
attachment,
|
||||
});
|
||||
}
|
||||
}
|
||||
UiEvent::AttachmentReady { id, data } => {
|
||||
// Bytes arrived. For images we can cache the iced handle now
|
||||
// (built once, not per redraw). If the user was waiting to save
|
||||
// this file, the save dialog is opened from update() below by
|
||||
// checking pending_saves — done lazily so this arm stays simple.
|
||||
if crate::files::validate_image_bytes(&data).is_some() {
|
||||
state.image_handle_cache.insert(
|
||||
id,
|
||||
iced::widget::image::Handle::from_bytes(data.clone()),
|
||||
);
|
||||
}
|
||||
let needs_save = state.pending_saves.remove(&id);
|
||||
state.attachment_data.insert(id, AttachmentState::Ready(data));
|
||||
if needs_save {
|
||||
save_attachment_to_disk(state, id);
|
||||
}
|
||||
}
|
||||
UiEvent::AttachmentFailed { id, error } => {
|
||||
state.pending_saves.remove(&id);
|
||||
state.attachment_data.insert(id, AttachmentState::Failed(error.clone()));
|
||||
state.status_message = format!("Attachment failed: {error}");
|
||||
}
|
||||
UiEvent::ScreenShareStarted => {
|
||||
state.self_sharing = true;
|
||||
state.status_message = "Sharing your screen".to_string();
|
||||
@@ -996,13 +1132,17 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
state.config.save();
|
||||
}
|
||||
AppMessage::PeerVolumeChanged(id, vol) => {
|
||||
state.peer_volumes.insert(id, vol);
|
||||
let vol = set_peer_volume_config(&mut state.config, id, vol);
|
||||
let _ = state.controller.send(CoreCommand::SetPeerVolume(id, vol));
|
||||
}
|
||||
AppMessage::PeerPanChanged(id, pan) => {
|
||||
let pan = set_peer_pan_config(&mut state.config, id, pan);
|
||||
let _ = state.controller.send(CoreCommand::SetPeerPan(id, pan));
|
||||
}
|
||||
AppMessage::PeerGateChanged(id, threshold) => {
|
||||
let threshold = set_peer_gate_config(&mut state.config, id, threshold);
|
||||
let _ = state.controller.send(CoreCommand::SetPeerGate(id, threshold));
|
||||
}
|
||||
AppMessage::PeerEqChanged(id, band, gain_db) => {
|
||||
let settings = set_peer_eq_config(&mut state.config, id, band, gain_db);
|
||||
let _ = state.controller.send(CoreCommand::SetPeerEq(id, settings));
|
||||
@@ -1329,6 +1469,73 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
}
|
||||
}
|
||||
}
|
||||
AppMessage::PickBackgroundFile => {
|
||||
// Native picker off the UI thread; result returns as BackgroundFilePicked.
|
||||
return Task::perform(
|
||||
async {
|
||||
let handle = rfd::AsyncFileDialog::new()
|
||||
.add_filter("Images", &["png", "jpg", "jpeg", "webp", "bmp"])
|
||||
.set_title("Choose a background image")
|
||||
.pick_file()
|
||||
.await;
|
||||
match handle {
|
||||
Some(h) => Some(h.read().await),
|
||||
None => None,
|
||||
}
|
||||
},
|
||||
AppMessage::BackgroundFilePicked,
|
||||
);
|
||||
}
|
||||
AppMessage::BackgroundFilePicked(picked) => {
|
||||
if let Some(bytes) = picked {
|
||||
match crate::background::process_background(&bytes) {
|
||||
Ok(png) => match AppConfig::background_path() {
|
||||
Some(path) => {
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
match std::fs::write(&path, &png) {
|
||||
Ok(()) => {
|
||||
state.config.background =
|
||||
Some(path.to_string_lossy().into_owned());
|
||||
state.config.save();
|
||||
// Refresh the in-memory cache from the bytes we
|
||||
// just wrote (avoids re-reading from disk).
|
||||
state.background_image = Some(bytes::Bytes::from(png));
|
||||
state.status_message = "Background updated.".to_string();
|
||||
}
|
||||
Err(e) => {
|
||||
state.status_message =
|
||||
format!("Couldn't save background: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
state.status_message =
|
||||
"Couldn't find a config directory to save the background."
|
||||
.to_string();
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
state.status_message = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AppMessage::RemoveBackground => {
|
||||
// Best-effort delete of our stored copy; clear the config + cache.
|
||||
if let Some(path) = AppConfig::background_path() {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
state.config.background = None;
|
||||
state.config.save();
|
||||
state.background_image = None;
|
||||
state.status_message = "Background removed.".to_string();
|
||||
}
|
||||
AppMessage::SetBackgroundDim(dim) => {
|
||||
state.config.background_dim = dim.clamp(0.0, 1.0);
|
||||
state.config.save();
|
||||
}
|
||||
AppMessage::ToggleDrawerChat => {
|
||||
state.drawer_chat_open = !state.drawer_chat_open;
|
||||
}
|
||||
@@ -1341,21 +1548,119 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
text: text.clone(),
|
||||
mine: true,
|
||||
from: Some(state.self_id.clone()),
|
||||
attachment: None,
|
||||
});
|
||||
let _ = state.controller.send(CoreCommand::SendChat(text));
|
||||
state.chat_input.clear();
|
||||
}
|
||||
}
|
||||
AppMessage::PickAttachmentFile => {
|
||||
// Native picker off the UI thread; returns (filename, bytes).
|
||||
return Task::perform(
|
||||
async {
|
||||
let handle = rfd::AsyncFileDialog::new()
|
||||
.set_title("Attach a file to the chat")
|
||||
.pick_file()
|
||||
.await;
|
||||
match handle {
|
||||
Some(h) => Some((h.file_name(), h.read().await)),
|
||||
None => None,
|
||||
}
|
||||
},
|
||||
AppMessage::AttachmentFilePicked,
|
||||
);
|
||||
}
|
||||
AppMessage::AttachmentFilePicked(picked) => {
|
||||
if let Some((name, bytes)) = picked {
|
||||
let size = bytes.len() as u64;
|
||||
if !crate::files::size_within_cap(size) {
|
||||
state.status_message = format!(
|
||||
"File too large — max {}.",
|
||||
crate::files::human_size(crate::files::MAX_ATTACHMENT_BYTES)
|
||||
);
|
||||
return Task::none();
|
||||
}
|
||||
let kind = crate::files::classify(&bytes);
|
||||
// Random 32-byte handle for this attachment.
|
||||
let id: crate::files::AttachmentId = rand::random();
|
||||
let att = crate::files::ChatAttachment {
|
||||
name: crate::files::sanitize_filename(&name),
|
||||
size,
|
||||
kind,
|
||||
id,
|
||||
};
|
||||
// Keep our own bytes locally so we see our own attachment inline
|
||||
// immediately (others fetch it off the file plane).
|
||||
if kind == crate::files::AttachmentKind::Image
|
||||
&& crate::files::validate_image_bytes(&bytes).is_some()
|
||||
{
|
||||
state
|
||||
.image_handle_cache
|
||||
.insert(id, iced::widget::image::Handle::from_bytes(bytes.clone()));
|
||||
}
|
||||
state
|
||||
.attachment_data
|
||||
.insert(id, AttachmentState::Ready(bytes.clone()));
|
||||
push_chat(
|
||||
&mut state.chat_messages,
|
||||
ChatEntry {
|
||||
name: format!("{} (You)", state.name),
|
||||
text: String::new(),
|
||||
mine: true,
|
||||
from: Some(state.self_id.clone()),
|
||||
attachment: Some(att.clone()),
|
||||
},
|
||||
);
|
||||
let _ = state.controller.send(CoreCommand::SendChatFile {
|
||||
text: String::new(),
|
||||
attachment: att,
|
||||
data: bytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
AppMessage::SaveAttachment(id) => {
|
||||
// If we already have the bytes, save now; otherwise fetch from the
|
||||
// sender and save when AttachmentReady arrives (pending_saves).
|
||||
if matches!(state.attachment_data.get(&id), Some(AttachmentState::Ready(_))) {
|
||||
save_attachment_to_disk(state, id);
|
||||
} else if let Some((from, att)) = find_attachment_source(state, id) {
|
||||
if let Ok(eid) = from.parse::<EndpointId>() {
|
||||
state.pending_saves.insert(id);
|
||||
state.status_message = format!("Downloading {}…", att.name);
|
||||
let _ = state
|
||||
.controller
|
||||
.send(CoreCommand::FetchAttachment { from: eid, attachment: att });
|
||||
} else {
|
||||
state.status_message = "Can't download: unknown sender.".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
AppMessage::OpenUrl(url) => {
|
||||
// Defence in depth: only ever hand http(s) URLs to the opener. The
|
||||
// link span's href came from `linkify`, which only emits http/https,
|
||||
// but re-check here so this can't be widened into launching arbitrary
|
||||
// schemes/args. `xdg-open` receives the URL as a single argv entry
|
||||
// (no shell), so there's no injection surface.
|
||||
if (url.starts_with("http://") || url.starts_with("https://"))
|
||||
&& let Err(e) = std::process::Command::new("xdg-open").arg(&url).spawn()
|
||||
{
|
||||
crate::log_msg(&format!("Failed to open URL {url:?}: {e}"));
|
||||
// schemes/args. Each opener receives the URL as a single argv entry
|
||||
// (no shell), so there's no injection surface:
|
||||
// - Unix: `xdg-open <url>`.
|
||||
// - Windows: `rundll32 url.dll,FileProtocolHandler <url>` — opens the
|
||||
// default browser without going through `cmd`/`start`, which would
|
||||
// otherwise re-parse `&` in query strings.
|
||||
if url.starts_with("http://") || url.starts_with("https://") {
|
||||
let spawned = {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::process::Command::new("xdg-open").arg(&url).spawn()
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
std::process::Command::new("rundll32")
|
||||
.args(["url.dll,FileProtocolHandler", &url])
|
||||
.spawn()
|
||||
}
|
||||
};
|
||||
if let Err(e) = spawned {
|
||||
crate::log_msg(&format!("Failed to open URL {url:?}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
AppMessage::ToggleMicTest(enabled) => {
|
||||
@@ -1563,6 +1868,54 @@ fn push_chat(messages: &mut Vec<ChatEntry>, entry: ChatEntry) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the sender id + descriptor for a received attachment by its id, so a
|
||||
/// fetch can be addressed. Returns `None` for our own attachments or an unknown
|
||||
/// id.
|
||||
fn find_attachment_source(
|
||||
state: &AppState,
|
||||
id: crate::files::AttachmentId,
|
||||
) -> Option<(String, crate::files::ChatAttachment)> {
|
||||
state.chat_messages.iter().find_map(|m| {
|
||||
let att = m.attachment.as_ref()?;
|
||||
if att.id == id && !m.mine {
|
||||
Some((m.from.clone()?, att.clone()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Write a ready attachment's bytes to a user-chosen location via a native save
|
||||
/// dialog. The default filename comes from the (already-sanitized) descriptor.
|
||||
/// No-op if the bytes aren't ready. Sync dialog: the brief block is acceptable
|
||||
/// for a deliberate save action.
|
||||
fn save_attachment_to_disk(state: &mut AppState, id: crate::files::AttachmentId) {
|
||||
let Some(AttachmentState::Ready(data)) = state.attachment_data.get(&id) else {
|
||||
return;
|
||||
};
|
||||
let data = data.clone();
|
||||
let default_name = state
|
||||
.chat_messages
|
||||
.iter()
|
||||
.find_map(|m| {
|
||||
m.attachment
|
||||
.as_ref()
|
||||
.filter(|a| a.id == id)
|
||||
.map(|a| a.name.clone())
|
||||
})
|
||||
.unwrap_or_else(|| "download".to_string());
|
||||
if let Some(path) = rfd::FileDialog::new()
|
||||
.set_file_name(default_name)
|
||||
.set_title("Save attachment")
|
||||
.save_file()
|
||||
{
|
||||
match std::fs::write(&path, &data) {
|
||||
Ok(()) => state.status_message = format!("Saved {}", path.display()),
|
||||
Err(e) => state.status_message = format!("Save failed: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn horizontal_space() -> iced::widget::Space {
|
||||
iced::widget::Space::new().width(iced::Length::Fill)
|
||||
}
|
||||
@@ -1984,6 +2337,40 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Wrap the main [`view`] with the custom background layer (W16). When a
|
||||
/// background image is set, render `stack![ image(Cover), scrim, ui ]` so the
|
||||
/// photo sits behind the whole UI with a legibility scrim (the theme base colour
|
||||
/// at `background_dim` alpha) between them; otherwise return the UI untouched. The
|
||||
/// three screen roots go transparent (`root_bg` in `view`) so the image shows
|
||||
/// through the gaps between panels. This is the registered top-level view.
|
||||
fn view_with_background(state: &AppState) -> Element<'_, AppMessage> {
|
||||
let content = view(state);
|
||||
let Some(bytes) = state.background_image.clone() else {
|
||||
return content;
|
||||
};
|
||||
let pal = state.config.theme.palette();
|
||||
let dim = state.config.background_dim;
|
||||
let image_layer = iced::widget::image(cached_image_handle(bytes))
|
||||
.content_fit(iced::ContentFit::Cover)
|
||||
.width(iced::Length::Fill)
|
||||
.height(iced::Length::Fill);
|
||||
let scrim = container(
|
||||
iced::widget::Space::new()
|
||||
.width(iced::Length::Fill)
|
||||
.height(iced::Length::Fill),
|
||||
)
|
||||
.width(iced::Length::Fill)
|
||||
.height(iced::Length::Fill)
|
||||
.style(move |_: &Theme| container::Style {
|
||||
background: Some(Background::Color(crate::background::scrim_color(pal.base, dim))),
|
||||
..Default::default()
|
||||
});
|
||||
iced::widget::stack![image_layer, scrim, content]
|
||||
.width(iced::Length::Fill)
|
||||
.height(iced::Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
// Theme colours — sourced from the active palette (see `src/theme.rs`), so
|
||||
// all styling below re-themes when the user picks a different theme.
|
||||
@@ -2002,6 +2389,16 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
let color_green = pal.green;
|
||||
let color_yellow = pal.yellow;
|
||||
|
||||
// The window backdrop fill for the three screen roots. When a custom
|
||||
// background image is set (W16), the root goes transparent so the image +
|
||||
// scrim layered behind by `view_with_background` shows through the gaps
|
||||
// between panels; otherwise it's the usual opaque `crust`.
|
||||
let root_bg = if state.background_image.is_some() {
|
||||
Color::TRANSPARENT
|
||||
} else {
|
||||
color_crust
|
||||
};
|
||||
|
||||
// Style Helpers
|
||||
let c_style = move |bg: Color, b_color: Color, radius: f32| {
|
||||
move |_theme: &Theme| container::Style {
|
||||
@@ -2263,6 +2660,35 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.spacing(10)
|
||||
.width(iced::Length::Fill);
|
||||
|
||||
let remove_background: Element<'_, AppMessage> = if state.config.background.is_some() {
|
||||
button(text("Remove background").size(13))
|
||||
.on_press(AppMessage::RemoveBackground)
|
||||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||||
.padding(8)
|
||||
.into()
|
||||
} else {
|
||||
iced::widget::Space::new().width(0.0).height(0.0).into()
|
||||
};
|
||||
let background_section = column![
|
||||
row![
|
||||
button(text("Choose image…").size(13))
|
||||
.on_press(AppMessage::PickBackgroundFile)
|
||||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||||
.padding(8),
|
||||
remove_background,
|
||||
].spacing(8),
|
||||
text(format!("Background dimming: {:.0}%", state.config.background_dim * 100.0))
|
||||
.size(11)
|
||||
.color(color_subtext),
|
||||
slider(0.0..=1.0, state.config.background_dim, AppMessage::SetBackgroundDim)
|
||||
.step(0.05),
|
||||
text("Set a picture from your computer as the app background. Auto-resized; a dimming overlay keeps text readable. Applies live.")
|
||||
.size(11)
|
||||
.color(color_subtext),
|
||||
]
|
||||
.spacing(10)
|
||||
.width(iced::Length::Fill);
|
||||
|
||||
// Inline avatar chooser (W4): the monogram fallback plus the bundled
|
||||
// presets, each a clickable tile. Same SelectAvatar message, applied live
|
||||
// + persisted (and re-announced to the room).
|
||||
@@ -2541,10 +2967,28 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
mic_meter,
|
||||
text("Drag the yellow handle to set the gate. While talking, place it just above your quiet-room level so silence is muted but your voice passes through.").size(11).color(color_subtext),
|
||||
vertical_space(4.0),
|
||||
checkbox(state.config.echo_cancellation_enabled)
|
||||
.label("Echo cancellation")
|
||||
.on_toggle(AppMessage::ToggleEchoCancellation),
|
||||
text("Cancels speaker echo + suppresses noise (PipeWire). Takes effect on your next room join.").size(11).color(color_subtext),
|
||||
{
|
||||
let control: Element<'_, AppMessage> = {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
column![
|
||||
checkbox(state.config.echo_cancellation_enabled)
|
||||
.label("Echo cancellation")
|
||||
.on_toggle(AppMessage::ToggleEchoCancellation),
|
||||
text("Cancels speaker echo + suppresses noise (PipeWire). Takes effect on your next room join.").size(11).color(color_subtext),
|
||||
].spacing(8).into()
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
column![
|
||||
checkbox(false)
|
||||
.label("Echo cancellation"),
|
||||
text("Echo cancellation is not available on Windows yet.").size(11).color(color_subtext),
|
||||
].spacing(8).into()
|
||||
}
|
||||
};
|
||||
control
|
||||
},
|
||||
].spacing(8).width(iced::Length::Fill),
|
||||
]
|
||||
.spacing(10)
|
||||
@@ -2593,6 +3037,9 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
vertical_space(section_gap),
|
||||
section_header("Theme"),
|
||||
theme_section,
|
||||
vertical_space(section_gap),
|
||||
section_header("Background"),
|
||||
background_section,
|
||||
]
|
||||
.spacing(10)
|
||||
.width(iced::Length::Fill)
|
||||
@@ -2778,7 +3225,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.height(iced::Length::Fill)
|
||||
.padding(24)
|
||||
.center_x(iced::Length::Fill)
|
||||
.style(c_style(color_crust, Color::TRANSPARENT, 0.0));
|
||||
.style(c_style(root_bg, Color::TRANSPARENT, 0.0));
|
||||
|
||||
return with_regenerate_confirm(settings_screen.into(), state);
|
||||
}
|
||||
@@ -2837,7 +3284,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
)
|
||||
.width(iced::Length::Fill)
|
||||
.height(iced::Length::Fill)
|
||||
.style(c_style(color_crust, Color::TRANSPARENT, 0.0));
|
||||
.style(c_style(root_bg, Color::TRANSPARENT, 0.0));
|
||||
|
||||
with_hotkey_info(with_layout_picker(home.into(), state), state)
|
||||
} else {
|
||||
@@ -3129,11 +3576,18 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
].spacing(8);
|
||||
|
||||
// Peer volume slider
|
||||
let current_vol = state.peer_volumes.get(peer_id).copied().unwrap_or(1.0);
|
||||
let current_vol = state
|
||||
.config
|
||||
.peer_volume
|
||||
.get(&peer_id.to_string())
|
||||
.copied()
|
||||
.unwrap_or(1.0);
|
||||
card_content = card_content.push(
|
||||
row![
|
||||
text("Vol:").size(12).color(color_subtext),
|
||||
slider(0.0..=2.0, current_vol, move |v| AppMessage::PeerVolumeChanged(peer_id_clone, v))
|
||||
.step(0.01)
|
||||
.on_release(AppMessage::PersistConfig)
|
||||
].spacing(8).align_y(iced::alignment::Vertical::Center)
|
||||
);
|
||||
|
||||
@@ -3152,6 +3606,28 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.align_y(iced::alignment::Vertical::Center),
|
||||
);
|
||||
|
||||
// Peer noise gate: suppress this peer's background noise on our end.
|
||||
// Threshold is normalized RMS on the same 0..METER_MAX scale as the
|
||||
// mic gate; 0 = off.
|
||||
let current_gate = state.config.peer_gate.get(&peer_key).copied().unwrap_or(0.0);
|
||||
let gate_label = if current_gate <= 0.0 {
|
||||
"Off".to_string()
|
||||
} else {
|
||||
format!("{:.0}%", (current_gate / METER_MAX * 100.0).clamp(0.0, 100.0))
|
||||
};
|
||||
card_content = card_content.push(
|
||||
row![
|
||||
text("Gate:").size(12).color(color_subtext),
|
||||
container(text(gate_label).size(11).color(color_subtext))
|
||||
.width(iced::Length::Fixed(58.0)),
|
||||
slider(0.0..=METER_MAX, current_gate, move |v| AppMessage::PeerGateChanged(peer_id_clone, v))
|
||||
.step(0.001)
|
||||
.on_release(AppMessage::PersistConfig),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::alignment::Vertical::Center),
|
||||
);
|
||||
|
||||
let eq = peer_eq_settings(&state.config, peer_id);
|
||||
let eq_row = |label: &'static str, band: EqBand, value: f32| -> Element<'_, AppMessage> {
|
||||
row![
|
||||
@@ -3247,26 +3723,40 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
column![]
|
||||
},
|
||||
vertical_space(20.0),
|
||||
// Echo cancellation — same flag + message as the Settings checkbox, so
|
||||
// toggling here and there stay in sync automatically (single source of
|
||||
// truth: config.echo_cancellation_enabled). Tooltip is explicit that it
|
||||
// applies on the NEXT join (the PipeWire-module AEC is wired at join
|
||||
// time, not hot-swappable mid-call).
|
||||
tooltip(
|
||||
checkbox(state.config.echo_cancellation_enabled)
|
||||
.label("Echo cancellation")
|
||||
.on_toggle(AppMessage::ToggleEchoCancellation),
|
||||
container(
|
||||
text("Cancels speaker echo + suppresses noise. Applies on your next room join.")
|
||||
.size(11)
|
||||
.color(color_text),
|
||||
)
|
||||
.padding(8)
|
||||
.max_width(260.0)
|
||||
.style(c_style(color_crust, color_surface, 6.0)),
|
||||
iced::widget::tooltip::Position::Top,
|
||||
)
|
||||
.gap(8),
|
||||
{
|
||||
// Echo cancellation is wired at join time on Linux; other
|
||||
// targets show an inert status row instead of a dead toggle.
|
||||
let control: Element<'_, AppMessage> = {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
tooltip(
|
||||
checkbox(state.config.echo_cancellation_enabled)
|
||||
.label("Echo cancellation")
|
||||
.on_toggle(AppMessage::ToggleEchoCancellation),
|
||||
container(
|
||||
text("Cancels speaker echo + suppresses noise. Applies on your next room join.")
|
||||
.size(11)
|
||||
.color(color_text),
|
||||
)
|
||||
.padding(8)
|
||||
.max_width(260.0)
|
||||
.style(c_style(color_crust, color_surface, 6.0)),
|
||||
iced::widget::tooltip::Position::Top,
|
||||
)
|
||||
.gap(8)
|
||||
.into()
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
column![
|
||||
checkbox(false)
|
||||
.label("Echo cancellation"),
|
||||
text("Not available on Windows yet.").size(11).color(color_subtext),
|
||||
].spacing(4).into()
|
||||
}
|
||||
};
|
||||
control
|
||||
},
|
||||
vertical_space(20.0),
|
||||
{
|
||||
let (rec_kind, rec_label, rec_bg, rec_hover, rec_fg) = if state.recording {
|
||||
@@ -3390,6 +3880,59 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.spacing(8)
|
||||
.align_y(iced::alignment::Vertical::Top),
|
||||
);
|
||||
// Attachment row (indented under the message), if any.
|
||||
if let Some(att) = &m.attachment {
|
||||
let data = state.attachment_data.get(&att.id);
|
||||
let elem: Element<'_, AppMessage> =
|
||||
if let Some(AttachmentState::Failed(e)) = data {
|
||||
text(format!("⚠ {} — {e}", att.name))
|
||||
.size(12)
|
||||
.color(color_red)
|
||||
.into()
|
||||
} else if att.kind == crate::files::AttachmentKind::Image {
|
||||
match state.image_handle_cache.get(&att.id) {
|
||||
Some(handle) => iced::widget::image(handle.clone())
|
||||
.width(iced::Length::Fixed(260.0))
|
||||
.into(),
|
||||
None => text(format!("🖼 {} — loading…", att.name))
|
||||
.size(12)
|
||||
.color(color_subtext)
|
||||
.into(),
|
||||
}
|
||||
} else {
|
||||
let ready =
|
||||
matches!(data, Some(AttachmentState::Ready(_)));
|
||||
let btn_label = if ready { "Save" } else { "Download" };
|
||||
row![
|
||||
text(format!(
|
||||
"📎 {} ({})",
|
||||
att.name,
|
||||
crate::files::human_size(att.size)
|
||||
))
|
||||
.size(12)
|
||||
.color(color_text),
|
||||
button(text(btn_label).size(12))
|
||||
.on_press(AppMessage::SaveAttachment(att.id))
|
||||
.style(b_style(
|
||||
color_blue,
|
||||
color_lavender,
|
||||
color_crust,
|
||||
6.0,
|
||||
))
|
||||
.padding(6),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::alignment::Vertical::Center)
|
||||
.into()
|
||||
};
|
||||
chat_col = chat_col.push(
|
||||
row![
|
||||
iced::widget::Space::new().width(iced::Length::Fixed(30.0)),
|
||||
elem
|
||||
]
|
||||
.spacing(0),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let chat_scroll = scrollable(chat_col)
|
||||
@@ -3397,6 +3940,10 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.height(iced::Length::Fill)
|
||||
.anchor_bottom();
|
||||
let chat_input_row = row![
|
||||
button(text("📎").size(15))
|
||||
.on_press(AppMessage::PickAttachmentFile)
|
||||
.style(b_style(color_surface, color_overlay, color_text, 6.0))
|
||||
.padding(8),
|
||||
text_input("Message the room…", &state.chat_input)
|
||||
.on_input(AppMessage::ChatInputChanged)
|
||||
.on_submit(AppMessage::ChatSubmit)
|
||||
@@ -3524,7 +4071,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.padding(15)
|
||||
.width(iced::Length::Fill)
|
||||
.height(iced::Length::Fill)
|
||||
.style(c_style(color_crust, Color::TRANSPARENT, 0.0));
|
||||
.style(c_style(root_bg, Color::TRANSPARENT, 0.0));
|
||||
|
||||
with_hotkey_info(
|
||||
with_pixelpass_help(with_layout_picker(room.into(), state), state),
|
||||
@@ -4667,8 +5214,48 @@ impl Program<AppMessage> for Icon {
|
||||
mod tests {
|
||||
use super::{
|
||||
format_duration, initial_window_position, reconnect_attempt_chime, reconnected_chime,
|
||||
GateMeter, METER_MAX,
|
||||
set_peer_gate_config, set_peer_volume_config, AppConfig, GateMeter, METER_MAX,
|
||||
};
|
||||
use iroh::SecretKey;
|
||||
|
||||
#[test]
|
||||
fn peer_gate_persists_when_on_and_clears_when_off() {
|
||||
let mut config = AppConfig::default();
|
||||
let id = SecretKey::generate().public();
|
||||
|
||||
// A positive threshold is stored, clamped to the slider's METER_MAX ceiling.
|
||||
let stored = set_peer_gate_config(&mut config, id, 0.05);
|
||||
assert_eq!(stored, 0.05);
|
||||
assert_eq!(config.peer_gate.get(&id.to_string()).copied(), Some(0.05));
|
||||
assert_eq!(set_peer_gate_config(&mut config, id, 99.0), METER_MAX);
|
||||
|
||||
// Zero (or negative) means "gate off" — the entry is removed so the
|
||||
// config doesn't carry a disabled gate.
|
||||
let stored = set_peer_gate_config(&mut config, id, 0.0);
|
||||
assert_eq!(stored, 0.0);
|
||||
assert!(!config.peer_gate.contains_key(&id.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_volume_persists_non_unity_and_clears_at_unity() {
|
||||
let mut config = AppConfig::default();
|
||||
let id = SecretKey::generate().public();
|
||||
|
||||
// A non-unity value is clamped into range and stored.
|
||||
let stored = set_peer_volume_config(&mut config, id, 1.5);
|
||||
assert_eq!(stored, 1.5);
|
||||
assert_eq!(config.peer_volume.get(&id.to_string()).copied(), Some(1.5));
|
||||
|
||||
// Out-of-range values clamp to the slider bounds.
|
||||
assert_eq!(set_peer_volume_config(&mut config, id, 5.0), 2.0);
|
||||
assert_eq!(set_peer_volume_config(&mut config, id, -1.0), 0.0);
|
||||
|
||||
// Returning to unity removes the entry (unity is the implicit default),
|
||||
// so the config doesn't accumulate no-op entries.
|
||||
let stored = set_peer_volume_config(&mut config, id, 1.0);
|
||||
assert_eq!(stored, 1.0);
|
||||
assert!(!config.peer_volume.contains_key(&id.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn x11_restores_saved_window_position() {
|
||||
@@ -4943,6 +5530,7 @@ mod tests {
|
||||
text: "Hello".to_string(),
|
||||
mine: true,
|
||||
from: None,
|
||||
attachment: None,
|
||||
};
|
||||
push_chat(&mut messages, entry);
|
||||
assert_eq!(messages.len(), 1);
|
||||
@@ -4963,6 +5551,7 @@ mod tests {
|
||||
text: format!("Msg{}", i),
|
||||
mine: i % 2 == 0,
|
||||
from: None,
|
||||
attachment: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -4986,6 +5575,7 @@ mod tests {
|
||||
text: format!("Msg{}", i),
|
||||
mine: i % 2 == 0,
|
||||
from: None,
|
||||
attachment: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+49
-1
@@ -56,12 +56,60 @@ pub trait AudioBackend: Send + Sync {
|
||||
fn stop(&self) -> Result<(), AudioError>;
|
||||
}
|
||||
|
||||
pub mod echo_cancel;
|
||||
pub mod eq;
|
||||
pub mod gate;
|
||||
pub mod limiter;
|
||||
pub mod multitrack;
|
||||
pub mod pan;
|
||||
// Linear resamplers used by the Windows/cpal backend (W4). Platform-neutral and
|
||||
// pure, so it builds (and its tests run) everywhere even though only the cpal
|
||||
// backend wires it in.
|
||||
pub mod resample;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod echo_cancel;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod pipewire_impl;
|
||||
#[cfg(windows)]
|
||||
pub mod cpal_impl;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod pw_cli;
|
||||
pub mod recorder;
|
||||
|
||||
/// A selectable audio device for the input/output pickers. `name` is the stable
|
||||
/// identifier the backend uses to request the device (`target_node`);
|
||||
/// `description` is the human-facing label shown in the UI. The two may be equal
|
||||
/// (cpal/WASAPI) or differ (PipeWire node name vs. description).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AudioDevice {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub is_input: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AudioDevice {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.description)
|
||||
}
|
||||
}
|
||||
|
||||
// Enumerate audio input/output devices for the pickers (sorted by description),
|
||||
// returning the same `AudioDevice` shape regardless of platform: PipeWire
|
||||
// (`pw-cli`) on Linux, cpal/WASAPI on Windows.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use pw_cli::enumerate_audio_devices;
|
||||
#[cfg(windows)]
|
||||
pub use cpal_impl::enumerate_audio_devices;
|
||||
|
||||
/// The audio backend implementation for the current platform.
|
||||
///
|
||||
/// The whole app constructs and threads this alias (via
|
||||
/// `PlatformAudioBackend::new()`) rather than any concrete backend type, so
|
||||
/// platform selection lives entirely here. Both implementations satisfy the
|
||||
/// [`AudioBackend`] trait, which is the only interface the core talks to.
|
||||
///
|
||||
/// - Linux → PipeWire ([`pipewire_impl::PipeWireBackend`]).
|
||||
/// - Windows → cpal/WASAPI ([`cpal_impl::CpalBackend`]).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub type PlatformAudioBackend = pipewire_impl::PipeWireBackend;
|
||||
#[cfg(windows)]
|
||||
pub type PlatformAudioBackend = cpal_impl::CpalBackend;
|
||||
|
||||
+1
-13
@@ -1,18 +1,6 @@
|
||||
use super::AudioDevice;
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AudioDevice {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub is_input: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AudioDevice {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.description)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enumerate_audio_devices() -> Vec<AudioDevice> {
|
||||
let output = Command::new("pw-cli")
|
||||
.arg("list-objects")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+229
-99
@@ -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
|
||||
|
||||
+22
-1
@@ -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).
|
||||
|
||||
+314
-44
@@ -1,16 +1,18 @@
|
||||
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;
|
||||
@@ -102,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) {
|
||||
@@ -116,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,
|
||||
) {
|
||||
@@ -129,15 +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
|
||||
@@ -146,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);
|
||||
}
|
||||
@@ -237,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();
|
||||
@@ -309,6 +394,7 @@ pub struct ConnEventHandler {
|
||||
seen_connected: SeenConnected,
|
||||
transport: Arc<IrohTransport>,
|
||||
jitter: Arc<Mutex<HashMap<EndpointId, JitterBuffer>>>,
|
||||
recovery: Option<RecoveryContext>,
|
||||
grace: Duration,
|
||||
}
|
||||
|
||||
@@ -326,6 +412,7 @@ impl ConnEventHandler {
|
||||
seen_connected,
|
||||
transport,
|
||||
jitter,
|
||||
recovery: None,
|
||||
grace: RECONNECT_GRACE,
|
||||
}
|
||||
}
|
||||
@@ -336,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) => {
|
||||
@@ -349,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,
|
||||
);
|
||||
@@ -359,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);
|
||||
@@ -371,6 +475,9 @@ 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);
|
||||
@@ -387,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).
|
||||
@@ -400,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
|
||||
@@ -420,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();
|
||||
@@ -431,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...");
|
||||
@@ -467,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,
|
||||
}
|
||||
@@ -578,6 +692,7 @@ 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
|
||||
@@ -585,6 +700,7 @@ async fn build_net_stack(
|
||||
let router = Router::builder(endpoint.clone())
|
||||
.accept(iroh_gossip::net::GOSSIP_ALPN, gossip.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),
|
||||
@@ -596,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).
|
||||
@@ -728,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));
|
||||
@@ -759,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();
|
||||
@@ -959,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;
|
||||
|
||||
@@ -981,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
|
||||
@@ -1037,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(),
|
||||
@@ -1081,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");
|
||||
@@ -1095,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(),
|
||||
@@ -1122,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;
|
||||
}
|
||||
|
||||
@@ -1139,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;
|
||||
}
|
||||
|
||||
@@ -1281,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();
|
||||
@@ -1297,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
|
||||
@@ -1322,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();
|
||||
@@ -1347,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);
|
||||
|
||||
@@ -1391,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
|
||||
@@ -1466,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.
|
||||
@@ -1478,6 +1684,7 @@ 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).
|
||||
@@ -1521,15 +1728,9 @@ 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);
|
||||
@@ -1543,6 +1744,7 @@ 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
|
||||
@@ -1570,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) => {
|
||||
@@ -1590,9 +1808,12 @@ async fn run_core_loop(
|
||||
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,
|
||||
);
|
||||
@@ -1616,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;
|
||||
@@ -1630,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(),
|
||||
@@ -1671,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;
|
||||
@@ -1757,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 {
|
||||
@@ -1807,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2031,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();
|
||||
}
|
||||
}
|
||||
+295
@@ -0,0 +1,295 @@
|
||||
//! 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
|
||||
}
|
||||
|
||||
/// 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 classify_maps_sniff_to_kind() {
|
||||
assert_eq!(classify(&[0xFF, 0xD8, 0xFF]), AttachmentKind::Image);
|
||||
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);
|
||||
}
|
||||
}
|
||||
+26
-8
@@ -15,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`
|
||||
@@ -84,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);
|
||||
}
|
||||
@@ -98,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)
|
||||
}
|
||||
|
||||
@@ -126,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 {
|
||||
@@ -145,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);
|
||||
|
||||
+126
-15
@@ -5,7 +5,7 @@ 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};
|
||||
@@ -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),
|
||||
@@ -295,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();
|
||||
@@ -393,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);
|
||||
@@ -423,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;
|
||||
}
|
||||
}
|
||||
@@ -472,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;
|
||||
}
|
||||
@@ -516,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() {
|
||||
@@ -533,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
|
||||
@@ -571,6 +642,7 @@ impl RoomState for IrohGossipState {
|
||||
}
|
||||
|
||||
self.peers.lock().unwrap().clear();
|
||||
self.disconnected_peers.lock().unwrap().clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -694,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");
|
||||
}
|
||||
@@ -710,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);
|
||||
@@ -722,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();
|
||||
@@ -734,6 +843,7 @@ mod tests {
|
||||
name: "Bob".to_string(),
|
||||
text: "Hi there".to_string(),
|
||||
ts: 987654321,
|
||||
attachment: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -741,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);
|
||||
@@ -756,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);
|
||||
@@ -799,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)
|
||||
@@ -872,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));
|
||||
|
||||
+148
-1
@@ -9,7 +9,8 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::time::Duration;
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::protocol::AUDIO_ALPN;
|
||||
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 {
|
||||
@@ -60,6 +65,11 @@ struct Shared {
|
||||
/// 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>,
|
||||
@@ -402,6 +412,88 @@ 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();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct IrohTransport {
|
||||
shared: Arc<Shared>,
|
||||
incoming_rx: tokio::sync::Mutex<Option<mpsc::Receiver<(EndpointId, Bytes)>>>,
|
||||
@@ -426,6 +518,7 @@ impl IrohTransport {
|
||||
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,
|
||||
});
|
||||
@@ -455,6 +548,7 @@ 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).
|
||||
@@ -483,6 +577,59 @@ impl IrohTransport {
|
||||
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]
|
||||
|
||||
+27
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+41
-5
@@ -3,11 +3,12 @@
|
||||
//!
|
||||
//! The WAVs are embedded in the binary (`include_bytes!`) so a deployed single
|
||||
//! binary is self-contained — no asset directory to ship alongside it. On first
|
||||
//! use each sound is written once to a temp file, then played fire-and-forget
|
||||
//! via `pw-play` (PipeWire-native; falls back to `paplay`/`aplay`). Playback runs
|
||||
//! on a detached thread that waits on the child, so it never blocks the UI and
|
||||
//! never leaves a zombie. Any failure (no player, no audio) is silent by design —
|
||||
//! a missing chime should never disrupt a call.
|
||||
//! use each sound is written once to a temp file, then played fire-and-forget.
|
||||
//! Linux uses `pw-play` (PipeWire-native; falls back to `paplay`/`aplay`);
|
||||
//! Windows uses PowerShell's `System.Media.SoundPlayer`. Playback runs on a
|
||||
//! detached thread that waits on the child, so it never blocks the UI and never
|
||||
//! leaves a zombie. Any failure (no player, no audio) is silent by design — a
|
||||
//! missing chime should never disrupt a call.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -202,8 +203,14 @@ fn cached_path(sound: Sound) -> Option<PathBuf> {
|
||||
Some(path)
|
||||
}
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
fn escape_powershell_single_quoted(s: &str) -> String {
|
||||
s.replace('\'', "''")
|
||||
}
|
||||
|
||||
/// Try each available player in turn, waiting on the first that starts (which
|
||||
/// reaps the child). Runs on a detached thread, so the wait is harmless.
|
||||
#[cfg(not(windows))]
|
||||
fn spawn_player(path: &Path) {
|
||||
for player in ["pw-play", "paplay", "aplay"] {
|
||||
let started = Command::new(player)
|
||||
@@ -221,6 +228,23 @@ fn spawn_player(path: &Path) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Play through Windows' built-in WAV player. Runs on a detached thread, so
|
||||
/// `PlaySync()` blocking for the sound duration is fine.
|
||||
#[cfg(windows)]
|
||||
fn spawn_player(path: &Path) {
|
||||
let path = escape_powershell_single_quoted(&path.display().to_string());
|
||||
let command = format!("(New-Object System.Media.SoundPlayer '{path}').PlaySync()");
|
||||
let _ = Command::new("powershell")
|
||||
.arg("-NoProfile")
|
||||
.arg("-NonInteractive")
|
||||
.arg("-Command")
|
||||
.arg(command)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -234,6 +258,18 @@ mod tests {
|
||||
assert!(!should_play(false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_powershell_single_quote_escape() {
|
||||
assert_eq!(
|
||||
escape_powershell_single_quoted(r"C:\Users\O'Brien\chime.wav"),
|
||||
r"C:\Users\O''Brien\chime.wav"
|
||||
);
|
||||
assert_eq!(
|
||||
escape_powershell_single_quoted("a'b'c"),
|
||||
"a''b''c"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sound_indices_unique_and_match_all() {
|
||||
// `index()` must be a 0..COUNT bijection in `ALL` order, or the flag
|
||||
|
||||
+13
-2
@@ -22,16 +22,26 @@ pub const FRIENDS_PROTO: u32 = 1;
|
||||
/// Gossip plane version (`GossipPayload`/`GossipMessage`/`PeerState`, signing,
|
||||
/// freshness). Bump on any change. Mirrored in [`GOSSIP_SIG_DOMAIN`] and folded
|
||||
/// into [`versioned_topic`].
|
||||
pub const GOSSIP_PROTO: u32 = 1;
|
||||
///
|
||||
/// 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-v1";
|
||||
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
|
||||
@@ -62,6 +72,7 @@ mod tests {
|
||||
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}"));
|
||||
}
|
||||
|
||||
|
||||
+21
-1
@@ -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")]);
|
||||
}
|
||||
}
|
||||
|
||||
+64
-4
@@ -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,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,
|
||||
|
||||
Executable
+17
@@ -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 "$@"
|
||||
Reference in New Issue
Block a user