Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6a88d15c0 | ||
|
|
a17b930524 | ||
|
|
6100abef33 | ||
|
|
49bd2ba687 | ||
|
|
6b0b23ef69 | ||
|
|
f422150c84 | ||
|
|
86d333d4dc | ||
|
|
fad65a4fcf | ||
|
|
3878e716dd | ||
|
|
961705ffa9 | ||
|
|
7d44808a5e | ||
|
|
e31d3db986 | ||
|
|
87a2209a85 | ||
|
|
9e8c8b4ace | ||
|
|
7e4f2f2127 | ||
|
|
b6eac330ca | ||
|
|
7fb1c96ca9 | ||
|
|
80a5b73e39 | ||
|
|
adf7d1c0e2 | ||
|
|
bcb597a0ea | ||
|
|
79b24fd567 | ||
|
|
efadc228eb | ||
|
|
2d067a2e41 | ||
|
|
8ea40f719c | ||
|
|
60c1951567 | ||
|
|
f75760b14e | ||
|
|
02cb46550e | ||
|
|
cbba4b644e | ||
|
|
c5375e200a |
@@ -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.
|
||||
@@ -117,6 +117,18 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alsa"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c88dbbce13b232b26250e1e2e6ac18b6a891a646b8148285036ebce260ac5c3"
|
||||
dependencies = [
|
||||
"alsa-sys",
|
||||
"bitflags 2.11.1",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alsa-sys"
|
||||
version = "0.3.1"
|
||||
@@ -1041,6 +1053,20 @@ dependencies = [
|
||||
"coreaudio-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coreaudio-rs"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"libc",
|
||||
"objc2-audio-toolbox",
|
||||
"objc2-core-audio",
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coreaudio-sys"
|
||||
version = "0.2.18"
|
||||
@@ -1080,14 +1106,14 @@ version = "0.15.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
|
||||
dependencies = [
|
||||
"alsa",
|
||||
"alsa 0.9.1",
|
||||
"core-foundation-sys",
|
||||
"coreaudio-rs",
|
||||
"coreaudio-rs 0.11.3",
|
||||
"dasp_sample",
|
||||
"jni 0.21.1",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"mach2",
|
||||
"mach2 0.4.3",
|
||||
"ndk 0.8.0",
|
||||
"ndk-context",
|
||||
"oboe",
|
||||
@@ -1097,6 +1123,36 @@ dependencies = [
|
||||
"windows 0.54.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpal"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b1f9c7312f19fc2fa12fd7acaf38de54e8320ba10d1a02dcbe21038def51ccb"
|
||||
dependencies = [
|
||||
"alsa 0.10.0",
|
||||
"coreaudio-rs 0.13.0",
|
||||
"dasp_sample",
|
||||
"jni 0.21.1",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"mach2 0.5.0",
|
||||
"ndk 0.9.0",
|
||||
"ndk-context",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
"objc2 0.6.4",
|
||||
"objc2-audio-toolbox",
|
||||
"objc2-avf-audio",
|
||||
"objc2-core-audio",
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -1564,6 +1620,15 @@ version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
@@ -1699,6 +1764,12 @@ dependencies = [
|
||||
"zune-inflate",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "extended"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
@@ -2557,6 +2628,7 @@ dependencies = [
|
||||
"iced_core",
|
||||
"log",
|
||||
"rustc-hash 2.1.2",
|
||||
"tokio",
|
||||
"wasm-bindgen-futures",
|
||||
"wasmtimer",
|
||||
]
|
||||
@@ -3565,6 +3637,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mach2"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "malloc_buf"
|
||||
version = "0.0.6"
|
||||
@@ -4252,6 +4333,31 @@ dependencies = [
|
||||
"objc2-quartz-core 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-audio-toolbox"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"libc",
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-audio",
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-avf-audio"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be"
|
||||
dependencies = [
|
||||
"objc2 0.6.4",
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-cloud-kit"
|
||||
version = "0.2.2"
|
||||
@@ -4287,6 +4393,29 @@ dependencies = [
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-audio"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2"
|
||||
dependencies = [
|
||||
"dispatch2",
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-audio-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"objc2 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-data"
|
||||
version = "0.2.2"
|
||||
@@ -4742,13 +4871,13 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "peerspeak"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"base64",
|
||||
"bytes",
|
||||
"cpal",
|
||||
"cpal 0.15.3",
|
||||
"dirs",
|
||||
"iced",
|
||||
"image",
|
||||
@@ -4759,11 +4888,13 @@ dependencies = [
|
||||
"rand 0.10.1",
|
||||
"rfd",
|
||||
"ringbuf",
|
||||
"rodio",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5198,6 +5329,16 @@ version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_distr"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"rand 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
@@ -5487,12 +5628,34 @@ dependencies = [
|
||||
"portable-atomic-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rodio"
|
||||
version = "0.22.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a536bb79db59098ef71a4dd4246c02eb87b316deceb1b68e0cde7167ec01eb"
|
||||
dependencies = [
|
||||
"cpal 0.17.1",
|
||||
"dasp_sample",
|
||||
"num-rational",
|
||||
"rand 0.10.1",
|
||||
"rand_distr",
|
||||
"rtrb",
|
||||
"symphonia",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
||||
|
||||
[[package]]
|
||||
name = "rtrb"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ade083ccbb4bf536df69d1f6432cc23deb7acccff86b183f3923a6fd56a1153"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
@@ -6156,6 +6319,153 @@ dependencies = [
|
||||
"zeno",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"symphonia-bundle-flac",
|
||||
"symphonia-bundle-mp3",
|
||||
"symphonia-codec-aac",
|
||||
"symphonia-codec-pcm",
|
||||
"symphonia-codec-vorbis",
|
||||
"symphonia-core",
|
||||
"symphonia-format-isomp4",
|
||||
"symphonia-format-ogg",
|
||||
"symphonia-format-riff",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-bundle-flac"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
"symphonia-utils-xiph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-bundle-mp3"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-codec-aac"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-codec-pcm"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-codec-vorbis"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-utils-xiph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-core"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bitflags 1.3.2",
|
||||
"bytemuck",
|
||||
"lazy_static",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-format-isomp4"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
"symphonia-utils-xiph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-format-ogg"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
"symphonia-utils-xiph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-format-riff"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f"
|
||||
dependencies = [
|
||||
"extended",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-metadata"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-utils-xiph"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16"
|
||||
dependencies = [
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "peerspeak"
|
||||
version = "0.2.0"
|
||||
version = "0.4.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.
|
||||
@@ -28,7 +28,7 @@ async-trait = "0.1.89"
|
||||
base64 = "0.22.1"
|
||||
bytes = "1.11.1"
|
||||
dirs = "6.0.0"
|
||||
iced = { version = "0.14.0", features = ["canvas", "image"] }
|
||||
iced = { version = "0.14.0", features = ["canvas", "image", "tokio"] }
|
||||
# W4 custom avatars: decode/resize an arbitrary user image (png/jpeg only to keep
|
||||
# the codec surface small). The matching native file picker (`rfd`) is platform-
|
||||
# gated below — its backend differs per OS (xdg-portal on Linux, Win32 on Windows).
|
||||
@@ -38,6 +38,7 @@ iroh-gossip = "0.99.0"
|
||||
opus = "0.3.1"
|
||||
rand = "0.10.1"
|
||||
ringbuf = "0.5.0"
|
||||
rodio = "0.22.2"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.150"
|
||||
thiserror = "2.0.18"
|
||||
@@ -64,3 +65,12 @@ 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"
|
||||
# Win32 FFI for game detection (no new crate: windows-sys is already pulled in
|
||||
# transitively by cpal/rfd). Registry reads the Steam RunningAppID + install path;
|
||||
# Toolhelp enumerates running processes for the non-Steam process-scan fallback.
|
||||
windows-sys = { version = "0.61", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Registry",
|
||||
"Win32_System_Diagnostics_ToolHelp",
|
||||
"Win32_System_Threading",
|
||||
] }
|
||||
|
||||
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 606 B After Width: | Height: | Size: 843 B |
|
Before Width: | Height: | Size: 994 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 5.7 KiB |
@@ -1,41 +1,66 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- PeerSpeak app icon: in-app mic glyph + P2P mesh nodes, Catppuccin Mocha. -->
|
||||
<title>PeerSpeak</title>
|
||||
<desc>Two luminous voices meet directly to form a flowing S.</desc>
|
||||
<defs>
|
||||
<linearGradient id="tile" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#1e1e2e"/>
|
||||
<stop offset="1" stop-color="#181825"/>
|
||||
<linearGradient id="tile" x1="32" y1="20" x2="225" y2="239" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#101d42"/>
|
||||
<stop offset="0.5" stop-color="#071225"/>
|
||||
<stop offset="1" stop-color="#160b31"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="voice" x1="45" y1="76" x2="214" y2="184" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#35efff"/>
|
||||
<stop offset="0.42" stop-color="#2583ff"/>
|
||||
<stop offset="0.68" stop-color="#8a42ff"/>
|
||||
<stop offset="1" stop-color="#ff3cdd"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="edge" x1="30" y1="31" x2="225" y2="231" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2fe9ff" stop-opacity="0.7"/>
|
||||
<stop offset="0.48" stop-color="#386dff" stop-opacity="0.18"/>
|
||||
<stop offset="1" stop-color="#eb42ff" stop-opacity="0.65"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="core">
|
||||
<stop stop-color="#ffffff"/>
|
||||
<stop offset="0.28" stop-color="#baf7ff"/>
|
||||
<stop offset="0.62" stop-color="#7b67ff" stop-opacity="0.65"/>
|
||||
<stop offset="1" stop-color="#7b67ff" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="shadow" x="-35%" y="-35%" width="170%" height="170%">
|
||||
<feGaussianBlur stdDeviation="6"/>
|
||||
</filter>
|
||||
<filter id="soft-shadow" x="-20%" y="-20%" width="140%" height="150%">
|
||||
<feDropShadow dx="0" dy="7" stdDeviation="7" flood-color="#000611" flood-opacity="0.8"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Rounded-square tile -->
|
||||
<rect x="20" y="20" width="216" height="216" rx="48" fill="url(#tile)"
|
||||
stroke="#313244" stroke-width="3"/>
|
||||
<!-- A dark stage makes the cyan/violet conversation mark legible at taskbar size. -->
|
||||
<rect x="8" y="8" width="240" height="240" rx="55" fill="url(#tile)"/>
|
||||
<rect x="9.5" y="9.5" width="237" height="237" rx="53.5" fill="none" stroke="url(#edge)" stroke-width="3"/>
|
||||
|
||||
<!-- P2P mesh: edges (under nodes + mic) -->
|
||||
<g stroke="#45475a" stroke-width="6" stroke-linecap="round" fill="none">
|
||||
<line x1="74" y1="74" x2="128" y2="128"/>
|
||||
<line x1="182" y1="74" x2="128" y2="128"/>
|
||||
<line x1="74" y1="182" x2="128" y2="128"/>
|
||||
<line x1="182" y1="182" x2="128" y2="128"/>
|
||||
<line x1="74" y1="74" x2="182" y2="74"/>
|
||||
<line x1="74" y1="182" x2="182" y2="182"/>
|
||||
</g>
|
||||
<!-- Broad color glow, kept behind the silhouette. -->
|
||||
<path d="M36 128 C50 128 48 101 60 101 C72 101 68 153 81 153 C94 153 90 112 104 112 C115 112 116 128 128 128 C140 128 141 144 152 144 C166 144 162 103 175 103 C188 103 184 155 196 155 C208 155 206 128 220 128"
|
||||
fill="none" stroke="url(#voice)" stroke-width="25" stroke-linecap="round"
|
||||
opacity="0.5" filter="url(#shadow)"/>
|
||||
|
||||
<!-- P2P mesh: peer nodes -->
|
||||
<g fill="#b4befe">
|
||||
<circle cx="74" cy="74" r="11"/>
|
||||
<circle cx="182" cy="74" r="11"/>
|
||||
<circle cx="74" cy="182" r="11"/>
|
||||
<circle cx="182" cy="182" r="11"/>
|
||||
</g>
|
||||
<!-- The two waveform halves are equal peers and meet at one bright point. -->
|
||||
<path d="M36 128 C50 128 48 101 60 101 C72 101 68 153 81 153 C94 153 90 112 104 112 C115 112 116 128 128 128"
|
||||
fill="none" stroke="url(#voice)" stroke-width="17" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M128 128 C140 128 141 144 152 144 C166 144 162 103 175 103 C188 103 184 155 196 155 C208 155 206 128 220 128"
|
||||
fill="none" stroke="url(#voice)" stroke-width="17" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
|
||||
<!-- Microphone (hero) — same geometry as the in-app Mic icon, scaled 6.4x -->
|
||||
<g fill="none" stroke="#89b4fa" stroke-width="13"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="108.8" y="68.8" width="38.4" height="70.4" rx="19.2"/>
|
||||
<path d="M 169.6 123.2 A 41.6 41.6 0 0 0 86.4 123.2"/>
|
||||
<line x1="128" y1="164.8" x2="128" y2="187.2"/>
|
||||
<line x1="105.6" y1="187.2" x2="150.4" y2="187.2"/>
|
||||
</g>
|
||||
<!-- A single flowing connection turns the conversation into PeerSpeak's S-mark. -->
|
||||
<path d="M160 66 C142 52 108 57 101 78 C94 98 113 111 128 119 C148 130 163 141 157 164 C151 188 117 199 94 184"
|
||||
fill="none" stroke="#050b1b" stroke-opacity="0.72" stroke-width="33"
|
||||
stroke-linecap="round" stroke-linejoin="round" filter="url(#soft-shadow)"/>
|
||||
<path d="M160 66 C142 52 108 57 101 78 C94 98 113 111 128 119 C148 130 163 141 157 164 C151 188 117 199 94 184"
|
||||
fill="none" stroke="url(#voice)" stroke-width="25"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M157 65 C139 56 113 62 108 79" fill="none" stroke="#bdf9ff"
|
||||
stroke-opacity="0.68" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M153 166 C146 184 118 192 98 181" fill="none" stroke="#f4a8ff"
|
||||
stroke-opacity="0.52" stroke-width="4" stroke-linecap="round"/>
|
||||
|
||||
<!-- The direct connection is the brightest and simplest detail. -->
|
||||
<circle cx="128" cy="128" r="30" fill="url(#core)" opacity="0.78" filter="url(#shadow)"/>
|
||||
<circle cx="128" cy="128" r="6.5" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 3.9 KiB |
@@ -1,7 +1,7 @@
|
||||
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
|
||||
pkgname=peerspeak-git
|
||||
_pkgname=peerspeak
|
||||
pkgver=0.1.0
|
||||
pkgver=0.3.0.r229.g7fb1c96
|
||||
pkgrel=1
|
||||
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -8,7 +8,7 @@ it once, then you and I connect directly to each other.
|
||||
|
||||
## 1. Install it
|
||||
|
||||
1. Double-click **`peerspeak-0.2.0-setup.exe`** (the file I sent you).
|
||||
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
|
||||
@@ -69,13 +69,39 @@ Either way works the same; it just depends on who makes the 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.
|
||||
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.
|
||||
|
||||
|
||||
@@ -9,6 +9,18 @@ notification chimes, and avatar presets are all embedded in the binary
|
||||
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 |
|
||||
|
||||
|
Before Width: | Height: | Size: 364 KiB After Width: | Height: | Size: 364 KiB |
@@ -12,7 +12,7 @@
|
||||
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
|
||||
|
||||
#define MyAppName "PeerSpeak"
|
||||
#define MyAppVersion "0.2.0"
|
||||
#define MyAppVersion "0.3.0"
|
||||
#define MyAppPublisher "mollusk"
|
||||
#define MyAppExeName "peerspeak.exe"
|
||||
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
//! Independent playback engine for inline chat audio attachments.
|
||||
//!
|
||||
//! The rodio device sink stays on a dedicated OS thread and never enters iced
|
||||
//! state or the call-audio pipeline. The GUI sends small commands and reads a
|
||||
//! shared status snapshot at its redraw cadence.
|
||||
|
||||
use crate::files::AttachmentId;
|
||||
use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source, decoder::DecoderError};
|
||||
use std::io::Cursor;
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
use std::time::Duration;
|
||||
|
||||
/// State published by the playback thread for the GUI.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ClipStatus {
|
||||
pub playing_id: Option<AttachmentId>,
|
||||
pub position: Duration,
|
||||
pub total: Option<Duration>,
|
||||
pub paused: bool,
|
||||
/// Set when output initialization or decoding rejects the requested clip.
|
||||
/// The app consumes this as a signal to fall back to the normal file chip.
|
||||
pub failure: Option<ClipFailure>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClipFailure {
|
||||
pub id: AttachmentId,
|
||||
pub error: String,
|
||||
/// Decoder rejection means the filename hint should fall back to a file
|
||||
/// chip. Output-device failures remain retryable as audio.
|
||||
pub invalid_audio: bool,
|
||||
}
|
||||
|
||||
pub type SharedClipStatus = Arc<Mutex<ClipStatus>>;
|
||||
|
||||
#[derive(Debug)]
|
||||
enum ClipCommand {
|
||||
Play(AttachmentId, Vec<u8>),
|
||||
Pause,
|
||||
Resume,
|
||||
Seek(Duration),
|
||||
Stop,
|
||||
}
|
||||
|
||||
/// Cheap, `Send` command handle for the dedicated playback thread.
|
||||
pub struct ClipPlayer {
|
||||
command_tx: mpsc::Sender<ClipCommand>,
|
||||
status: SharedClipStatus,
|
||||
}
|
||||
|
||||
impl ClipPlayer {
|
||||
/// Start the playback worker. The system output device is opened lazily on
|
||||
/// first Play, so merely launching PeerSpeak never claims another stream.
|
||||
pub fn new() -> (Self, SharedClipStatus) {
|
||||
let (command_tx, command_rx) = mpsc::channel();
|
||||
let status = Arc::new(Mutex::new(ClipStatus::default()));
|
||||
let worker_status = Arc::clone(&status);
|
||||
std::thread::Builder::new()
|
||||
.name("peerspeak-clip-player".to_string())
|
||||
.spawn(move || playback_worker(command_rx, worker_status))
|
||||
.expect("failed to spawn clip playback thread");
|
||||
(
|
||||
Self {
|
||||
command_tx,
|
||||
status: Arc::clone(&status),
|
||||
},
|
||||
status,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn play(&self, id: AttachmentId, bytes: Vec<u8>) {
|
||||
update_status(&self.status, |status| {
|
||||
status.playing_id = Some(id);
|
||||
status.position = Duration::ZERO;
|
||||
status.total = None;
|
||||
status.paused = false;
|
||||
status.failure = None;
|
||||
});
|
||||
let _ = self.command_tx.send(ClipCommand::Play(id, bytes));
|
||||
}
|
||||
|
||||
pub fn pause(&self) {
|
||||
let _ = self.command_tx.send(ClipCommand::Pause);
|
||||
}
|
||||
|
||||
pub fn resume(&self) {
|
||||
let _ = self.command_tx.send(ClipCommand::Resume);
|
||||
}
|
||||
|
||||
pub fn seek(&self, position: Duration) {
|
||||
let _ = self.command_tx.send(ClipCommand::Seek(position));
|
||||
}
|
||||
|
||||
pub fn stop(&self) {
|
||||
let _ = self.command_tx.send(ClipCommand::Stop);
|
||||
}
|
||||
}
|
||||
|
||||
fn playback_worker(command_rx: mpsc::Receiver<ClipCommand>, status: SharedClipStatus) {
|
||||
let mut output: Option<MixerDeviceSink> = None;
|
||||
let mut player: Option<Player> = None;
|
||||
|
||||
loop {
|
||||
match command_rx.recv_timeout(Duration::from_millis(100)) {
|
||||
Ok(ClipCommand::Play(id, bytes)) => {
|
||||
// In-memory readers do not expose file metadata to rodio. Pass
|
||||
// the known attachment length explicitly so formats without a
|
||||
// duration in their headers (notably MP3 and Vorbis) can derive
|
||||
// a total duration and support reliable seeking.
|
||||
let source = match decode_clip(bytes) {
|
||||
Ok(source) => source,
|
||||
Err(error) => {
|
||||
fail(
|
||||
&status,
|
||||
id,
|
||||
format!("unsupported or invalid audio: {error}"),
|
||||
true,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let total = source.total_duration();
|
||||
|
||||
if output.is_none() {
|
||||
match DeviceSinkBuilder::open_default_sink() {
|
||||
Ok(sink) => {
|
||||
player = Some(Player::connect_new(sink.mixer()));
|
||||
output = Some(sink);
|
||||
}
|
||||
Err(error) => {
|
||||
fail(
|
||||
&status,
|
||||
id,
|
||||
format!("audio output unavailable: {error}"),
|
||||
false,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(player) = player.as_ref() {
|
||||
player.clear();
|
||||
player.append(source);
|
||||
player.play();
|
||||
update_status(&status, |s| {
|
||||
s.playing_id = Some(id);
|
||||
s.position = Duration::ZERO;
|
||||
s.total = total;
|
||||
s.paused = false;
|
||||
s.failure = None;
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(ClipCommand::Pause) => {
|
||||
if let Some(player) = player.as_ref() {
|
||||
player.pause();
|
||||
update_status(&status, |s| s.paused = true);
|
||||
}
|
||||
}
|
||||
Ok(ClipCommand::Resume) => {
|
||||
if let Some(player) = player.as_ref() {
|
||||
player.play();
|
||||
update_status(&status, |s| s.paused = false);
|
||||
}
|
||||
}
|
||||
Ok(ClipCommand::Seek(position)) => {
|
||||
if let Some(player) = player.as_ref()
|
||||
&& player.try_seek(position).is_ok()
|
||||
{
|
||||
update_status(&status, |s| s.position = position);
|
||||
}
|
||||
}
|
||||
Ok(ClipCommand::Stop) => {
|
||||
if let Some(player) = player.as_ref() {
|
||||
player.clear();
|
||||
}
|
||||
reset(&status);
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
}
|
||||
|
||||
if let Some(player) = player.as_ref() {
|
||||
let (active, failed) = status
|
||||
.lock()
|
||||
.map(|s| (s.playing_id.is_some(), s.failure.is_some()))
|
||||
.unwrap_or_default();
|
||||
if active && !failed && player.empty() {
|
||||
reset(&status);
|
||||
} else if active && !failed {
|
||||
update_status(&status, |s| {
|
||||
s.position = player.get_pos();
|
||||
s.paused = player.is_paused();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_clip(bytes: Vec<u8>) -> Result<Decoder<Cursor<Vec<u8>>>, DecoderError> {
|
||||
let byte_len = bytes.len() as u64;
|
||||
Decoder::builder()
|
||||
.with_data(Cursor::new(bytes))
|
||||
.with_byte_len(byte_len)
|
||||
.build()
|
||||
}
|
||||
|
||||
fn fail(status: &SharedClipStatus, id: AttachmentId, error: String, invalid_audio: bool) {
|
||||
crate::log_msg(&format!("Inline audio playback failed: {error}"));
|
||||
update_status(status, |s| {
|
||||
// Keep the id active until the GUI observes the failure on its next
|
||||
// tick. This guarantees the active-only timer cannot disappear in the
|
||||
// small window between sending Play and decoder/output failure.
|
||||
s.playing_id = Some(id);
|
||||
s.position = Duration::ZERO;
|
||||
s.total = None;
|
||||
s.paused = false;
|
||||
s.failure = Some(ClipFailure {
|
||||
id,
|
||||
error,
|
||||
invalid_audio,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn reset(status: &SharedClipStatus) {
|
||||
update_status(status, |s| *s = ClipStatus::default());
|
||||
}
|
||||
|
||||
fn update_status(status: &SharedClipStatus, update: impl FnOnce(&mut ClipStatus)) {
|
||||
if let Ok(mut status) = status.lock() {
|
||||
update(&mut status);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status_snapshot(status: &SharedClipStatus) -> ClipStatus {
|
||||
status.lock().map(|s| s.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Format clip time as `mm:ss` (hours are folded into minutes).
|
||||
pub fn format_time(duration: Duration) -> String {
|
||||
let seconds = duration.as_secs();
|
||||
format!("{}:{:02}", seconds / 60, seconds % 60)
|
||||
}
|
||||
|
||||
/// Playback progress in `0.0..=1.0`; unknown and zero durations report zero.
|
||||
pub fn progress(position: Duration, total: Option<Duration>) -> f32 {
|
||||
let Some(total) = total.filter(|duration| !duration.is_zero()) else {
|
||||
return 0.0;
|
||||
};
|
||||
(position.as_secs_f64() / total.as_secs_f64()).clamp(0.0, 1.0) as f32
|
||||
}
|
||||
|
||||
/// Convert a slider fraction into a clamped position within a clip.
|
||||
pub fn seek_target(fraction: f32, total: Duration) -> Duration {
|
||||
total.mul_f64(f64::from(fraction.clamp(0.0, 1.0)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use base64::Engine;
|
||||
|
||||
#[test]
|
||||
fn in_memory_mp3_reports_duration() {
|
||||
// One headerless constant-bitrate MP3 frame repeated to model files
|
||||
// that do not carry an Xing/VBR duration header.
|
||||
let frame = base64::engine::general_purpose::STANDARD
|
||||
.decode("//sQxAAABIQVWVRggDCqCKiDNlAAAAGgS4BgAmTT2AQAABCxOD5d7gQOfqBAEHS4Ph/EAIRI7//0A0KBNpABgMRIDCSI04PcIFdF0PJKFgzlUf5eAoF8BRIPfh4FTvUDQl+dUi5pc0w=")
|
||||
.expect("valid test fixture");
|
||||
let bytes = frame.repeat(20);
|
||||
|
||||
let decoder = decode_clip(bytes).expect("CBR MP3 should decode");
|
||||
assert!(decoder.total_duration().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_clip_time() {
|
||||
assert_eq!(format_time(Duration::ZERO), "0:00");
|
||||
assert_eq!(format_time(Duration::from_secs(65)), "1:05");
|
||||
assert_eq!(format_time(Duration::from_secs(3_661)), "61:01");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_handles_unknown_zero_and_clamps() {
|
||||
assert_eq!(progress(Duration::from_secs(1), None), 0.0);
|
||||
assert_eq!(progress(Duration::from_secs(1), Some(Duration::ZERO)), 0.0);
|
||||
assert_eq!(
|
||||
progress(Duration::from_secs(5), Some(Duration::from_secs(10))),
|
||||
0.5
|
||||
);
|
||||
assert_eq!(
|
||||
progress(Duration::from_secs(20), Some(Duration::from_secs(10))),
|
||||
1.0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seek_target_clamps_fraction() {
|
||||
let total = Duration::from_secs(100);
|
||||
assert_eq!(seek_target(0.25, total), Duration::from_secs(25));
|
||||
assert_eq!(seek_target(-1.0, total), Duration::ZERO);
|
||||
assert_eq!(seek_target(2.0, total), total);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,18 @@ const NODE_READY_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
/// nodes never leak past the call that created them.
|
||||
pub struct EchoCancelGuard {
|
||||
module_index: String,
|
||||
source_name: String,
|
||||
sink_name: String,
|
||||
}
|
||||
|
||||
impl EchoCancelGuard {
|
||||
pub fn source_name(&self) -> &str {
|
||||
&self.source_name
|
||||
}
|
||||
|
||||
pub fn sink_name(&self) -> &str {
|
||||
&self.sink_name
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EchoCancelGuard {
|
||||
@@ -58,12 +70,16 @@ pub fn enable(real_source: Option<&str>, real_sink: Option<&str>) -> Result<Echo
|
||||
// don't stack duplicate modules / fight over the virtual node names.
|
||||
unload_stale();
|
||||
|
||||
let owner_pid = std::process::id();
|
||||
let source_name = format!("{EC_SOURCE}.{owner_pid}");
|
||||
let sink_name = format!("{EC_SINK}.{owner_pid}");
|
||||
|
||||
let mut cmd = Command::new("pactl");
|
||||
cmd.arg("load-module")
|
||||
.arg("module-echo-cancel")
|
||||
.arg("aec_method=webrtc")
|
||||
.arg(format!("source_name={EC_SOURCE}"))
|
||||
.arg(format!("sink_name={EC_SINK}"));
|
||||
.arg(format!("source_name={source_name}"))
|
||||
.arg(format!("sink_name={sink_name}"));
|
||||
if let Some(src) = real_source.filter(|s| !s.is_empty()) {
|
||||
cmd.arg(format!("source_master={src}"));
|
||||
}
|
||||
@@ -85,12 +101,12 @@ pub fn enable(real_source: Option<&str>, real_sink: Option<&str>) -> Result<Echo
|
||||
if module_index.parse::<u64>().is_err() {
|
||||
return Err(format!("unexpected pactl output: {module_index:?}"));
|
||||
}
|
||||
let guard = EchoCancelGuard { module_index };
|
||||
let guard = EchoCancelGuard { module_index, source_name, sink_name };
|
||||
|
||||
// The virtual nodes appear shortly after the module loads; wait for both so
|
||||
// the subsequent capture/playback streams can actually target them. If they
|
||||
// never show, drop the guard (unloads) and report failure.
|
||||
if !wait_for_nodes() {
|
||||
if !wait_for_nodes(guard.source_name(), guard.sink_name()) {
|
||||
return Err("echo-cancel virtual nodes did not appear in time".to_string());
|
||||
}
|
||||
|
||||
@@ -102,10 +118,10 @@ pub fn enable(real_source: Option<&str>, real_sink: Option<&str>) -> Result<Echo
|
||||
}
|
||||
|
||||
/// Polls until both virtual nodes exist or the timeout elapses.
|
||||
fn wait_for_nodes() -> bool {
|
||||
fn wait_for_nodes(source_name: &str, sink_name: &str) -> bool {
|
||||
let deadline = Instant::now() + NODE_READY_TIMEOUT;
|
||||
loop {
|
||||
if node_present("sources", EC_SOURCE) && node_present("sinks", EC_SINK) {
|
||||
if node_present("sources", source_name) && node_present("sinks", sink_name) {
|
||||
return true;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
@@ -126,8 +142,30 @@ fn node_present(kind: &str, name: &str) -> bool {
|
||||
.any(|line| line.split('\t').nth(1) == Some(name))
|
||||
}
|
||||
|
||||
/// Unloads any leftover `module-echo-cancel` instance we previously created
|
||||
/// (identified by our virtual node names in its argument string). Best-effort.
|
||||
fn pid_from_ec_args(args: &str) -> Option<u32> {
|
||||
let source_prefix = format!("source_name={EC_SOURCE}.");
|
||||
args.split_whitespace()
|
||||
.find_map(|arg| arg.strip_prefix(&source_prefix))?
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn ec_module_is_stale(args: &str, is_alive: impl Fn(u32) -> bool) -> bool {
|
||||
pid_from_ec_args(args).is_some_and(|pid| !is_alive(pid))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn process_is_alive(pid: u32) -> bool {
|
||||
std::path::Path::new("/proc").join(pid.to_string()).exists()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn process_is_alive(_pid: u32) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Unloads leftover PeerSpeak `module-echo-cancel` instances only when their
|
||||
/// owning process is gone. Best-effort and conservative on non-Linux platforms.
|
||||
fn unload_stale() {
|
||||
let Ok(out) = Command::new("pactl").arg("list").arg("modules").arg("short").output() else {
|
||||
return;
|
||||
@@ -137,7 +175,10 @@ fn unload_stale() {
|
||||
let index = cols.next().unwrap_or("");
|
||||
let name = cols.next().unwrap_or("");
|
||||
let args = cols.next().unwrap_or("");
|
||||
if name == "module-echo-cancel" && args.contains(EC_SOURCE) && index.parse::<u64>().is_ok() {
|
||||
if name == "module-echo-cancel"
|
||||
&& ec_module_is_stale(args, process_is_alive)
|
||||
&& index.parse::<u64>().is_ok()
|
||||
{
|
||||
let _ = Command::new("pactl").arg("unload-module").arg(index).output();
|
||||
crate::log_msg(&format!("Echo cancel: cleaned up stale module {index}"));
|
||||
}
|
||||
@@ -155,12 +196,42 @@ mod tests {
|
||||
#[ignore]
|
||||
fn enable_creates_and_unloads_nodes() {
|
||||
let guard = enable(None, None).expect("module-echo-cancel should load");
|
||||
assert!(node_present("sources", EC_SOURCE), "cleaned source must exist");
|
||||
assert!(node_present("sinks", EC_SINK), "reference sink must exist");
|
||||
let source_name = guard.source_name().to_string();
|
||||
let sink_name = guard.sink_name().to_string();
|
||||
assert!(node_present("sources", &source_name), "cleaned source must exist");
|
||||
assert!(node_present("sinks", &sink_name), "reference sink must exist");
|
||||
drop(guard);
|
||||
// Give pactl a moment to tear the nodes down.
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
assert!(!node_present("sources", EC_SOURCE), "source must be gone after unload");
|
||||
assert!(!node_present("sinks", EC_SINK), "sink must be gone after unload");
|
||||
assert!(!node_present("sources", &source_name), "source must be gone after unload");
|
||||
assert!(!node_present("sinks", &sink_name), "sink must be gone after unload");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_owner_pid_only_from_our_source_name() {
|
||||
assert_eq!(
|
||||
pid_from_ec_args(
|
||||
"aec_method=webrtc source_name=peerspeak_echocancel_source.4242 sink_name=peerspeak_echocancel_sink.4242"
|
||||
),
|
||||
Some(4242)
|
||||
);
|
||||
assert_eq!(pid_from_ec_args("aec_method=webrtc"), None);
|
||||
assert_eq!(
|
||||
pid_from_ec_args("source_name=peerspeak_echocancel_source.not-a-pid"),
|
||||
None
|
||||
);
|
||||
assert_eq!(pid_from_ec_args("source_name=someone_elses_source.4242"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_decision_keeps_live_and_foreign_modules() {
|
||||
let ours = "source_name=peerspeak_echocancel_source.4242";
|
||||
assert!(!ec_module_is_stale(ours, |pid| pid == 4242));
|
||||
assert!(ec_module_is_stale(ours, |_| false));
|
||||
assert!(!ec_module_is_stale("source_name=foreign.4242", |_| false));
|
||||
assert!(!ec_module_is_stale(
|
||||
"source_name=peerspeak_echocancel_source.malformed",
|
||||
|_| false
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ pub trait AudioBackend: Send + Sync {
|
||||
fn stop(&self) -> Result<(), AudioError>;
|
||||
}
|
||||
|
||||
pub mod clip_player;
|
||||
pub mod eq;
|
||||
pub mod gate;
|
||||
pub mod limiter;
|
||||
|
||||
@@ -29,6 +29,32 @@ const SILENCE_CHUNK: usize = FRAME_SAMPLES * 256;
|
||||
/// can drift if the capture clock runs ahead of the mixer cycle; past it the
|
||||
/// oldest mic audio is dropped. Mirrors `recorder::MAX_MIC_FIFO`.
|
||||
const MAX_MIC_FIFO: usize = 48_000 / 5;
|
||||
const MAX_SESSION_DIR_ATTEMPTS: usize = 1_000;
|
||||
|
||||
/// Create a collision-free session directory for a timestamp. The base
|
||||
/// timestamp is tried first, followed by `-2`, `-3`, and so on; an existing
|
||||
/// recording is never reopened or overwritten.
|
||||
pub fn create_session_dir(base: &Path, now_unix_secs: u64) -> io::Result<PathBuf> {
|
||||
let filename = crate::audio::recorder::timestamp_filename(now_unix_secs);
|
||||
let stem = filename.trim_end_matches(".wav");
|
||||
for attempt in 1..=MAX_SESSION_DIR_ATTEMPTS {
|
||||
let name = if attempt == 1 {
|
||||
stem.to_string()
|
||||
} else {
|
||||
format!("{stem}-{attempt}")
|
||||
};
|
||||
let path = base.join(name);
|
||||
match std::fs::create_dir(&path) {
|
||||
Ok(()) => return Ok(path),
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
"multitrack directory suffixes exhausted",
|
||||
))
|
||||
}
|
||||
|
||||
/// One output track: its WAV writer plus whether it has been written *this*
|
||||
/// cycle (so `end_cycle` knows which tracks to pad with silence).
|
||||
@@ -263,6 +289,19 @@ mod tests {
|
||||
assert_eq!(track_filename("!!!", &id), format!("peer-{short}.wav"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_second_sessions_get_unique_directories_without_reuse() {
|
||||
let base = tmpdir("collision");
|
||||
let first = create_session_dir(&base, 1_700_000_000).unwrap();
|
||||
std::fs::write(first.join("sentinel"), b"keep me").unwrap();
|
||||
|
||||
let second = create_session_dir(&base, 1_700_000_000).unwrap();
|
||||
|
||||
assert_ne!(second, first);
|
||||
assert_eq!(std::fs::read(first.join("sentinel")).unwrap(), b"keep me");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_tracks_equal_length_after_n_cycles() {
|
||||
let dir = tmpdir("equal");
|
||||
|
||||
@@ -151,11 +151,9 @@ fn run_capture(cmd_rx: pw::channel::Receiver<()>, tx: Sender<Vec<i16>>, target_n
|
||||
let data = &mut datas[0];
|
||||
let size = data.chunk().size() as usize;
|
||||
if let Some(slice) = data.data() {
|
||||
// Each sample is 2 bytes (S16LE)
|
||||
for chunk in slice[..size].chunks_exact(2) {
|
||||
let sample = i16::from_le_bytes([chunk[0], chunk[1]]);
|
||||
for_each_capture_sample(slice, size, |sample| {
|
||||
let _ = user_data.producer.try_push(sample);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,6 +222,16 @@ fn run_capture(cmd_rx: pw::channel::Receiver<()>, tx: Sender<Vec<i16>>, target_n
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Visit the complete S16LE samples in the portion PipeWire reports as filled.
|
||||
/// Clamp the reported byte count to the mapped slice before indexing: a bad
|
||||
/// chunk size must not panic from the realtime capture callback.
|
||||
fn for_each_capture_sample(slice: &[u8], size: usize, mut visit: impl FnMut(i16)) {
|
||||
let size = size.min(slice.len());
|
||||
for chunk in slice[..size].chunks_exact(2) {
|
||||
visit(i16::from_le_bytes([chunk[0], chunk[1]]));
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames the playback RT callback should produce this cycle.
|
||||
///
|
||||
/// `requested` is the graph's per-cycle quantum from `Buffer::requested()` (0 if
|
||||
@@ -263,6 +271,25 @@ fn drain_loop(
|
||||
}
|
||||
}
|
||||
|
||||
/// Reserve exact occupancy before making a frame visible to the consumer.
|
||||
/// `after_reserve` is empty in production and lets the regression test force a
|
||||
/// consumer interleaving at the critical ordering boundary.
|
||||
fn publish_frame<P: Producer<Item = i16>>(
|
||||
fill: &AtomicUsize,
|
||||
dropped: &AtomicU64,
|
||||
producer: &mut P,
|
||||
frame: &[i16],
|
||||
after_reserve: impl FnOnce(),
|
||||
) {
|
||||
fill.fetch_add(frame.len(), Ordering::Relaxed);
|
||||
after_reserve();
|
||||
let pushed = producer.push_slice(frame);
|
||||
if pushed != frame.len() {
|
||||
fill.fetch_sub(frame.len() - pushed, Ordering::Relaxed);
|
||||
dropped.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
fn frames_to_produce(requested: usize, mapped_frames: usize) -> usize {
|
||||
/// Safe per-cycle fallback when the graph doesn't report a quantum.
|
||||
const FALLBACK_FRAMES: usize = 1024;
|
||||
@@ -522,10 +549,12 @@ fn run_playback(
|
||||
worker_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
for &sample in &frame {
|
||||
let _ = producer.try_push(sample);
|
||||
}
|
||||
worker_fill.fetch_add(frame.len(), Ordering::Relaxed);
|
||||
// Reserve occupancy BEFORE publishing samples. Otherwise the RT
|
||||
// consumer can pop a newly-visible sample before it is counted and
|
||||
// wrap the exact fill gauge to usize::MAX, wedging mixer pacing.
|
||||
// `push_slice` also publishes the frame as one operation rather than
|
||||
// exposing a half-written stereo pair.
|
||||
publish_frame(&worker_fill, &worker_dropped, &mut producer, &frame, || {});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -577,8 +606,9 @@ fn run_playback(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{drain_loop, frames_to_produce};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use super::{drain_loop, for_each_capture_sample, frames_to_produce, publish_frame};
|
||||
use ringbuf::{HeapRb, traits::{Consumer, Producer, Split}};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use std::{sync::mpsc, thread};
|
||||
@@ -614,6 +644,39 @@ mod tests {
|
||||
assert_eq!(frames_to_produce(1024, 0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_size_larger_than_mapping_is_clamped() {
|
||||
let mut samples = Vec::new();
|
||||
for_each_capture_sample(&[1, 0, 2, 0, 3], usize::MAX, |sample| {
|
||||
samples.push(sample)
|
||||
});
|
||||
assert_eq!(samples, vec![1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occupancy_is_reserved_before_frame_is_published() {
|
||||
let rb = HeapRb::<i16>::new(8);
|
||||
let (mut producer, mut consumer) = rb.split();
|
||||
assert!(producer.try_push(7).is_ok());
|
||||
|
||||
let fill = AtomicUsize::new(1);
|
||||
let dropped = AtomicU64::new(0);
|
||||
publish_frame(&fill, &dropped, &mut producer, &[10, 11], || {
|
||||
// Force the consumer to drain the old sample after the new frame's
|
||||
// occupancy is reserved but before that frame is published.
|
||||
assert_eq!(consumer.try_pop(), Some(7));
|
||||
assert_eq!(fill.fetch_sub(1, Ordering::Relaxed), 3);
|
||||
});
|
||||
|
||||
assert_eq!(fill.load(Ordering::Relaxed), 2);
|
||||
assert_eq!(consumer.try_pop(), Some(10));
|
||||
assert_eq!(fill.fetch_sub(1, Ordering::Relaxed), 2);
|
||||
assert_eq!(consumer.try_pop(), Some(11));
|
||||
assert_eq!(fill.fetch_sub(1, Ordering::Relaxed), 1);
|
||||
assert_eq!(fill.load(Ordering::Relaxed), 0);
|
||||
assert_eq!(dropped.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
|
||||
// --- drain_loop (A7: worker must not hang shutdown) ---
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
//! and patches the two size fields on [`Recorder::finalize`].
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::fs::File;
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -24,6 +24,7 @@ const BITS_PER_SAMPLE: u16 = 16;
|
||||
const CHANNELS: u16 = 1;
|
||||
const RIFF_DATA_OVERHEAD: u64 = 36;
|
||||
const MAX_RIFF_DATA_BYTES: u64 = u32::MAX as u64 - RIFF_DATA_OVERHEAD;
|
||||
const MAX_NAME_ATTEMPTS: usize = 1_000;
|
||||
|
||||
/// Cap on buffered mic samples (~200ms). Bounds how far recording lag can drift
|
||||
/// if the capture clock runs persistently faster than playout — past this we drop
|
||||
@@ -42,7 +43,12 @@ pub struct WavWriter {
|
||||
impl WavWriter {
|
||||
/// Create the file and write the 44-byte header with zeroed size fields.
|
||||
pub fn new(path: &Path) -> io::Result<Self> {
|
||||
let mut file = File::create(path)?;
|
||||
Self::from_file(File::create(path)?)
|
||||
}
|
||||
|
||||
/// Start a WAV in an already-opened file. This lets callers choose atomic
|
||||
/// create-new semantics instead of the truncating behavior of `File::create`.
|
||||
fn from_file(mut file: File) -> io::Result<Self> {
|
||||
file.write_all(&Self::header(0))?;
|
||||
Ok(Self {
|
||||
file,
|
||||
@@ -125,13 +131,31 @@ impl Recorder {
|
||||
/// Create a recording at `dir/<timestamped>.wav`. The directory is assumed to
|
||||
/// exist (the caller creates it).
|
||||
pub fn create(dir: &Path, now_unix_secs: u64) -> io::Result<Self> {
|
||||
let path = dir.join(timestamp_filename(now_unix_secs));
|
||||
let writer = WavWriter::new(&path)?;
|
||||
Ok(Self {
|
||||
writer,
|
||||
let filename = timestamp_filename(now_unix_secs);
|
||||
let stem = filename.trim_end_matches(".wav");
|
||||
for attempt in 1..=MAX_NAME_ATTEMPTS {
|
||||
let name = if attempt == 1 {
|
||||
filename.clone()
|
||||
} else {
|
||||
format!("{stem}-{attempt}.wav")
|
||||
};
|
||||
let path = dir.join(name);
|
||||
match OpenOptions::new().write(true).create_new(true).open(&path) {
|
||||
Ok(file) => {
|
||||
return Ok(Self {
|
||||
writer: WavWriter::from_file(file)?,
|
||||
mic_fifo: VecDeque::new(),
|
||||
path,
|
||||
})
|
||||
});
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
"recording filename suffixes exhausted",
|
||||
))
|
||||
}
|
||||
|
||||
/// The path being written.
|
||||
@@ -210,6 +234,30 @@ mod tests {
|
||||
assert_eq!(timestamp_filename(0), "peerspeak-1970-01-01_000000.wav");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_second_recordings_get_unique_files_without_truncation() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"peerspeak-collision-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
let mut first = Recorder::create(&dir, 1_700_000_000).unwrap();
|
||||
first.write_frame(&[123, 456]).unwrap();
|
||||
let first_path = first.path().to_path_buf();
|
||||
first.finalize().unwrap();
|
||||
let original = std::fs::read(&first_path).unwrap();
|
||||
|
||||
let second = Recorder::create(&dir, 1_700_000_000).unwrap();
|
||||
let second_path = second.path().to_path_buf();
|
||||
assert_ne!(second_path, first_path);
|
||||
assert_eq!(std::fs::read(&first_path).unwrap(), original);
|
||||
second.finalize().unwrap();
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_header_round_trips_sizes() {
|
||||
let dir = std::env::temp_dir();
|
||||
|
||||
@@ -45,6 +45,22 @@ pub fn process_background(raw: &[u8]) -> Result<Vec<u8>, String> {
|
||||
Ok(png.into_inner())
|
||||
}
|
||||
|
||||
/// A filesystem-safe, app-owned filename for the processed PNG of a per-game
|
||||
/// background (W18), derived from the game's stable id by hashing rather than
|
||||
/// embedding the raw id: keeps the name short and safe (ids contain `:` and
|
||||
/// arbitrary executable basenames) and avoids leaking the id into the filesystem.
|
||||
/// Deterministic and dependency-free (FNV-1a 64-bit), so the same game id always
|
||||
/// maps to the same file.
|
||||
pub fn game_background_filename(game_id: &str) -> String {
|
||||
// FNV-1a, 64-bit.
|
||||
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for b in game_id.as_bytes() {
|
||||
hash ^= *b as u64;
|
||||
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
format!("game-bg-{hash:016x}.png")
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -90,6 +106,17 @@ mod tests {
|
||||
assert!(process_background(b"definitely not an image").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_background_filename_is_stable_safe_and_distinct() {
|
||||
let a = game_background_filename("steam:730");
|
||||
// Stable for the same id.
|
||||
assert_eq!(a, game_background_filename("steam:730"));
|
||||
// Distinct ids → distinct files (no `:` or path chars leak through).
|
||||
assert_ne!(a, game_background_filename("exe:hl2_linux"));
|
||||
assert!(a.starts_with("game-bg-") && a.ends_with(".png"));
|
||||
assert!(!a.contains(':') && !a.contains('/') && !a.contains('\\'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrim_color_sets_alpha_and_keeps_rgb() {
|
||||
let base = Color::from_rgb(0.1, 0.2, 0.3);
|
||||
|
||||
@@ -64,6 +64,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
addr: endpoint_a.addr(),
|
||||
sharing: None,
|
||||
avatar: Default::default(),
|
||||
game: None,
|
||||
};
|
||||
room_a.join(&ticket_str, state_a, vec![]).await?;
|
||||
println!("Node A joined topic.");
|
||||
@@ -83,6 +84,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
addr: endpoint_b.addr(),
|
||||
sharing: None,
|
||||
avatar: Default::default(),
|
||||
game: None,
|
||||
};
|
||||
room_b.join(&ticket_str, state_b, vec![]).await?;
|
||||
println!("Node B joined topic.");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::notify::Sound;
|
||||
use crate::theme::AppTheme;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -199,6 +199,26 @@ pub struct AppConfig {
|
||||
/// `crate::background::scrim_color`.
|
||||
#[serde(default = "default_background_dim")]
|
||||
pub background_dim: f32,
|
||||
/// Broadcast the detected game as presence next to our avatar (game-detection
|
||||
/// feature). **Opt-in, default OFF.** Enabling immediately publishes the
|
||||
/// current game; disabling immediately publishes `game: None`. Toggling this
|
||||
/// is the only thing that puts our game on the wire — detection itself (for the
|
||||
/// local background) runs regardless.
|
||||
#[serde(default)]
|
||||
pub game_presence_enabled: bool,
|
||||
/// Per-game UI background overrides (W18), keyed by stable game id
|
||||
/// (`steam:730`, `exe:hl2_linux`) → path to the processed PNG we wrote in the
|
||||
/// config dir (see `game_background_path`). The running game's entry wins; with
|
||||
/// no entry we fall back to the single custom `background`. Local-only; never
|
||||
/// sent to peers. `BTreeMap` for deterministic serialization.
|
||||
#[serde(default)]
|
||||
pub game_backgrounds: BTreeMap<String, String>,
|
||||
/// User process→display-name mappings for non-Steam game detection, keyed by
|
||||
/// normalized executable basename (`hl2_linux`) → the name to show/broadcast
|
||||
/// (`Half-Life 2`). Only exact mappings here are ever matched (we never guess a
|
||||
/// game from an arbitrary process). Local-only.
|
||||
#[serde(default)]
|
||||
pub game_process_map: BTreeMap<String, String>,
|
||||
/// What a call recording captures (mixed / per-peer stems / both).
|
||||
#[serde(default)]
|
||||
pub recording_mode: RecordingMode,
|
||||
@@ -255,6 +275,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,
|
||||
@@ -296,6 +325,9 @@ impl Default for AppConfig {
|
||||
avatar: crate::avatar::Avatar::default(),
|
||||
background: None,
|
||||
background_dim: default_background_dim(),
|
||||
game_presence_enabled: false,
|
||||
game_backgrounds: BTreeMap::new(),
|
||||
game_process_map: BTreeMap::new(),
|
||||
recording_mode: RecordingMode::default(),
|
||||
custom_sound_self_join: None,
|
||||
custom_sound_peer_join: None,
|
||||
@@ -317,6 +349,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(),
|
||||
@@ -364,17 +398,31 @@ 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> {
|
||||
/// Path to a processed-background PNG of the given filename, alongside
|
||||
/// `config.json` in the app config dir. We store our own downscaled copies here
|
||||
/// (rather than base64 in the config) so the JSON stays small. Used for both
|
||||
/// the single custom background and the per-game backgrounds.
|
||||
fn background_dir_path(filename: &str) -> Option<PathBuf> {
|
||||
dirs::config_dir().map(|mut p| {
|
||||
p.push("peerspeak");
|
||||
p.push("background.png");
|
||||
p.push(filename);
|
||||
p
|
||||
})
|
||||
}
|
||||
|
||||
/// Path the single custom-background PNG (W16) is written to.
|
||||
pub fn background_path() -> Option<PathBuf> {
|
||||
Self::background_dir_path("background.png")
|
||||
}
|
||||
|
||||
/// Path the processed per-game background PNG (W18) for `game_id` is written
|
||||
/// to. The filename is an app-owned hash of the id (see
|
||||
/// `crate::background::game_background_filename`), so raw game ids never appear
|
||||
/// on disk and the name is always filesystem-safe.
|
||||
pub fn game_background_path(game_id: &str) -> Option<PathBuf> {
|
||||
Self::background_dir_path(&crate::background::game_background_filename(game_id))
|
||||
}
|
||||
|
||||
pub fn load() -> Self {
|
||||
if let Some(path) = Self::config_path()
|
||||
&& let Ok(contents) = fs::read_to_string(&path)
|
||||
@@ -457,6 +505,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
|
||||
@@ -467,6 +517,51 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backward_compat_game_detection_fields() {
|
||||
// A config that predates the game-detection feature (W18) — and crucially
|
||||
// still carries the W16 single `background` as a plain string — must
|
||||
// deserialize without error. `AppConfig::load()` silently replaces ANY
|
||||
// deserialize failure with full defaults, so a broken migration here would
|
||||
// wipe everyone's settings; this guards that the additive fields kept the
|
||||
// old shape loadable and that `background` was NOT retyped.
|
||||
let legacy_json = r#"{
|
||||
"input_device": "",
|
||||
"output_device": "",
|
||||
"noise_gate_threshold": 0.01,
|
||||
"username": "Eric",
|
||||
"background": "/home/eric/.config/peerspeak/background.png",
|
||||
"background_dim": 0.4
|
||||
}"#;
|
||||
let cfg: AppConfig = serde_json::from_str(legacy_json).unwrap();
|
||||
// The pre-existing single background survives untouched (still Option<String>).
|
||||
assert_eq!(cfg.background.as_deref(), Some("/home/eric/.config/peerspeak/background.png"));
|
||||
assert!((cfg.background_dim - 0.4).abs() < f32::EPSILON);
|
||||
// The new game-detection fields default to off/empty → silent, opt-in upgrade.
|
||||
assert!(!cfg.game_presence_enabled);
|
||||
assert!(cfg.game_backgrounds.is_empty());
|
||||
assert!(cfg.game_process_map.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_game_maps_serialize_deterministically() {
|
||||
// BTreeMap ordering makes the serialized config stable across runs.
|
||||
let mut cfg = AppConfig::default();
|
||||
cfg.game_backgrounds.insert("steam:730".into(), "/a.png".into());
|
||||
cfg.game_backgrounds.insert("exe:hl2_linux".into(), "/b.png".into());
|
||||
cfg.game_process_map.insert("hl2_linux".into(), "Half-Life 2".into());
|
||||
let json = serde_json::to_string(&cfg).unwrap();
|
||||
// Keys appear in sorted order (exe: before steam:).
|
||||
let bg = json.find("game_backgrounds").unwrap();
|
||||
let exe_at = json[bg..].find("exe:hl2_linux").unwrap();
|
||||
let steam_at = json[bg..].find("steam:730").unwrap();
|
||||
assert!(exe_at < steam_at, "BTreeMap keys must serialize sorted");
|
||||
// Full round-trip preserves the maps.
|
||||
let back: AppConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.game_backgrounds, cfg.game_backgrounds);
|
||||
assert_eq!(back.game_process_map, cfg.game_process_map);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_window_size_fields() {
|
||||
// Default impl is the standard launch size.
|
||||
|
||||
@@ -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>),
|
||||
@@ -77,12 +89,26 @@ pub enum CoreCommand {
|
||||
/// invisible) and the outbound ping scheduler (invisible = fully dark). Sent at
|
||||
/// startup from config and whenever the user changes it.
|
||||
SetPresenceMode(PresenceMode),
|
||||
/// Toggle broadcasting the detected game as presence (game detection). Opt-in,
|
||||
/// default OFF. Enabling immediately publishes the current game; disabling
|
||||
/// immediately publishes `game: None`. Detection for the local background runs
|
||||
/// regardless. Sent at startup from config and on user toggle.
|
||||
SetGamePresenceEnabled(bool),
|
||||
/// Set the manual game-detection override (`Auto` / `None` / a forced game).
|
||||
/// Forwarded to the detector and applied immediately (bypasses debounce).
|
||||
SetGameOverride(crate::game::ManualOverride),
|
||||
/// Replace the user process→display-name mappings used by the non-Steam
|
||||
/// detection fallback. Sent at startup from config and after Settings edits.
|
||||
SetGameProcessMap(std::collections::BTreeMap<String, String>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum UiEvent {
|
||||
RoomJoined { ticket: String, self_id: String },
|
||||
RoomLeft,
|
||||
/// Clear room-scoped UI state after a failed in-call room switch, without a
|
||||
/// leave chime. The persistent identity remains unchanged.
|
||||
RoomReset,
|
||||
PeerJoined { id: EndpointId, state: PeerState },
|
||||
PeerLeft { id: EndpointId },
|
||||
/// The fixed reconnect grace expired and bounded background gossip recovery
|
||||
@@ -105,7 +131,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).
|
||||
@@ -132,6 +164,12 @@ pub enum UiEvent {
|
||||
/// failure, this carries the previous truthful mode. The GUI must mirror +
|
||||
/// persist this so its presence picker matches the endpoint's discovery state.
|
||||
PresenceModeReverted { mode: PresenceMode },
|
||||
/// The locally-detected running game changed (game detection). Carries the
|
||||
/// debounced `DetectedGame` (id + display name + source) or `None` when nothing
|
||||
/// is detected. The GUI uses the stable `id` to switch the per-game background
|
||||
/// (W18) and may show a local "Playing …" indicator. Emitted regardless of
|
||||
/// whether game presence is being broadcast — the broadcast is core's own job.
|
||||
GameChanged(Option<crate::game::DetectedGame>),
|
||||
/// Core finished orderly app shutdown and the GUI can exit.
|
||||
ShutdownComplete,
|
||||
Error(String),
|
||||
|
||||
@@ -7,8 +7,8 @@ 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},
|
||||
NetworkTransport, RoomState, SelfPresence, RoomEvent, ConnEvent, PeerSpeakTicket,
|
||||
iroh_impl::{IrohTransport, AudioRouter, FileRouter},
|
||||
gossip::IrohGossipState,
|
||||
};
|
||||
use crate::core::messages::{CoreCommand, UiEvent};
|
||||
@@ -80,6 +80,33 @@ fn audio_datagram_len_ok(len: usize) -> bool {
|
||||
(4..=4 + MAX_OPUS_PAYLOAD).contains(&len)
|
||||
}
|
||||
|
||||
/// The presence label to broadcast for a detected game: its display name,
|
||||
/// sanitized + length-capped, or `None` when there's no game or no broadcastable
|
||||
/// name (a Steam appid without a manifest name, or a label that sanitizes empty).
|
||||
/// Sanitizing here as well as at the gossip ingest boundary keeps the outgoing
|
||||
/// value clean even though every peer re-sanitizes on receipt.
|
||||
fn game_presence_label(game: Option<&crate::game::DetectedGame>) -> Option<String> {
|
||||
game.and_then(|g| g.name.as_deref())
|
||||
.map(crate::sanitize::sanitize_game_label)
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Wait for the next game update. A closed sender permanently disables the
|
||||
/// source by clearing the receiver; subsequent calls remain pending instead of
|
||||
/// leaving an always-ready closed branch in the core `select!` loop.
|
||||
async fn next_game_change(
|
||||
game_rx: &mut Option<tokio::sync::watch::Receiver<Option<crate::game::DetectedGame>>>,
|
||||
) -> Option<Option<crate::game::DetectedGame>> {
|
||||
let Some(rx) = game_rx.as_mut() else {
|
||||
return std::future::pending().await;
|
||||
};
|
||||
if rx.changed().await.is_err() {
|
||||
*game_rx = None;
|
||||
return None;
|
||||
}
|
||||
Some(rx.borrow_and_update().clone())
|
||||
}
|
||||
|
||||
fn arm_discovery_retry(
|
||||
discovery_deadline: &mut Option<tokio::time::Instant>,
|
||||
now: tokio::time::Instant,
|
||||
@@ -105,14 +132,14 @@ type GraceTimers = Arc<std::sync::Mutex<HashMap<EndpointId, tokio::task::JoinHan
|
||||
type SeenConnected = Arc<std::sync::Mutex<HashSet<EndpointId>>>;
|
||||
|
||||
type KnownPeers =
|
||||
Arc<std::sync::Mutex<HashMap<String, HashMap<EndpointId, EndpointAddr>>>>;
|
||||
Arc<std::sync::Mutex<HashMap<[u8; 32], HashMap<EndpointId, EndpointAddr>>>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct RecoveryContext {
|
||||
coordinator: RecoveryCoordinator,
|
||||
room_state: Arc<IrohGossipState>,
|
||||
known_peers: KnownPeers,
|
||||
ticket: String,
|
||||
topic_id: [u8; 32],
|
||||
}
|
||||
|
||||
impl RecoveryContext {
|
||||
@@ -120,7 +147,7 @@ impl RecoveryContext {
|
||||
self.known_peers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&self.ticket)
|
||||
.get(&self.topic_id)
|
||||
.and_then(|peers| peers.get(peer_id))
|
||||
.cloned()
|
||||
}
|
||||
@@ -130,7 +157,7 @@ impl RecoveryContext {
|
||||
}
|
||||
|
||||
fn forget(&self, peer_id: EndpointId) {
|
||||
if let Some(peers) = self.known_peers.lock().unwrap().get_mut(&self.ticket) {
|
||||
if let Some(peers) = self.known_peers.lock().unwrap().get_mut(&self.topic_id) {
|
||||
peers.remove(&peer_id);
|
||||
}
|
||||
self.coordinator.cancel(peer_id);
|
||||
@@ -578,6 +605,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,
|
||||
}
|
||||
@@ -689,6 +719,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
|
||||
@@ -696,6 +727,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),
|
||||
@@ -707,10 +739,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).
|
||||
@@ -869,12 +940,42 @@ 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();
|
||||
// Our chosen avatar (W4), set on Join and changeable via SetAvatar; included
|
||||
// in every self-state we announce over presence.
|
||||
let mut current_avatar = crate::avatar::Avatar::default();
|
||||
// Sticky identity fields of our own presence (display name + W4 avatar), set on
|
||||
// Join and changed via SetName/SetAvatar. Combined with the volatile per-announce
|
||||
// fields (mute/addr/share ticket) by `SelfPresence::to_state` — the single place
|
||||
// our `PeerState` is built. Defaults match the prior `current_name`/`current_avatar`.
|
||||
let mut presence = SelfPresence {
|
||||
name: "Anonymous".to_string(),
|
||||
avatar: crate::avatar::Avatar::default(),
|
||||
game: None,
|
||||
};
|
||||
// Game detection (W17/W18): a background worker polls Steam state + the process
|
||||
// list and publishes the debounced running game on a watch channel. Detection
|
||||
// runs continuously (the GUI uses it for the local per-game background); whether
|
||||
// the game is *broadcast* as presence is gated by `game_presence_enabled` (opt-in,
|
||||
// seeded false; the GUI sends `SetGamePresenceEnabled` from config at startup).
|
||||
// The override + process map start at their defaults and are set via commands.
|
||||
let (game_detector, mut game_rx) = match crate::game::detector::GameDetector::spawn(
|
||||
crate::game::ManualOverride::Auto,
|
||||
std::collections::BTreeMap::new(),
|
||||
) {
|
||||
Ok(detector) => {
|
||||
let rx = detector.subscribe();
|
||||
(Some(detector), Some(rx))
|
||||
}
|
||||
Err(e) => {
|
||||
crate::log_msg(&format!("game detector unavailable: {e}"));
|
||||
(None, None)
|
||||
}
|
||||
};
|
||||
let mut game_presence_enabled = false;
|
||||
// The latest debounced detection, kept regardless of the broadcast toggle so a
|
||||
// later opt-in can immediately publish whatever is currently running.
|
||||
let mut current_game: Option<crate::game::DetectedGame> = None;
|
||||
let mut network_mode = NetworkMode::default();
|
||||
// Pixelpass binary override (config), and the ticket of our own active screen
|
||||
// share (rides our presence so the room — incl. late joiners — can watch).
|
||||
@@ -981,6 +1082,31 @@ async fn run_core_loop(
|
||||
Some(cmd) => cmd,
|
||||
None => break,
|
||||
},
|
||||
game_change = next_game_change(&mut game_rx) => {
|
||||
// The detector worker published a new debounced game (or `None`).
|
||||
let Some(detected) = game_change else {
|
||||
// Worker gone unexpectedly. The helper fused this source, so
|
||||
// this logs once and the closed channel cannot spin select!.
|
||||
crate::log_msg("game detector stopped; disabling game detection");
|
||||
continue;
|
||||
};
|
||||
current_game = detected.clone();
|
||||
// Always tell the GUI for the local per-game background + indicator.
|
||||
let _ = ui_tx.send(UiEvent::GameChanged(detected.clone())).await;
|
||||
// Broadcast as presence only when opted in; re-announce if in a room.
|
||||
if game_presence_enabled {
|
||||
presence.game = game_presence_label(detected.as_ref());
|
||||
if let Some(session) = &active_session {
|
||||
let self_state = presence.to_state(
|
||||
is_muted.load(Ordering::Relaxed),
|
||||
net.endpoint.addr(),
|
||||
current_sharing.clone(),
|
||||
);
|
||||
let _ = session.room_state.update_self_state(self_state).await;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
_ = ping_interval.tick() => {
|
||||
// Fully dark while Invisible (the user's choice): don't even probe,
|
||||
// so nothing we do touches a friend's machine. Otherwise refresh in a
|
||||
@@ -1069,6 +1195,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;
|
||||
|
||||
@@ -1078,8 +1205,9 @@ async fn run_core_loop(
|
||||
}
|
||||
|
||||
CoreCommand::Join { name, ticket, room_name, input_device, output_device, echo_cancellation, avatar } => {
|
||||
current_name = name.clone();
|
||||
current_avatar = avatar;
|
||||
presence.name = name.clone();
|
||||
presence.avatar = avatar;
|
||||
let was_in_room = active_session.is_some();
|
||||
|
||||
// Finalize any recording before tearing down the old session — its
|
||||
// capture/mixer feeders are about to stop.
|
||||
@@ -1091,6 +1219,8 @@ 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();
|
||||
*current_room.lock().unwrap() = None;
|
||||
}
|
||||
|
||||
// If a network-mode / identity change was deferred while a call was
|
||||
@@ -1140,6 +1270,17 @@ async fn run_core_loop(
|
||||
));
|
||||
ticket_str
|
||||
};
|
||||
let topic_id = match PeerSpeakTicket::topic_of(&ticket_str) {
|
||||
Some(topic_id) => topic_id,
|
||||
None => {
|
||||
crate::log_msg("Error invalid room ticket");
|
||||
if was_in_room {
|
||||
let _ = ui_tx.send(UiEvent::RoomReset).await;
|
||||
}
|
||||
let _ = ui_tx.send(UiEvent::Error("invalid room ticket".to_string())).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Per-session transport over the persistent endpoint, bound to the
|
||||
// persistent audio router so this call's inbound audio links route
|
||||
@@ -1147,6 +1288,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(),
|
||||
@@ -1157,22 +1299,20 @@ async fn run_core_loop(
|
||||
|
||||
// Fresh join starts not sharing; clear any stale share ticket.
|
||||
current_sharing = None;
|
||||
let self_state = PeerState {
|
||||
name: current_name.clone(),
|
||||
is_muted: is_muted.load(Ordering::Relaxed),
|
||||
addr: endpoint.addr(),
|
||||
sharing: None,
|
||||
avatar: current_avatar.clone(),
|
||||
};
|
||||
let self_state = presence.to_state(
|
||||
is_muted.load(Ordering::Relaxed),
|
||||
endpoint.addr(),
|
||||
None,
|
||||
);
|
||||
|
||||
// Snapshot THIS room's retained peers (by ticket) as extra bootstrap
|
||||
// Snapshot THIS room's retained peers (by topic) as extra bootstrap
|
||||
// targets so a rejoin can dial them (A8) — including after a detour
|
||||
// through another room, since the per-ticket archive isn't cleared.
|
||||
// through another room, since the per-topic archive isn't cleared.
|
||||
// Resolution rides the persistent address book.
|
||||
let extra_bootstrap: Vec<EndpointAddr> = known_peers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&ticket_str)
|
||||
.get(&topic_id)
|
||||
.map(|peers| peers.values().cloned().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
@@ -1189,8 +1329,12 @@ async fn run_core_loop(
|
||||
));
|
||||
if let Err(e) = room_state.join(&ticket_str, self_state.clone(), extra_bootstrap).await {
|
||||
crate::log_msg(&format!("Error room_state.join failed: {:?}", e));
|
||||
if was_in_room {
|
||||
let _ = ui_tx.send(UiEvent::RoomReset).await;
|
||||
}
|
||||
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");
|
||||
@@ -1214,12 +1358,11 @@ async fn run_core_loop(
|
||||
output_device.as_deref(),
|
||||
) {
|
||||
Ok(guard) => {
|
||||
let source_name = guard.source_name().to_string();
|
||||
let sink_name = guard.sink_name().to_string();
|
||||
echo_cancel_guard = Some(guard);
|
||||
crate::log_msg("Echo cancellation enabled");
|
||||
(
|
||||
Some(crate::audio::echo_cancel::EC_SOURCE.to_string()),
|
||||
Some(crate::audio::echo_cancel::EC_SINK.to_string()),
|
||||
)
|
||||
(Some(source_name), Some(sink_name))
|
||||
}
|
||||
Err(e) => {
|
||||
crate::log_msg(&format!(
|
||||
@@ -1240,9 +1383,13 @@ async fn run_core_loop(
|
||||
let (capture_target, playback_target) = (input_device.clone(), output_device.clone());
|
||||
|
||||
if let Err(e) = audio_backend.start_capture(capture_tx, capture_target) {
|
||||
if was_in_room {
|
||||
let _ = ui_tx.send(UiEvent::RoomReset).await;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1251,10 +1398,14 @@ async fn run_core_loop(
|
||||
// production to the hardware clock instead of a fixed timer.
|
||||
let ring_fill = Arc::new(AtomicUsize::new(0));
|
||||
if let Err(e) = audio_backend.start_playback(playback_rx, playback_target, ring_fill.clone()) {
|
||||
if was_in_room {
|
||||
let _ = ui_tx.send(UiEvent::RoomReset).await;
|
||||
}
|
||||
let _ = ui_tx.send(UiEvent::Error(format!("Failed to start playback: {}", e))).await;
|
||||
let _ = audio_backend.stop();
|
||||
let _ = room_state.leave().await;
|
||||
net.audio_router.clear();
|
||||
net.file_router.clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1397,6 +1548,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();
|
||||
@@ -1413,6 +1565,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
|
||||
@@ -1438,6 +1595,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();
|
||||
@@ -1463,6 +1621,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);
|
||||
|
||||
@@ -1507,6 +1685,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
|
||||
@@ -1522,23 +1702,57 @@ async fn run_core_loop(
|
||||
// track in Both mode) one aligned frame per cycle; Mixed mode
|
||||
// writes the single blended file as before.
|
||||
if mt_active {
|
||||
if let Some(mt) = multitrack_mixer.lock().unwrap().as_mut() {
|
||||
let res = (|| -> std::io::Result<()> {
|
||||
let write_err = multitrack_mixer
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_mut()
|
||||
.map(|mt| -> std::io::Result<()> {
|
||||
for (id, f) in &stems {
|
||||
mt.write_peer(*id, f)?;
|
||||
}
|
||||
mt.write_mix(&record_mix)?;
|
||||
mt.end_cycle()
|
||||
})();
|
||||
if let Err(e) = res {
|
||||
})
|
||||
.transpose()
|
||||
.err();
|
||||
if let Some(e) = write_err {
|
||||
crate::log_msg(&format!("Multitrack write failed: {e}"));
|
||||
stop_recording(
|
||||
&recorder_mixer,
|
||||
&is_recording_mixer,
|
||||
&multitrack_mixer,
|
||||
&is_multitrack_mixer,
|
||||
&ui_tx_mixer,
|
||||
).await;
|
||||
let _ = ui_tx_mixer
|
||||
.send(UiEvent::Error(format!(
|
||||
"Recording stopped — write failed: {e}"
|
||||
)))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
} else if is_recording_mixer.load(Ordering::Relaxed)
|
||||
&& let Some(rec) = recorder_mixer.lock().unwrap().as_mut()
|
||||
&& let Err(e) = rec.write_frame(&record_mix)
|
||||
{
|
||||
} else if is_recording_mixer.load(Ordering::Relaxed) {
|
||||
let write_err = recorder_mixer
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_mut()
|
||||
.map(|rec| rec.write_frame(&record_mix))
|
||||
.transpose()
|
||||
.err();
|
||||
if let Some(e) = write_err {
|
||||
crate::log_msg(&format!("Recording write failed: {e}"));
|
||||
stop_recording(
|
||||
&recorder_mixer,
|
||||
&is_recording_mixer,
|
||||
&multitrack_mixer,
|
||||
&is_multitrack_mixer,
|
||||
&ui_tx_mixer,
|
||||
).await;
|
||||
let _ = ui_tx_mixer
|
||||
.send(UiEvent::Error(format!(
|
||||
"Recording stopped — write failed: {e}"
|
||||
)))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
let frame_to_send = if is_deafened_clone.load(Ordering::Relaxed) {
|
||||
@@ -1565,6 +1779,9 @@ async fn run_core_loop(
|
||||
let mut room_events = match room_state.subscribe_events().await {
|
||||
Ok(rx) => rx,
|
||||
Err(e) => {
|
||||
if was_in_room {
|
||||
let _ = ui_tx.send(UiEvent::RoomReset).await;
|
||||
}
|
||||
let _ = ui_tx.send(UiEvent::Error(format!("Failed to subscribe events: {}", e))).await;
|
||||
continue;
|
||||
}
|
||||
@@ -1579,16 +1796,16 @@ async fn run_core_loop(
|
||||
let multitrack_events = multitrack.clone();
|
||||
let is_multitrack_events = is_multitrack.clone();
|
||||
let known_peers_events = known_peers.clone();
|
||||
// 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();
|
||||
// The topic of the room this event loop serves, so peer add/remove
|
||||
// updates the right per-topic bucket in `known_peers` (A8 archive).
|
||||
let room_topic = topic_id;
|
||||
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(),
|
||||
topic_id,
|
||||
};
|
||||
let recovery_events = recovery_context.clone();
|
||||
// Friends store + ui sender, so a connected peer who is a friend has
|
||||
@@ -1625,12 +1842,12 @@ async fn run_core_loop(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
// Retain this peer under this room's ticket as a
|
||||
// Retain this peer under this room's topic as a
|
||||
// future rejoin bootstrap target (A8).
|
||||
known_peers_events
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(ticket_events.clone())
|
||||
.entry(room_topic)
|
||||
.or_default()
|
||||
.insert(peer_id, state.addr.clone());
|
||||
// If a multitrack recording is live, give this peer
|
||||
@@ -1686,16 +1903,32 @@ async fn run_core_loop(
|
||||
known_peers_events
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(ticket_events.clone())
|
||||
.entry(room_topic)
|
||||
.or_default()
|
||||
.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) => {
|
||||
@@ -1730,6 +1963,9 @@ async fn run_core_loop(
|
||||
let mut conn_events = match transport.subscribe_conn_events().await {
|
||||
Ok(rx) => rx,
|
||||
Err(e) => {
|
||||
if was_in_room {
|
||||
let _ = ui_tx.send(UiEvent::RoomReset).await;
|
||||
}
|
||||
let _ = ui_tx.send(UiEvent::Error(format!("Failed to subscribe conn events: {}", e))).await;
|
||||
continue;
|
||||
}
|
||||
@@ -1798,6 +2034,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;
|
||||
@@ -1819,29 +2056,25 @@ async fn run_core_loop(
|
||||
is_muted.store(new_state, Ordering::Relaxed);
|
||||
|
||||
if let Some(session) = &active_session {
|
||||
let self_state = PeerState {
|
||||
name: current_name.clone(),
|
||||
is_muted: new_state,
|
||||
addr: net.endpoint.addr(),
|
||||
sharing: current_sharing.clone(),
|
||||
avatar: current_avatar.clone(),
|
||||
};
|
||||
let self_state = presence.to_state(
|
||||
new_state,
|
||||
net.endpoint.addr(),
|
||||
current_sharing.clone(),
|
||||
);
|
||||
let _ = session.room_state.update_self_state(self_state).await;
|
||||
}
|
||||
}
|
||||
|
||||
CoreCommand::SetAvatar(avatar) => {
|
||||
current_avatar = avatar;
|
||||
presence.avatar = avatar;
|
||||
// Re-announce presence so the room (incl. late joiners, via the
|
||||
// retained presence) picks up the new avatar (W4).
|
||||
if let Some(session) = &active_session {
|
||||
let self_state = PeerState {
|
||||
name: current_name.clone(),
|
||||
is_muted: is_muted.load(Ordering::Relaxed),
|
||||
addr: net.endpoint.addr(),
|
||||
sharing: current_sharing.clone(),
|
||||
avatar: current_avatar.clone(),
|
||||
};
|
||||
let self_state = presence.to_state(
|
||||
is_muted.load(Ordering::Relaxed),
|
||||
net.endpoint.addr(),
|
||||
current_sharing.clone(),
|
||||
);
|
||||
let _ = session.room_state.update_self_state(self_state).await;
|
||||
}
|
||||
}
|
||||
@@ -1884,6 +2117,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 {
|
||||
@@ -2076,6 +2319,39 @@ async fn run_core_loop(
|
||||
}
|
||||
}
|
||||
|
||||
CoreCommand::SetGamePresenceEnabled(enabled) => {
|
||||
game_presence_enabled = enabled;
|
||||
// Recompute our broadcast label: the current game when enabling,
|
||||
// cleared when disabling. Publish immediately (D8) so peers see the
|
||||
// game appear/disappear at once, not on the next detector tick.
|
||||
presence.game = if enabled {
|
||||
game_presence_label(current_game.as_ref())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(session) = &active_session {
|
||||
let self_state = presence.to_state(
|
||||
is_muted.load(Ordering::Relaxed),
|
||||
net.endpoint.addr(),
|
||||
current_sharing.clone(),
|
||||
);
|
||||
let _ = session.room_state.update_self_state(self_state).await;
|
||||
}
|
||||
}
|
||||
|
||||
CoreCommand::SetGameOverride(override_) => {
|
||||
// Applied on the detector's next poll, immediately (bypasses debounce).
|
||||
if let Some(detector) = &game_detector {
|
||||
detector.set_override(override_);
|
||||
}
|
||||
}
|
||||
|
||||
CoreCommand::SetGameProcessMap(map) => {
|
||||
if let Some(detector) = &game_detector {
|
||||
detector.set_process_map(map);
|
||||
}
|
||||
}
|
||||
|
||||
CoreCommand::SetRecordingMode(mode) => {
|
||||
recording_mode = mode;
|
||||
}
|
||||
@@ -2097,11 +2373,13 @@ async fn run_core_loop(
|
||||
.unwrap_or(0);
|
||||
let result: Result<String, String> = if recording_mode.is_multitrack() {
|
||||
// Multitrack/Both: a per-session directory of stems.
|
||||
let stamp = crate::audio::recorder::timestamp_filename(now);
|
||||
let session_dir = base.join(stamp.trim_end_matches(".wav"));
|
||||
std::fs::create_dir_all(&session_dir)
|
||||
std::fs::create_dir_all(&base)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|_| {
|
||||
crate::audio::multitrack::create_session_dir(&base, now)
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.and_then(|session_dir| {
|
||||
MultitrackRecorder::create(
|
||||
&session_dir,
|
||||
FRAME_SAMPLES,
|
||||
@@ -2167,12 +2445,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());
|
||||
}
|
||||
@@ -2203,13 +2507,11 @@ async fn run_core_loop(
|
||||
crate::log_msg("Screen share host started");
|
||||
session.screenshare_host = Some(child);
|
||||
current_sharing = Some(ticket.clone());
|
||||
let self_state = PeerState {
|
||||
name: current_name.clone(),
|
||||
is_muted: is_muted.load(Ordering::Relaxed),
|
||||
addr: net.endpoint.addr(),
|
||||
sharing: Some(ticket),
|
||||
avatar: current_avatar.clone(),
|
||||
};
|
||||
let self_state = presence.to_state(
|
||||
is_muted.load(Ordering::Relaxed),
|
||||
net.endpoint.addr(),
|
||||
Some(ticket),
|
||||
);
|
||||
let _ = session.room_state.update_self_state(self_state).await;
|
||||
let _ = ui_tx.send(UiEvent::ScreenShareStarted).await;
|
||||
}
|
||||
@@ -2228,13 +2530,11 @@ async fn run_core_loop(
|
||||
let _ = child.kill().await;
|
||||
crate::log_msg("Screen share host stopped");
|
||||
}
|
||||
let self_state = PeerState {
|
||||
name: current_name.clone(),
|
||||
is_muted: is_muted.load(Ordering::Relaxed),
|
||||
addr: net.endpoint.addr(),
|
||||
sharing: None,
|
||||
avatar: current_avatar.clone(),
|
||||
};
|
||||
let self_state = presence.to_state(
|
||||
is_muted.load(Ordering::Relaxed),
|
||||
net.endpoint.addr(),
|
||||
None,
|
||||
);
|
||||
let _ = session.room_state.update_self_state(self_state).await;
|
||||
}
|
||||
let _ = ui_tx.send(UiEvent::ScreenShareStopped).await;
|
||||
@@ -2276,14 +2576,66 @@ async fn run_core_loop(
|
||||
mod tests {
|
||||
use super::{
|
||||
apply_volume, audio_datagram_len_ok, frame_level, mix_frames, mix_stereo_frames,
|
||||
stereo_to_mono, MicLevelMeter, MAX_OPUS_PAYLOAD, MIC_LEVEL_REPORT_SAMPLES,
|
||||
next_game_change, stereo_to_mono, KnownPeers, MicLevelMeter, PeerSpeakTicket,
|
||||
MAX_OPUS_PAYLOAD, MIC_LEVEL_REPORT_SAMPLES,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn retained_peers_are_shared_by_topic_across_restamped_tickets() {
|
||||
let topic_id = [23u8; 32];
|
||||
let original_host = iroh::SecretKey::generate().public();
|
||||
let member_host = iroh::SecretKey::generate().public();
|
||||
let retained_peer = iroh::SecretKey::generate().public();
|
||||
let original = PeerSpeakTicket {
|
||||
host_addr: iroh::EndpointAddr::from(original_host),
|
||||
topic_id,
|
||||
name: "Room".to_string(),
|
||||
}.to_string();
|
||||
let restamped = PeerSpeakTicket {
|
||||
host_addr: iroh::EndpointAddr::from(member_host),
|
||||
topic_id,
|
||||
name: "Room".to_string(),
|
||||
}.to_string();
|
||||
assert_ne!(original, restamped);
|
||||
|
||||
let original_topic = PeerSpeakTicket::topic_of(&original).unwrap();
|
||||
let restamped_topic = PeerSpeakTicket::topic_of(&restamped).unwrap();
|
||||
assert_eq!(original_topic, restamped_topic);
|
||||
|
||||
let retained_addr = iroh::EndpointAddr::from(retained_peer);
|
||||
let known_peers: KnownPeers =
|
||||
std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
|
||||
known_peers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(original_topic)
|
||||
.or_default()
|
||||
.insert(retained_peer, retained_addr.clone());
|
||||
|
||||
let found = known_peers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&restamped_topic)
|
||||
.and_then(|peers| peers.get(&retained_peer))
|
||||
.cloned();
|
||||
assert_eq!(found, Some(retained_addr));
|
||||
}
|
||||
|
||||
/// A frame of constant amplitude with the given sample count.
|
||||
fn frame(amp: i16, len: usize) -> Vec<i16> {
|
||||
vec![amp; len]
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn closed_game_watch_is_fused_after_one_ready_event() {
|
||||
let (tx, rx) = tokio::sync::watch::channel(None);
|
||||
let mut rx = Some(rx);
|
||||
drop(tx);
|
||||
|
||||
assert_eq!(next_game_change(&mut rx).await, None);
|
||||
assert!(rx.is_none(), "closed receiver must disable its select source");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mic_meter_reports_only_after_enough_samples() {
|
||||
let mut m = MicLevelMeter::new();
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
//! Chat file attachments: the compact descriptor that rides a gossip chat
|
||||
//! message, plus the pure validation/sanitization seams for the file-transfer
|
||||
//! plane.
|
||||
//!
|
||||
//! Attachment **bytes do not travel over gossip** — gossip is a small-frame
|
||||
//! broadcast plane (see `avatar` for why image bytes there are hard-capped to
|
||||
//! tens of KB). Instead a chat message carries a [`ChatAttachment`] *descriptor*
|
||||
//! (name, size, kind, id); the sender serves the actual bytes over the dedicated
|
||||
//! file ALPN (`protocol::FILES_ALPN`) via direct QUIC streams, and recipients
|
||||
//! fetch them point-to-point. Everything in this module is dependency-light and
|
||||
//! pure so it can be unit-tested away from the network and the GUI.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Hard ceiling on a single attachment's byte size. Bounds the memory a peer can
|
||||
/// make us hold (when fetching) or serve, and the time a transfer can take.
|
||||
/// 25 MiB comfortably covers phone photos and ordinary documents.
|
||||
pub const MAX_ATTACHMENT_BYTES: u64 = 25 * 1024 * 1024;
|
||||
|
||||
/// Max decoded pixels per side for an inline image preview. Defends against a
|
||||
/// decode-bomb (a small file that expands to an enormous bitmap), independent of
|
||||
/// the byte cap. Applied via `image::Limits` when validating/decoding.
|
||||
pub const MAX_IMAGE_PX: u32 = 4096;
|
||||
|
||||
/// Longest filename we keep and display. Keeps the gossip descriptor compact and
|
||||
/// the UI tidy; the real bytes are unaffected.
|
||||
pub const MAX_FILENAME_LEN: usize = 96;
|
||||
|
||||
/// A 32-byte opaque id identifying one attachment for the fetch request. Minted
|
||||
/// randomly per attachment by the sender (see core); the transfer itself is
|
||||
/// authenticated + encrypted + room-member gated, so the id only needs to be a
|
||||
/// hard-to-guess handle into the sender's serve store, not a content hash.
|
||||
pub type AttachmentId = [u8; 32];
|
||||
|
||||
/// How the receiver should present an attachment. A *hint* derived from the
|
||||
/// sender's content sniff — never trusted for a safety decision. The receiver
|
||||
/// re-validates image bytes itself before decoding, and falls back to a file
|
||||
/// chip if an "Image" doesn't actually decode.
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum AttachmentKind {
|
||||
Image,
|
||||
File,
|
||||
}
|
||||
|
||||
/// The descriptor carried inside a `GossipMessage::Chat`. Compact by design: it
|
||||
/// holds no file bytes, only what the UI needs to render a placeholder/chip and
|
||||
/// what a fetch needs to pull the bytes.
|
||||
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
|
||||
pub struct ChatAttachment {
|
||||
/// Sanitized display filename (already path-stripped — see
|
||||
/// [`sanitize_filename`]). Never used as a filesystem path on receipt without
|
||||
/// the user choosing a save location.
|
||||
pub name: String,
|
||||
/// Byte length of the file. Bounds the fetch read; must be
|
||||
/// `<= MAX_ATTACHMENT_BYTES` (enforced by [`size_within_cap`]).
|
||||
pub size: u64,
|
||||
/// Presentation hint (image vs. generic file).
|
||||
pub kind: AttachmentKind,
|
||||
/// Opaque handle the receiver writes on the file plane to request the bytes.
|
||||
pub id: AttachmentId,
|
||||
}
|
||||
|
||||
/// Sanitize an arbitrary (possibly hostile) filename for display and as a
|
||||
/// save-dialog default. Strips any directory component (both `/` and `\`),
|
||||
/// removes control characters, collapses whitespace, trims, caps the length
|
||||
/// while trying to preserve a short extension, and rejects the `.`/`..` traps.
|
||||
/// Always returns a non-empty, path-component-free name (falls back to `file`).
|
||||
pub fn sanitize_filename(raw: &str) -> String {
|
||||
// Take only the final *non-empty* path component, defeating
|
||||
// `../../etc/passwd`, `C:\foo\bar`, embedded separators, and trailing slashes
|
||||
// (`a/b/c/` → `c`).
|
||||
let base = raw
|
||||
.rsplit(['/', '\\'])
|
||||
.find(|s| !s.trim().is_empty())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
|
||||
// Drop control chars; turn other whitespace into single spaces later.
|
||||
let cleaned: String = base
|
||||
.chars()
|
||||
.filter(|c| !c.is_control())
|
||||
.collect();
|
||||
let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
let collapsed = collapsed.trim_matches('.').trim();
|
||||
|
||||
if collapsed.is_empty() {
|
||||
return "file".to_string();
|
||||
}
|
||||
if collapsed.chars().count() <= MAX_FILENAME_LEN {
|
||||
return collapsed.to_string();
|
||||
}
|
||||
|
||||
// Too long: keep the extension (if short + sane) and truncate the stem.
|
||||
if let Some((stem, ext)) = collapsed.rsplit_once('.')
|
||||
&& !ext.is_empty()
|
||||
&& ext.chars().count() <= 8
|
||||
&& ext.chars().all(|c| c.is_ascii_alphanumeric())
|
||||
{
|
||||
let keep = MAX_FILENAME_LEN.saturating_sub(ext.chars().count() + 1);
|
||||
let truncated: String = stem.chars().take(keep).collect();
|
||||
return format!("{truncated}.{ext}");
|
||||
}
|
||||
collapsed.chars().take(MAX_FILENAME_LEN).collect()
|
||||
}
|
||||
|
||||
/// Whether a declared/observed size is within the transfer cap and non-zero.
|
||||
/// Used both when sending (reject before serving) and when fetching (reject a
|
||||
/// descriptor before opening a stream).
|
||||
pub fn size_within_cap(size: u64) -> bool {
|
||||
size > 0 && size <= MAX_ATTACHMENT_BYTES
|
||||
}
|
||||
|
||||
/// Sniff the leading bytes for a known image container, to set the attachment
|
||||
/// *kind* hint at send time. Recognizes PNG, JPEG, GIF, WebP, and BMP. This is a
|
||||
/// presentation hint only — actual inline rendering still depends on the bytes
|
||||
/// decoding (we only build image features for PNG/JPEG), with a file-chip
|
||||
/// fallback otherwise.
|
||||
pub fn is_probably_image(bytes: &[u8]) -> bool {
|
||||
let b = bytes;
|
||||
let png = b.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]);
|
||||
let jpeg = b.starts_with(&[0xFF, 0xD8, 0xFF]);
|
||||
let gif = b.starts_with(b"GIF87a") || b.starts_with(b"GIF89a");
|
||||
let bmp = b.starts_with(b"BM");
|
||||
let webp = b.len() >= 12 && b.starts_with(b"RIFF") && &b[8..12] == b"WEBP";
|
||||
png || jpeg || gif || bmp || webp
|
||||
}
|
||||
|
||||
/// Sniff the leading bytes for an audio container supported by the inline clip
|
||||
/// player. Audio remains [`AttachmentKind::File`] on the wire; this receiver-side
|
||||
/// check confirms that a filename-based player hint actually contains WAV, MP3,
|
||||
/// Ogg Vorbis, or FLAC data before playback is attempted.
|
||||
pub fn is_probably_audio(bytes: &[u8]) -> bool {
|
||||
let flac = bytes.starts_with(b"fLaC");
|
||||
let ogg = bytes.starts_with(b"OggS");
|
||||
let wav = bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WAVE";
|
||||
let mp3_id3 = bytes.starts_with(b"ID3");
|
||||
let mp3_frame = bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] & 0xE0 == 0xE0;
|
||||
flac || ogg || wav || mp3_id3 || mp3_frame
|
||||
}
|
||||
|
||||
/// Whether a sanitized attachment name has an extension supported by the
|
||||
/// inline audio player. This is only a pre-fetch presentation hint; fetched
|
||||
/// bytes are confirmed with [`is_probably_audio`] before being decoded.
|
||||
pub fn looks_like_audio_name(name: &str) -> bool {
|
||||
let Some((_, extension)) = name.rsplit_once('.') else {
|
||||
return false;
|
||||
};
|
||||
matches!(extension.to_ascii_lowercase().as_str(), "wav" | "mp3" | "ogg" | "oga" | "flac")
|
||||
}
|
||||
|
||||
/// The attachment kind for some file bytes: [`AttachmentKind::Image`] if it
|
||||
/// sniffs as an image container, else [`AttachmentKind::File`].
|
||||
pub fn classify(bytes: &[u8]) -> AttachmentKind {
|
||||
if is_probably_image(bytes) {
|
||||
AttachmentKind::Image
|
||||
} else {
|
||||
AttachmentKind::File
|
||||
}
|
||||
}
|
||||
|
||||
/// Defensively decode image bytes under strict pixel limits to confirm they're a
|
||||
/// real, sane image before we hand them to the renderer. Returns the decoded
|
||||
/// dimensions on success. Guards against decode-bombs (small file → huge bitmap)
|
||||
/// regardless of [`MAX_ATTACHMENT_BYTES`]. Only PNG/JPEG are buildable in our
|
||||
/// `image` feature set; anything else returns `None` and the caller shows a chip.
|
||||
pub fn validate_image_bytes(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(MAX_IMAGE_PX);
|
||||
limits.max_image_height = Some(MAX_IMAGE_PX);
|
||||
let reader = image::ImageReader::new(std::io::Cursor::new(bytes))
|
||||
.with_guessed_format()
|
||||
.ok()?;
|
||||
let mut reader = reader;
|
||||
reader.limits(limits);
|
||||
let img = reader.decode().ok()?;
|
||||
let (w, h) = (img.width(), img.height());
|
||||
if w == 0 || h == 0 || w > MAX_IMAGE_PX || h > MAX_IMAGE_PX {
|
||||
return None;
|
||||
}
|
||||
Some((w, h))
|
||||
}
|
||||
|
||||
/// Parse a file-plane request: it must be exactly one [`AttachmentId`] (32
|
||||
/// bytes). Anything else is rejected so a peer can't send a malformed/oversized
|
||||
/// request frame. Pure half of the serve handler.
|
||||
pub fn parse_request(bytes: &[u8]) -> Option<AttachmentId> {
|
||||
if bytes.len() != 32 {
|
||||
return None;
|
||||
}
|
||||
let mut id = [0u8; 32];
|
||||
id.copy_from_slice(bytes);
|
||||
Some(id)
|
||||
}
|
||||
|
||||
/// A human-readable size like `2.3 MB` / `812 KB` / `40 B` for the file chip.
|
||||
pub fn human_size(bytes: u64) -> String {
|
||||
const KB: u64 = 1024;
|
||||
const MB: u64 = 1024 * KB;
|
||||
if bytes >= MB {
|
||||
format!("{:.1} MB", bytes as f64 / MB as f64)
|
||||
} else if bytes >= KB {
|
||||
format!("{:.0} KB", bytes as f64 / KB as f64)
|
||||
} else {
|
||||
format!("{bytes} B")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sanitize_strips_directory_traversal() {
|
||||
assert_eq!(sanitize_filename("../../etc/passwd"), "passwd");
|
||||
assert_eq!(sanitize_filename("/abs/path/photo.png"), "photo.png");
|
||||
assert_eq!(sanitize_filename(r"C:\Users\me\secret.doc"), "secret.doc");
|
||||
assert_eq!(sanitize_filename("a/b/c/"), "c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_rejects_dot_traps_and_empty() {
|
||||
assert_eq!(sanitize_filename(""), "file");
|
||||
assert_eq!(sanitize_filename("."), "file");
|
||||
assert_eq!(sanitize_filename(".."), "file");
|
||||
assert_eq!(sanitize_filename(" "), "file");
|
||||
assert_eq!(sanitize_filename("/"), "file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_removes_control_chars_and_collapses_ws() {
|
||||
// Control chars (incl. tab/newline) are stripped entirely.
|
||||
assert_eq!(sanitize_filename("my\tphoto\n.png"), "myphoto.png");
|
||||
assert_eq!(sanitize_filename("a\u{0000}b.txt"), "ab.txt");
|
||||
// Real spaces are collapsed but preserved.
|
||||
assert_eq!(sanitize_filename("my photo .png"), "my photo .png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_caps_length_preserving_extension() {
|
||||
let long_stem = "x".repeat(200);
|
||||
let name = format!("{long_stem}.png");
|
||||
let out = sanitize_filename(&name);
|
||||
assert!(out.chars().count() <= MAX_FILENAME_LEN, "len was {}", out.chars().count());
|
||||
assert!(out.ends_with(".png"), "extension preserved: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_cap_bounds() {
|
||||
assert!(!size_within_cap(0));
|
||||
assert!(size_within_cap(1));
|
||||
assert!(size_within_cap(MAX_ATTACHMENT_BYTES));
|
||||
assert!(!size_within_cap(MAX_ATTACHMENT_BYTES + 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_sniffing_recognizes_containers() {
|
||||
assert!(is_probably_image(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0]));
|
||||
assert!(is_probably_image(&[0xFF, 0xD8, 0xFF, 0xE0]));
|
||||
assert!(is_probably_image(b"GIF89a...."));
|
||||
let mut webp = b"RIFF".to_vec();
|
||||
webp.extend_from_slice(&[0, 0, 0, 0]);
|
||||
webp.extend_from_slice(b"WEBP");
|
||||
assert!(is_probably_image(&webp));
|
||||
assert!(!is_probably_image(b"%PDF-1.7"));
|
||||
assert!(!is_probably_image(b""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_sniffing_recognizes_supported_containers() {
|
||||
assert!(is_probably_audio(b"fLaC\0\0\0\x22"));
|
||||
assert!(is_probably_audio(b"OggS\0\x02"));
|
||||
|
||||
let mut wav = b"RIFF".to_vec();
|
||||
wav.extend_from_slice(&[0, 0, 0, 0]);
|
||||
wav.extend_from_slice(b"WAVE");
|
||||
assert!(is_probably_audio(&wav));
|
||||
|
||||
assert!(is_probably_audio(b"ID3\x04\0\0"));
|
||||
assert!(is_probably_audio(&[0xFF, 0xFB, 0x90, 0x64]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_sniffing_disambiguates_wav_from_webp() {
|
||||
let mut wav = b"RIFF".to_vec();
|
||||
wav.extend_from_slice(&[0, 0, 0, 0]);
|
||||
wav.extend_from_slice(b"WAVE");
|
||||
assert!(is_probably_audio(&wav));
|
||||
assert!(!is_probably_image(&wav));
|
||||
|
||||
let mut webp = b"RIFF".to_vec();
|
||||
webp.extend_from_slice(&[0, 0, 0, 0]);
|
||||
webp.extend_from_slice(b"WEBP");
|
||||
assert!(is_probably_image(&webp));
|
||||
assert!(!is_probably_audio(&webp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_sniffing_rejects_non_audio() {
|
||||
assert!(!is_probably_audio(b"%PDF-1.7"));
|
||||
assert!(!is_probably_audio(&[0x89, b'P', b'N', b'G']));
|
||||
assert!(!is_probably_audio(&[]));
|
||||
assert!(!is_probably_audio(&[0xFF]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_name_detection_is_case_insensitive() {
|
||||
for name in ["clip.wav", "clip.mp3", "clip.ogg", "clip.oga", "clip.flac"] {
|
||||
assert!(looks_like_audio_name(name), "{name}");
|
||||
}
|
||||
assert!(looks_like_audio_name("VOICE.MP3"));
|
||||
assert!(looks_like_audio_name("mix.FlAc"));
|
||||
assert!(!looks_like_audio_name("recording"));
|
||||
assert!(!looks_like_audio_name("notes.pdf"));
|
||||
assert!(!looks_like_audio_name("photo.webp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_maps_sniff_to_kind() {
|
||||
assert_eq!(classify(&[0xFF, 0xD8, 0xFF]), AttachmentKind::Image);
|
||||
assert_eq!(classify(b"fLaC\0\0\0\x22"), AttachmentKind::File);
|
||||
assert_eq!(classify(b"plain text"), AttachmentKind::File);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_request_requires_exact_32_bytes() {
|
||||
assert_eq!(parse_request(&[7u8; 32]), Some([7u8; 32]));
|
||||
assert_eq!(parse_request(&[7u8; 31]), None);
|
||||
assert_eq!(parse_request(&[7u8; 33]), None);
|
||||
assert_eq!(parse_request(&[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_image_rejects_garbage() {
|
||||
assert_eq!(validate_image_bytes(b"not an image"), None);
|
||||
assert_eq!(validate_image_bytes(&[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_image_accepts_a_real_png() {
|
||||
// Encode a tiny PNG in-memory, then validate it.
|
||||
let img = image::RgbImage::from_pixel(4, 3, image::Rgb([10, 20, 30]));
|
||||
let mut buf = std::io::Cursor::new(Vec::new());
|
||||
image::DynamicImage::ImageRgb8(img)
|
||||
.write_to(&mut buf, image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
assert_eq!(validate_image_bytes(&buf.into_inner()), Some((4, 3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn human_size_units() {
|
||||
assert_eq!(human_size(40), "40 B");
|
||||
assert_eq!(human_size(2048), "2 KB");
|
||||
assert_eq!(human_size(3 * 1024 * 1024 + 300 * 1024), "3.3 MB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_descriptor_round_trips_json() {
|
||||
let a = ChatAttachment {
|
||||
name: "photo.png".to_string(),
|
||||
size: 12345,
|
||||
kind: AttachmentKind::Image,
|
||||
id: [9u8; 32],
|
||||
};
|
||||
let bytes = serde_json::to_vec(&a).unwrap();
|
||||
let back: ChatAttachment = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(a, back);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
//! The detector service (§5): one cancellable background worker that polls the OS
|
||||
//! adapters, runs the pure matcher + debouncer, and publishes the stable detected
|
||||
//! game on a watch channel — only when it changes, so a flapping detector can't
|
||||
//! spam `PeerState` re-announces.
|
||||
//!
|
||||
//! All the OS reads (Steam files / registry, the process scan) are blocking, so
|
||||
//! the worker is a dedicated `std::thread`, not a tokio task; it owns the
|
||||
//! [`SteamProbe`] cache and the [`Debouncer`] across ticks. The per-tick decision
|
||||
//! is factored into the pure [`poll_once`] so the wiring of resolve + match +
|
||||
//! debounce is unit-tested without any I/O.
|
||||
|
||||
use super::{
|
||||
builtin_denylist, match_processes, resolve, Debouncer, DetectedGame, ManualOverride,
|
||||
};
|
||||
use super::scan;
|
||||
use super::steam::SteamProbe;
|
||||
use std::collections::BTreeMap;
|
||||
use std::io;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::watch;
|
||||
|
||||
/// How often the detector samples Steam state + the process list.
|
||||
pub const POLL_INTERVAL: Duration = Duration::from_secs(3);
|
||||
/// Granularity of the cancellable sleep between polls, so a stop request is
|
||||
/// honored promptly instead of after a full [`POLL_INTERVAL`].
|
||||
const SLEEP_TICK: Duration = Duration::from_millis(200);
|
||||
|
||||
/// Apply one poll's worth of inputs to the debouncer, returning the new published
|
||||
/// value **iff it changed** (the signal to re-announce presence / switch the
|
||||
/// background). Pure: the caller supplies the already-fetched Steam detection and
|
||||
/// process list, so resolve + match + debounce are testable with zero I/O.
|
||||
pub fn poll_once(
|
||||
debouncer: &mut Debouncer,
|
||||
override_: &ManualOverride,
|
||||
steam: Option<DetectedGame>,
|
||||
processes: &[String],
|
||||
process_map: &BTreeMap<String, String>,
|
||||
denylist: &std::collections::BTreeSet<&str>,
|
||||
) -> Option<Option<DetectedGame>> {
|
||||
let matched = match_processes(processes, process_map, denylist);
|
||||
let res = resolve(override_, steam, &matched);
|
||||
if debouncer.observe(res.game, res.immediate) {
|
||||
Some(debouncer.current().cloned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared, live-updatable inputs to the detector, written by core (manual override
|
||||
/// changes, config edits to the process map) and read each poll by the worker.
|
||||
#[derive(Default)]
|
||||
pub struct DetectorInputs {
|
||||
pub override_: Mutex<ManualOverride>,
|
||||
pub process_map: Mutex<BTreeMap<String, String>>,
|
||||
}
|
||||
|
||||
/// A running detector service. Holds the watch receiver for detected-game changes
|
||||
/// and the shared inputs; dropping it (or calling [`stop`](Self::stop)) ends the
|
||||
/// worker thread.
|
||||
pub struct GameDetector {
|
||||
inputs: Arc<DetectorInputs>,
|
||||
rx: watch::Receiver<Option<DetectedGame>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
worker: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl GameDetector {
|
||||
/// Spawn the detector worker. `process_map` seeds the non-Steam mappings;
|
||||
/// `override_` seeds the manual override (usually `Auto`). The worker runs
|
||||
/// until [`stop`](Self::stop) or the returned `GameDetector` is dropped.
|
||||
pub fn spawn(
|
||||
override_: ManualOverride,
|
||||
process_map: BTreeMap<String, String>,
|
||||
) -> io::Result<Self> {
|
||||
let inputs = Arc::new(DetectorInputs {
|
||||
override_: Mutex::new(override_),
|
||||
process_map: Mutex::new(process_map),
|
||||
});
|
||||
let (tx, rx) = watch::channel(None);
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let worker_inputs = inputs.clone();
|
||||
let worker_stop = stop.clone();
|
||||
let worker = std::thread::Builder::new()
|
||||
.name("game-detector".to_string())
|
||||
.spawn(move || worker_loop(worker_inputs, tx, worker_stop))?;
|
||||
|
||||
Ok(Self {
|
||||
inputs,
|
||||
rx,
|
||||
stop,
|
||||
worker: Some(worker),
|
||||
})
|
||||
}
|
||||
|
||||
/// A clone of the watch receiver for detected-game changes. The current value
|
||||
/// is `None` until the first non-empty detection is debounced in.
|
||||
pub fn subscribe(&self) -> watch::Receiver<Option<DetectedGame>> {
|
||||
self.rx.clone()
|
||||
}
|
||||
|
||||
/// Replace the manual override (applied on the next poll, immediately,
|
||||
/// bypassing debounce).
|
||||
pub fn set_override(&self, override_: ManualOverride) {
|
||||
*self.inputs.override_.lock().unwrap() = override_;
|
||||
}
|
||||
|
||||
/// Replace the user process→name mappings (e.g. after a Settings edit).
|
||||
pub fn set_process_map(&self, map: BTreeMap<String, String>) {
|
||||
*self.inputs.process_map.lock().unwrap() = map;
|
||||
}
|
||||
|
||||
/// Signal the worker to exit. Idempotent; also happens on drop.
|
||||
pub fn stop(&self) {
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for GameDetector {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
if let Some(worker) = self.worker.take() {
|
||||
let _ = worker.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The blocking worker loop: probe, decide, publish on change, sleep (cancellably).
|
||||
fn worker_loop(
|
||||
inputs: Arc<DetectorInputs>,
|
||||
tx: watch::Sender<Option<DetectedGame>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
) {
|
||||
let denylist = builtin_denylist();
|
||||
let mut steam = SteamProbe::new();
|
||||
let mut debouncer = Debouncer::default();
|
||||
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
let override_ = inputs.override_.lock().unwrap().clone();
|
||||
let process_map = inputs.process_map.lock().unwrap().clone();
|
||||
|
||||
let steam_game = steam.detect();
|
||||
let processes = scan::running_executables();
|
||||
|
||||
if let Some(new_current) =
|
||||
poll_once(&mut debouncer, &override_, steam_game, &processes, &process_map, &denylist)
|
||||
{
|
||||
// A closed receiver means core shut down; stop quietly.
|
||||
if tx.send(new_current).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Cancellable sleep: wake promptly on a stop request.
|
||||
let mut slept = Duration::ZERO;
|
||||
while slept < POLL_INTERVAL && !stop.load(Ordering::Relaxed) {
|
||||
std::thread::sleep(SLEEP_TICK);
|
||||
slept += SLEEP_TICK;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::GameSource;
|
||||
use super::*;
|
||||
|
||||
fn game(id: &str, name: &str, source: GameSource) -> DetectedGame {
|
||||
DetectedGame { id: id.into(), name: Some(name.into()), source }
|
||||
}
|
||||
|
||||
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
|
||||
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poll_once_debounces_steam_detection() {
|
||||
let deny = builtin_denylist();
|
||||
let mut d = Debouncer::default();
|
||||
let steam = game("steam:730", "CS2", GameSource::Steam);
|
||||
let empty = BTreeMap::new();
|
||||
|
||||
// First poll: detected but not yet published (needs two hits).
|
||||
assert_eq!(
|
||||
poll_once(&mut d, &ManualOverride::Auto, Some(steam.clone()), &[], &empty, &deny),
|
||||
None
|
||||
);
|
||||
// Second poll: published.
|
||||
assert_eq!(
|
||||
poll_once(&mut d, &ManualOverride::Auto, Some(steam.clone()), &[], &empty, &deny),
|
||||
Some(Some(steam))
|
||||
);
|
||||
// Third identical poll: no change event.
|
||||
assert_eq!(
|
||||
poll_once(&mut d, &ManualOverride::Auto, Some(game("steam:730", "CS2", GameSource::Steam)), &[], &empty, &deny),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poll_once_matches_process_when_no_steam() {
|
||||
let deny = builtin_denylist();
|
||||
let mut d = Debouncer::default();
|
||||
let procs = vec!["/games/hl2_linux".to_string()];
|
||||
let user = map(&[("hl2_linux", "Half-Life 2")]);
|
||||
|
||||
poll_once(&mut d, &ManualOverride::Auto, None, &procs, &user, &deny);
|
||||
let change = poll_once(&mut d, &ManualOverride::Auto, None, &procs, &user, &deny);
|
||||
let published = change.expect("should publish on second hit").expect("a game");
|
||||
assert_eq!(published.id, "exe:hl2_linux");
|
||||
assert_eq!(published.name.as_deref(), Some("Half-Life 2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poll_once_manual_override_is_immediate() {
|
||||
let deny = builtin_denylist();
|
||||
let mut d = Debouncer::default();
|
||||
let forced = game("steam:220", "HL2", GameSource::Steam);
|
||||
// Even with a live Steam detection of something else, the override wins now.
|
||||
let other = game("steam:730", "CS2", GameSource::Steam);
|
||||
let change = poll_once(
|
||||
&mut d,
|
||||
&ManualOverride::Force(forced.clone()),
|
||||
Some(other),
|
||||
&[],
|
||||
&BTreeMap::new(),
|
||||
&deny,
|
||||
);
|
||||
assert_eq!(change, Some(Some(forced)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_and_stop_is_clean() {
|
||||
// Smoke test the lifecycle: spawning and stopping must not panic, and the
|
||||
// initial published value is None.
|
||||
let det = GameDetector::spawn(ManualOverride::Auto, BTreeMap::new()).unwrap();
|
||||
assert_eq!(*det.subscribe().borrow(), None);
|
||||
det.set_override(ManualOverride::ForceNone);
|
||||
det.set_process_map(map(&[("x", "X")]));
|
||||
det.stop();
|
||||
// Dropping also stops; no hang/panic.
|
||||
drop(det);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
//! Game detection, game-presence, and game-reactive backgrounds.
|
||||
//!
|
||||
//! A single local "what game is running" detector feeds two consumers:
|
||||
//! 1. **Local** — a per-game UI background that auto-switches (extends W16).
|
||||
//! 2. **Broadcast** — a `Playing <name>` status next to our avatar in every peer's
|
||||
//! roster, riding the gossip presence plane like nickname + avatar.
|
||||
//!
|
||||
//! This module is structured testable-seams-first: the *pure* logic lives here
|
||||
//! (the stable-id scheme, the priority [`resolve`] matcher, the [`Debouncer`], and
|
||||
//! the process-name [`match_processes`] mapping), unit-tested with zero I/O. The OS
|
||||
//! edges — Steam state/file reads ([`steam`]) and the running-process scan
|
||||
//! ([`scan`]) — feed already-parsed values into these pure functions, and the
|
||||
//! cancellable poll service ([`detector`]) wires them together.
|
||||
|
||||
pub mod detector;
|
||||
pub mod scan;
|
||||
pub mod steam;
|
||||
pub mod vdf;
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
/// Where a detected game came from. Encodes the trust/priority tier directly:
|
||||
/// a manual override beats live Steam state, which beats a matched process. Used
|
||||
/// only for prioritization and as a presentation hint — never trusted as identity.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GameSource {
|
||||
/// The user forced a specific game (or "none") via the manual override.
|
||||
Manual,
|
||||
/// Steam's live `RunningAppID` resolved against an `appmanifest`.
|
||||
Steam,
|
||||
/// A running process matched against the user's process→name mappings.
|
||||
Process,
|
||||
}
|
||||
|
||||
/// A game the local detector currently believes is running.
|
||||
///
|
||||
/// `id` is the stable, namespaced identity used as the config key for backgrounds
|
||||
/// (`steam:730`, `exe:hl2_linux`) — **never** the mutable display name. `name` is
|
||||
/// the human label shown locally and broadcast as presence; it is `None` only for
|
||||
/// the Steam appid-without-manifest case, where the background can still switch by
|
||||
/// `id` but nothing is broadcast (per the "don't invent `Steam App 123`" rule).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DetectedGame {
|
||||
/// Stable namespaced identity. Config-key safe; survives renames.
|
||||
pub id: String,
|
||||
/// Trustworthy human name; `None` = id-only (Steam manifest unavailable).
|
||||
pub name: Option<String>,
|
||||
/// Provenance / priority tier.
|
||||
pub source: GameSource,
|
||||
}
|
||||
|
||||
impl DetectedGame {
|
||||
/// The Steam namespaced id for an appid: `steam:<appid>`.
|
||||
pub fn steam_id(app_id: u32) -> String {
|
||||
format!("steam:{app_id}")
|
||||
}
|
||||
|
||||
/// The process namespaced id for an executable identity: `exe:<normalized>`.
|
||||
pub fn exe_id(exe: &str) -> String {
|
||||
format!("exe:{}", normalize_exe(exe))
|
||||
}
|
||||
}
|
||||
|
||||
/// The user's manual override sitting above both detectors (D2). Small by design.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub enum ManualOverride {
|
||||
/// Trust the auto-detector (default).
|
||||
#[default]
|
||||
Auto,
|
||||
/// Force "not playing anything" regardless of what is detected.
|
||||
ForceNone,
|
||||
/// Force a specific game (the user picked it from the known-games list).
|
||||
Force(DetectedGame),
|
||||
}
|
||||
|
||||
/// The outcome of [`resolve`]: the chosen game (if any) plus whether the choice is
|
||||
/// a manual override and so should **bypass the [`Debouncer`]** (apply immediately).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Resolution {
|
||||
pub game: Option<DetectedGame>,
|
||||
/// `true` when a manual override (`ForceNone`/`Force`) decided the value.
|
||||
pub immediate: bool,
|
||||
}
|
||||
|
||||
/// Apply the detector priority (D2 / §5): **manual override → Steam → mapped
|
||||
/// process → none**. Pure; the adapters resolve `steam`/`processes` into
|
||||
/// `DetectedGame`s and this only picks the winner. `processes` is in the adapter's
|
||||
/// deterministic priority order (see [`match_processes`]); its first entry wins.
|
||||
pub fn resolve(
|
||||
override_: &ManualOverride,
|
||||
steam: Option<DetectedGame>,
|
||||
processes: &[DetectedGame],
|
||||
) -> Resolution {
|
||||
match override_ {
|
||||
ManualOverride::ForceNone => Resolution { game: None, immediate: true },
|
||||
ManualOverride::Force(g) => Resolution { game: Some(g.clone()), immediate: true },
|
||||
ManualOverride::Auto => {
|
||||
let game = steam.or_else(|| processes.first().cloned());
|
||||
Resolution { game, immediate: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Samples required before a *new* game is accepted/switched to.
|
||||
pub const ACCEPT_HITS: u32 = 2;
|
||||
/// Consecutive "no game" samples before a currently-shown game is cleared. At the
|
||||
/// ~3 s poll cadence this is ~9 s, absorbing a brief Steam stale/crash blip.
|
||||
pub const CLEAR_MISSES: u32 = 3;
|
||||
|
||||
/// Debounces a stream of raw per-poll detections into a stable published value, so
|
||||
/// a flapping detector can't repeatedly re-announce the entire `PeerState` (which
|
||||
/// can carry the ~48 KB avatar). Pure state machine — the service feeds it samples
|
||||
/// and re-announces only when [`observe`](Debouncer::observe) reports a change.
|
||||
///
|
||||
/// A switch to a different game needs [`ACCEPT_HITS`] matching samples; clearing a
|
||||
/// game needs [`CLEAR_MISSES`] consecutive misses. A manual override
|
||||
/// (`immediate = true`) applies at once, bypassing both counters.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Debouncer {
|
||||
current: Option<DetectedGame>,
|
||||
pending: Option<DetectedGame>,
|
||||
pending_hits: u32,
|
||||
misses: u32,
|
||||
}
|
||||
|
||||
impl Debouncer {
|
||||
/// The currently published, debounced value.
|
||||
pub fn current(&self) -> Option<&DetectedGame> {
|
||||
self.current.as_ref()
|
||||
}
|
||||
|
||||
/// Feed one poll result. `immediate` (a manual override is active) bypasses the
|
||||
/// debounce. Returns `true` iff the published [`current`](Self::current) value
|
||||
/// changed — the signal for the service to re-announce presence / switch the
|
||||
/// background.
|
||||
pub fn observe(&mut self, sample: Option<DetectedGame>, immediate: bool) -> bool {
|
||||
if immediate {
|
||||
let changed = self.current != sample;
|
||||
self.current = sample;
|
||||
self.pending = None;
|
||||
self.pending_hits = 0;
|
||||
self.misses = 0;
|
||||
return changed;
|
||||
}
|
||||
match sample {
|
||||
Some(game) => {
|
||||
self.misses = 0;
|
||||
if self.current.as_ref() == Some(&game) {
|
||||
// Already publishing this game; drop any half-counted switch.
|
||||
self.pending = None;
|
||||
self.pending_hits = 0;
|
||||
false
|
||||
} else {
|
||||
if self.pending.as_ref() == Some(&game) {
|
||||
self.pending_hits += 1;
|
||||
} else {
|
||||
self.pending = Some(game);
|
||||
self.pending_hits = 1;
|
||||
}
|
||||
if self.pending_hits >= ACCEPT_HITS {
|
||||
self.current = self.pending.take();
|
||||
self.pending_hits = 0;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// A miss never counts toward a *switch*; drop any pending candidate.
|
||||
self.pending = None;
|
||||
self.pending_hits = 0;
|
||||
if self.current.is_some() {
|
||||
self.misses += 1;
|
||||
if self.misses >= CLEAR_MISSES {
|
||||
self.current = None;
|
||||
self.misses = 0;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize a raw executable name/path to a stable identity for matching and ids:
|
||||
/// take the final path component (handling both `/` and `\\` separators) and
|
||||
/// lowercase it. Keeps any extension (`minecraft.exe` stays distinct from a
|
||||
/// hypothetical `minecraft`), trims surrounding whitespace.
|
||||
pub fn normalize_exe(raw: &str) -> String {
|
||||
raw.rsplit(['/', '\\']).next().unwrap_or(raw).trim().to_lowercase()
|
||||
}
|
||||
|
||||
/// Launcher/helper executables that must NEVER be reported as a game even if a
|
||||
/// mapping names them — defense against a mis-entered mapping turning the launcher
|
||||
/// itself into "the game". Normalized (lowercase basename) for comparison.
|
||||
const BUILTIN_DENYLIST: &[&str] = &[
|
||||
"steam",
|
||||
"steam.exe",
|
||||
"steamwebhelper",
|
||||
"steamwebhelper.exe",
|
||||
"steamerrorreporter",
|
||||
"gameoverlayui",
|
||||
"reaper",
|
||||
"lutris",
|
||||
"heroic",
|
||||
"heroic.exe",
|
||||
"legendary",
|
||||
"gogdl",
|
||||
"wine",
|
||||
"wine64",
|
||||
"wineserver",
|
||||
"wine-preloader",
|
||||
"proton",
|
||||
"pressure-vessel-wrap",
|
||||
"explorer.exe",
|
||||
"services.exe",
|
||||
"svchost.exe",
|
||||
];
|
||||
|
||||
/// The built-in launcher/helper denylist as a set, for membership checks.
|
||||
pub fn builtin_denylist() -> BTreeSet<&'static str> {
|
||||
BUILTIN_DENYLIST.iter().copied().collect()
|
||||
}
|
||||
|
||||
/// Match the currently-running executables against the user's explicit
|
||||
/// process→display-name mappings, returning detected games in **deterministic
|
||||
/// priority order** (sorted by stable id) with duplicates removed.
|
||||
///
|
||||
/// Conservative by construction (§3): only exact normalized-basename matches to a
|
||||
/// user mapping count — we never guess that an arbitrary long-running process is a
|
||||
/// game. Any executable on `denylist` is rejected even if mapped, so a launcher or
|
||||
/// helper can't be promoted to "the game".
|
||||
///
|
||||
/// `user_map` keys are matched against the normalized basename of each running
|
||||
/// entry; the key itself is normalized too, so the caller may store either
|
||||
/// `Half-Life 2` style display values keyed by `hl2_linux` or `HL2_Linux`.
|
||||
pub fn match_processes(
|
||||
running: &[String],
|
||||
user_map: &BTreeMap<String, String>,
|
||||
denylist: &BTreeSet<&str>,
|
||||
) -> Vec<DetectedGame> {
|
||||
// Normalize the user map once so lookups are basename/case-insensitive.
|
||||
let normalized_map: BTreeMap<String, &String> =
|
||||
user_map.iter().map(|(k, v)| (normalize_exe(k), v)).collect();
|
||||
|
||||
let mut seen: BTreeSet<String> = BTreeSet::new();
|
||||
let mut out: Vec<DetectedGame> = Vec::new();
|
||||
for raw in running {
|
||||
let norm = normalize_exe(raw);
|
||||
if norm.is_empty() || denylist.contains(norm.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(name) = normalized_map.get(&norm) {
|
||||
let id = format!("exe:{norm}");
|
||||
if seen.insert(id.clone()) {
|
||||
out.push(DetectedGame {
|
||||
id,
|
||||
name: Some((*name).clone()),
|
||||
source: GameSource::Process,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Deterministic priority: stable order independent of process-scan order.
|
||||
out.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn steam_game(app_id: u32, name: &str) -> DetectedGame {
|
||||
DetectedGame {
|
||||
id: DetectedGame::steam_id(app_id),
|
||||
name: Some(name.to_string()),
|
||||
source: GameSource::Steam,
|
||||
}
|
||||
}
|
||||
|
||||
// --- ids / normalization ----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn stable_ids_are_namespaced() {
|
||||
assert_eq!(DetectedGame::steam_id(730), "steam:730");
|
||||
assert_eq!(DetectedGame::exe_id("/usr/games/hl2_linux"), "exe:hl2_linux");
|
||||
assert_eq!(DetectedGame::exe_id("C:\\Games\\Minecraft.exe"), "exe:minecraft.exe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_handles_both_separators_and_case() {
|
||||
assert_eq!(normalize_exe("/opt/Foo/Bar.x86_64"), "bar.x86_64");
|
||||
assert_eq!(normalize_exe("D:\\a\\b\\GAME.EXE"), "game.exe");
|
||||
assert_eq!(normalize_exe(" spaced.bin "), "spaced.bin");
|
||||
assert_eq!(normalize_exe("bare"), "bare");
|
||||
}
|
||||
|
||||
// --- resolve priority --------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn resolve_prefers_steam_over_process_in_auto() {
|
||||
let steam = steam_game(730, "CS2");
|
||||
let procs = vec![DetectedGame {
|
||||
id: "exe:foo".into(),
|
||||
name: Some("Foo".into()),
|
||||
source: GameSource::Process,
|
||||
}];
|
||||
let r = resolve(&ManualOverride::Auto, Some(steam.clone()), &procs);
|
||||
assert_eq!(r.game, Some(steam));
|
||||
assert!(!r.immediate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_falls_back_to_first_process_then_none() {
|
||||
let procs = vec![
|
||||
DetectedGame { id: "exe:a".into(), name: Some("A".into()), source: GameSource::Process },
|
||||
DetectedGame { id: "exe:b".into(), name: Some("B".into()), source: GameSource::Process },
|
||||
];
|
||||
let r = resolve(&ManualOverride::Auto, None, &procs);
|
||||
assert_eq!(r.game.as_ref().unwrap().id, "exe:a");
|
||||
let none = resolve(&ManualOverride::Auto, None, &[]);
|
||||
assert_eq!(none.game, None);
|
||||
assert!(!none.immediate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_manual_override_wins_and_is_immediate() {
|
||||
let steam = steam_game(730, "CS2");
|
||||
// ForceNone overrides a live Steam detection, immediately.
|
||||
let r = resolve(&ManualOverride::ForceNone, Some(steam.clone()), &[]);
|
||||
assert_eq!(r.game, None);
|
||||
assert!(r.immediate);
|
||||
// Force(x) overrides too.
|
||||
let forced = steam_game(220, "HL2");
|
||||
let r = resolve(&ManualOverride::Force(forced.clone()), Some(steam), &[]);
|
||||
assert_eq!(r.game, Some(forced));
|
||||
assert!(r.immediate);
|
||||
}
|
||||
|
||||
// --- debounce ----------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn debounce_requires_two_hits_to_switch() {
|
||||
let mut d = Debouncer::default();
|
||||
let g = steam_game(730, "CS2");
|
||||
// First sighting: not yet published.
|
||||
assert!(!d.observe(Some(g.clone()), false));
|
||||
assert_eq!(d.current(), None);
|
||||
// Second consecutive sighting: now published.
|
||||
assert!(d.observe(Some(g.clone()), false));
|
||||
assert_eq!(d.current(), Some(&g));
|
||||
// Steady state: same game, no further change events.
|
||||
assert!(!d.observe(Some(g.clone()), false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debounce_requires_three_misses_to_clear() {
|
||||
let mut d = Debouncer::default();
|
||||
let g = steam_game(730, "CS2");
|
||||
d.observe(Some(g.clone()), false);
|
||||
d.observe(Some(g.clone()), false);
|
||||
assert_eq!(d.current(), Some(&g));
|
||||
// Two misses: still shown (absorbs a transient blip).
|
||||
assert!(!d.observe(None, false));
|
||||
assert!(!d.observe(None, false));
|
||||
assert_eq!(d.current(), Some(&g));
|
||||
// Third miss: cleared.
|
||||
assert!(d.observe(None, false));
|
||||
assert_eq!(d.current(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debounce_blip_during_clear_resets_miss_count() {
|
||||
let mut d = Debouncer::default();
|
||||
let g = steam_game(730, "CS2");
|
||||
d.observe(Some(g.clone()), false);
|
||||
d.observe(Some(g.clone()), false);
|
||||
// Miss, miss, then the game reappears: miss count resets, stays published.
|
||||
d.observe(None, false);
|
||||
d.observe(None, false);
|
||||
assert!(!d.observe(Some(g.clone()), false));
|
||||
assert_eq!(d.current(), Some(&g));
|
||||
// It now takes a fresh run of three misses to clear.
|
||||
d.observe(None, false);
|
||||
d.observe(None, false);
|
||||
assert!(d.observe(None, false));
|
||||
assert_eq!(d.current(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debounce_immediate_bypasses_counters() {
|
||||
let mut d = Debouncer::default();
|
||||
let g = steam_game(730, "CS2");
|
||||
// A manual override publishes on the first sample.
|
||||
assert!(d.observe(Some(g.clone()), true));
|
||||
assert_eq!(d.current(), Some(&g));
|
||||
// ForceNone clears immediately.
|
||||
assert!(d.observe(None, true));
|
||||
assert_eq!(d.current(), None);
|
||||
// Re-issuing the same immediate value is not a change.
|
||||
d.observe(Some(g.clone()), true);
|
||||
assert!(!d.observe(Some(g.clone()), true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debounce_switching_games_needs_two_hits_of_the_new_one() {
|
||||
let mut d = Debouncer::default();
|
||||
let a = steam_game(1, "A");
|
||||
let b = steam_game(2, "B");
|
||||
d.observe(Some(a.clone()), false);
|
||||
d.observe(Some(a.clone()), false);
|
||||
assert_eq!(d.current(), Some(&a));
|
||||
// One sample of B does not switch.
|
||||
assert!(!d.observe(Some(b.clone()), false));
|
||||
assert_eq!(d.current(), Some(&a));
|
||||
// Second consecutive B switches.
|
||||
assert!(d.observe(Some(b.clone()), false));
|
||||
assert_eq!(d.current(), Some(&b));
|
||||
}
|
||||
|
||||
// --- process matching --------------------------------------------------
|
||||
|
||||
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
|
||||
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_processes_matches_only_explicit_mappings() {
|
||||
let user = map(&[("hl2_linux", "Half-Life 2")]);
|
||||
let deny = builtin_denylist();
|
||||
let running = vec![
|
||||
"/usr/bin/firefox".to_string(),
|
||||
"/games/Half-Life 2/hl2_linux".to_string(),
|
||||
"/usr/bin/htop".to_string(),
|
||||
];
|
||||
let got = match_processes(&running, &user, &deny);
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].id, "exe:hl2_linux");
|
||||
assert_eq!(got[0].name.as_deref(), Some("Half-Life 2"));
|
||||
assert_eq!(got[0].source, GameSource::Process);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_processes_rejects_denylisted_even_if_mapped() {
|
||||
// A mis-entered mapping naming the Steam client must not win.
|
||||
let user = map(&[("steam", "Steam (oops)"), ("mygame", "My Game")]);
|
||||
let deny = builtin_denylist();
|
||||
let running = vec!["/usr/bin/steam".into(), "/opt/mygame".into()];
|
||||
let got = match_processes(&running, &user, &deny);
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].id, "exe:mygame");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_processes_is_deterministic_and_deduped() {
|
||||
let user = map(&[("zed", "Zed"), ("alpha", "Alpha")]);
|
||||
let deny = builtin_denylist();
|
||||
// Same game twice (two processes) + reverse discovery order.
|
||||
let running = vec![
|
||||
"/b/zed".into(),
|
||||
"/a/alpha".into(),
|
||||
"/c/alpha".into(),
|
||||
];
|
||||
let got = match_processes(&running, &user, &deny);
|
||||
// Deduped to two, sorted by id (alpha before zed) regardless of scan order.
|
||||
assert_eq!(got.iter().map(|g| g.id.as_str()).collect::<Vec<_>>(), vec!["exe:alpha", "exe:zed"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_processes_ignores_unmapped_and_case_folds() {
|
||||
let user = map(&[("Game.x86_64", "The Game")]);
|
||||
let deny = builtin_denylist();
|
||||
let running = vec!["/x/GAME.X86_64".into(), "/y/random".into()];
|
||||
let got = match_processes(&running, &user, &deny);
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].name.as_deref(), Some("The Game"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Running-process enumeration for the non-Steam detection fallback (D6/D7):
|
||||
//! native adapters only — `/proc` on Linux, Toolhelp on Windows — so there is no
|
||||
//! `sysinfo` dependency and the audit surface stays small.
|
||||
//!
|
||||
//! This module is *just the OS edge*: it returns the list of running executable
|
||||
//! paths/names. The trustworthy part — turning that list into a game via the
|
||||
//! user's explicit mappings and the launcher denylist — is the pure
|
||||
//! [`match_processes`](super::match_processes), unit-tested in the parent module.
|
||||
|
||||
/// Enumerate the executables of currently-running processes as paths/basenames.
|
||||
/// Best-effort: processes we can't introspect (other users') are skipped rather
|
||||
/// than erroring. The result is fed to [`match_processes`](super::match_processes),
|
||||
/// which normalizes each entry to a basename before matching.
|
||||
pub fn running_executables() -> Vec<String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
linux_proc_executables()
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
windows_toolhelp_executables()
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", windows)))]
|
||||
{
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_proc_executables() -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let Ok(entries) = std::fs::read_dir("/proc") else {
|
||||
return out;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let Some(name) = name.to_str() else { continue };
|
||||
// Only numeric entries are processes.
|
||||
if !name.bytes().all(|b| b.is_ascii_digit()) {
|
||||
continue;
|
||||
}
|
||||
let proc_dir = entry.path();
|
||||
// Prefer the real exe path (full, untruncated); fall back to `comm`, which
|
||||
// is readable for all processes but truncated to 15 bytes.
|
||||
if let Ok(exe) = std::fs::read_link(proc_dir.join("exe"))
|
||||
&& let Some(s) = exe.to_str()
|
||||
{
|
||||
out.push(s.to_string());
|
||||
continue;
|
||||
}
|
||||
if let Ok(comm) = std::fs::read_to_string(proc_dir.join("comm")) {
|
||||
let trimmed = comm.trim();
|
||||
if !trimmed.is_empty() {
|
||||
out.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_toolhelp_executables() -> Vec<String> {
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
|
||||
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
|
||||
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
|
||||
TH32CS_SNAPPROCESS,
|
||||
};
|
||||
|
||||
let mut out = Vec::new();
|
||||
// SAFETY: standard Toolhelp snapshot of all processes; handle checked below.
|
||||
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
|
||||
if snapshot == INVALID_HANDLE_VALUE {
|
||||
return out;
|
||||
}
|
||||
let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() };
|
||||
entry.dwSize = std::mem::size_of::<PROCESSENTRY32W>() as u32;
|
||||
// SAFETY: entry is zeroed with dwSize set, as Process32FirstW requires.
|
||||
let mut ok = unsafe { Process32FirstW(snapshot, &mut entry) };
|
||||
while ok != 0 {
|
||||
// szExeFile is a NUL-terminated UTF-16 array (the basename, e.g. game.exe).
|
||||
let end = entry.szExeFile.iter().position(|&c| c == 0).unwrap_or(entry.szExeFile.len());
|
||||
let name = String::from_utf16_lossy(&entry.szExeFile[..end]);
|
||||
if !name.is_empty() {
|
||||
out.push(name);
|
||||
}
|
||||
// SAFETY: same valid snapshot + entry struct.
|
||||
ok = unsafe { Process32NextW(snapshot, &mut entry) };
|
||||
}
|
||||
// SAFETY: snapshot handle came from CreateToolhelp32Snapshot above.
|
||||
unsafe { CloseHandle(snapshot) };
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn enumerates_at_least_this_process() {
|
||||
// The test runner itself is a process, so /proc enumeration must be
|
||||
// non-empty and include something that normalizes to our own exe basename.
|
||||
let exes = running_executables();
|
||||
assert!(!exes.is_empty(), "expected to see running processes via /proc");
|
||||
// Our own /proc/self/exe basename should appear among them.
|
||||
let me = std::fs::read_link("/proc/self/exe")
|
||||
.ok()
|
||||
.and_then(|p| p.file_name().map(|f| f.to_string_lossy().into_owned()));
|
||||
if let Some(me) = me {
|
||||
let me_norm = super::super::normalize_exe(&me);
|
||||
assert!(
|
||||
exes.iter().any(|e| super::super::normalize_exe(e) == me_norm),
|
||||
"running list should include our own executable {me_norm:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
//! Steam detection adapter: the primary signal (D1). Reads Steam's live
|
||||
//! `RunningAppID` and resolves it to a display name via the plain-text
|
||||
//! `appmanifest_<appid>.acf`, with no dependency on the binary `appinfo.vdf`.
|
||||
//!
|
||||
//! The *parsing* is pure and unit-tested ([`parse_running_app_id`],
|
||||
//! [`parse_library_paths`], [`parse_app_name`], all over file contents). The fs /
|
||||
//! Windows-registry reads are the thin edge, and [`SteamProbe`] caches roots,
|
||||
//! library list, and resolved names — invalidating by mtime — so the 3 s detector
|
||||
//! poll does not rescan every library each tick (Codex hardening).
|
||||
|
||||
use super::vdf::{self, Value};
|
||||
use super::{DetectedGame, GameSource};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
|
||||
/// Max bytes read from any single Steam state file. These are small text files
|
||||
/// (a manifest is a few KB); the cap stops a corrupt/hostile giant file from being
|
||||
/// slurped into memory before the parser's own depth guard kicks in.
|
||||
const MAX_STEAM_FILE_BYTES: u64 = 4 * 1024 * 1024;
|
||||
/// SteamPath is a local filesystem path. Four KiB is deliberately generous and
|
||||
/// prevents a corrupt registry length from driving an enormous allocation.
|
||||
#[cfg(any(windows, test))]
|
||||
const MAX_STEAM_PATH_BYTES: u32 = 4 * 1024;
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
fn validate_reg_len(len: u32) -> Option<usize> {
|
||||
(len != 0 && len.is_multiple_of(2) && len <= MAX_STEAM_PATH_BYTES)
|
||||
.then_some(len as usize / 2)
|
||||
}
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
fn decode_reg_sz(mut buf: Vec<u16>, returned_bytes: u32) -> Option<String> {
|
||||
let units = validate_reg_len(returned_bytes)?;
|
||||
if units > buf.len() {
|
||||
return None;
|
||||
}
|
||||
buf.truncate(units);
|
||||
while buf.last() == Some(&0) {
|
||||
buf.pop();
|
||||
}
|
||||
Some(String::from_utf16_lossy(&buf))
|
||||
}
|
||||
|
||||
/// Parse the live `RunningAppID` out of a Steam `registry.vdf` (the Linux/macOS
|
||||
/// client's emulated-registry text file). Returns the appid only when present and
|
||||
/// nonzero — `0`/absent is the "no game" state. Pure.
|
||||
pub fn parse_running_app_id(registry_vdf: &str) -> Option<u32> {
|
||||
let root = vdf::parse(registry_vdf).ok()?;
|
||||
let raw = root
|
||||
.get_path(&["Registry", "HKCU", "Software", "Valve", "Steam", "RunningAppID"])
|
||||
.and_then(Value::as_str)?;
|
||||
let id: u32 = raw.trim().parse().ok()?;
|
||||
(id != 0).then_some(id)
|
||||
}
|
||||
|
||||
/// Parse the library folder paths out of a `libraryfolders.vdf`, handling **both**
|
||||
/// the current shape (`"0" { "path" "..." }`) and the legacy shape
|
||||
/// (`"1" "/path"`, the path as a direct string value). Non-numeric keys
|
||||
/// (`contentstatsid`, …) are skipped. Pure; paths are returned as-is (escapes
|
||||
/// already decoded by the VDF parser), including ones on offline drives — the
|
||||
/// caller checks existence.
|
||||
pub fn parse_library_paths(libraryfolders_vdf: &str) -> Vec<PathBuf> {
|
||||
let Ok(root) = vdf::parse(libraryfolders_vdf) else {
|
||||
return Vec::new();
|
||||
};
|
||||
// The root may or may not wrap entries in a "libraryfolders" object.
|
||||
let container = root.get("libraryfolders").unwrap_or(&root);
|
||||
let mut out = Vec::new();
|
||||
for (key, val) in container.entries() {
|
||||
// Only numeric-keyed entries are library folders.
|
||||
if key.parse::<u32>().is_err() {
|
||||
continue;
|
||||
}
|
||||
let path = match val {
|
||||
Value::Str(s) => Some(s.as_str()),
|
||||
Value::Obj(_) => val.get("path").and_then(Value::as_str),
|
||||
};
|
||||
if let Some(p) = path
|
||||
&& !p.is_empty()
|
||||
{
|
||||
out.push(PathBuf::from(p));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse the human `name` out of an `appmanifest_<appid>.acf`. Pure.
|
||||
pub fn parse_app_name(appmanifest_acf: &str) -> Option<String> {
|
||||
let root = vdf::parse(appmanifest_acf).ok()?;
|
||||
root.get_path(&["AppState", "name"])
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Read at most [`MAX_STEAM_FILE_BYTES`] of a file as UTF-8 (lossy), or `None` if
|
||||
/// it is missing/unreadable. The thin fs edge under the pure parsers above.
|
||||
fn read_capped(path: &Path) -> Option<String> {
|
||||
use std::io::Read;
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
let mut buf = Vec::new();
|
||||
file.take(MAX_STEAM_FILE_BYTES).read_to_end(&mut buf).ok()?;
|
||||
Some(String::from_utf8_lossy(&buf).into_owned())
|
||||
}
|
||||
|
||||
fn mtime_of(path: &Path) -> Option<SystemTime> {
|
||||
std::fs::metadata(path).ok()?.modified().ok()
|
||||
}
|
||||
|
||||
/// A library list cached against its source file's mtime.
|
||||
#[derive(Default)]
|
||||
struct CachedLibraries {
|
||||
source: Option<PathBuf>,
|
||||
mtime: Option<SystemTime>,
|
||||
paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
/// A per-appid resolved name cached against the manifest's mtime. `name` is `None`
|
||||
/// when the manifest exists but carries no usable name, or wasn't found.
|
||||
struct CachedManifest {
|
||||
mtime: Option<SystemTime>,
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
/// Stateful Steam probe with mtime-invalidated caches. Construct once and call
|
||||
/// [`detect`](Self::detect) each poll; all reads are blocking, so the detector
|
||||
/// service runs it off the async worker.
|
||||
pub struct SteamProbe {
|
||||
roots: Vec<PathBuf>,
|
||||
libraries: CachedLibraries,
|
||||
manifests: HashMap<u32, CachedManifest>,
|
||||
}
|
||||
|
||||
impl Default for SteamProbe {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SteamProbe {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
roots: discover_roots(),
|
||||
libraries: CachedLibraries::default(),
|
||||
manifests: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// One detection pass: read the live `RunningAppID`, and if a game is running,
|
||||
/// resolve its name from the appmanifest (cached). Returns a `DetectedGame`
|
||||
/// with `name: None` when the appid is known but no manifest name is available
|
||||
/// — the background can still switch by id, but presence must not invent a name.
|
||||
pub fn detect(&mut self) -> Option<DetectedGame> {
|
||||
let app_id = self.running_app_id()?;
|
||||
let name = self.app_name(app_id);
|
||||
Some(DetectedGame {
|
||||
id: DetectedGame::steam_id(app_id),
|
||||
name,
|
||||
source: GameSource::Steam,
|
||||
})
|
||||
}
|
||||
|
||||
/// The live RunningAppID (nonzero), or `None`.
|
||||
///
|
||||
/// Platform notes: on **Windows** the real registry's `RunningAppID` is updated
|
||||
/// live, so we read it. On **Linux** the client's `registry.vdf` is only
|
||||
/// rewritten on Steam *shutdown* — it's stale while a game runs — so the live
|
||||
/// signal is the running game process's `SteamAppId` environment variable
|
||||
/// (`/proc/<pid>/environ`, readable for our own processes; the same approach
|
||||
/// MangoHud uses); `registry.vdf` stays as a best-effort fallback. Other Unix
|
||||
/// (macOS) only has the `registry.vdf` fallback for now.
|
||||
fn running_app_id(&self) -> Option<u32> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
win::running_app_id()
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
running_app_id_from_environ().or_else(registry_running_app_id)
|
||||
}
|
||||
#[cfg(not(any(windows, target_os = "linux")))]
|
||||
{
|
||||
registry_running_app_id()
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve (and cache) the display name for an appid by locating its
|
||||
/// `appmanifest_<appid>.acf` across the known libraries.
|
||||
fn app_name(&mut self, app_id: u32) -> Option<String> {
|
||||
let manifest = self.find_manifest(app_id)?;
|
||||
let mtime = mtime_of(&manifest);
|
||||
if let Some(cached) = self.manifests.get(&app_id)
|
||||
&& cached.mtime == mtime
|
||||
{
|
||||
return cached.name.clone();
|
||||
}
|
||||
let name = read_capped(&manifest).and_then(|c| parse_app_name(&c));
|
||||
self.manifests.insert(app_id, CachedManifest { mtime, name: name.clone() });
|
||||
name
|
||||
}
|
||||
|
||||
/// The path to an appid's manifest, if it exists in any library.
|
||||
fn find_manifest(&mut self, app_id: u32) -> Option<PathBuf> {
|
||||
let filename = format!("appmanifest_{app_id}.acf");
|
||||
for lib in self.library_paths() {
|
||||
let candidate = lib.join("steamapps").join(&filename);
|
||||
if candidate.exists() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// All Steam library folder paths, cached and refreshed only when the source
|
||||
/// `libraryfolders.vdf` changes (mtime). Discovered from the known roots.
|
||||
fn library_paths(&mut self) -> Vec<PathBuf> {
|
||||
// Locate the libraryfolders.vdf to watch (first existing across roots).
|
||||
let source = self
|
||||
.roots
|
||||
.iter()
|
||||
.map(|r| r.join("steamapps").join("libraryfolders.vdf"))
|
||||
.find(|p| p.exists());
|
||||
|
||||
let mtime = source.as_deref().and_then(mtime_of);
|
||||
if self.libraries.source == source && self.libraries.mtime == mtime && source.is_some() {
|
||||
return self.libraries.paths.clone();
|
||||
}
|
||||
|
||||
let mut paths = Vec::new();
|
||||
if let Some(ref src) = source
|
||||
&& let Some(contents) = read_capped(src)
|
||||
{
|
||||
paths = parse_library_paths(&contents);
|
||||
}
|
||||
// Always include the roots themselves: the install dir is an implicit
|
||||
// library even if libraryfolders.vdf is missing or lists only extras.
|
||||
for root in &self.roots {
|
||||
if !paths.contains(root) {
|
||||
paths.push(root.clone());
|
||||
}
|
||||
}
|
||||
self.libraries = CachedLibraries { source, mtime, paths: paths.clone() };
|
||||
paths
|
||||
}
|
||||
}
|
||||
|
||||
/// Candidate Steam install roots that actually exist on this machine (each is a
|
||||
/// directory containing a `steamapps` folder). Covers native, Flatpak, and Snap
|
||||
/// layouts on Linux; on Windows the install path comes from the registry.
|
||||
fn discover_roots() -> Vec<PathBuf> {
|
||||
let mut roots = Vec::new();
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Some(p) = win::install_path() {
|
||||
roots.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
for rel in [
|
||||
".steam/steam",
|
||||
".steam/root",
|
||||
".local/share/Steam",
|
||||
".var/app/com.valvesoftware.Steam/.local/share/Steam",
|
||||
"snap/steam/common/.local/share/Steam",
|
||||
] {
|
||||
roots.push(home.join(rel));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep only roots that exist and look like a Steam install.
|
||||
roots.retain(|p| p.join("steamapps").is_dir());
|
||||
roots.sort();
|
||||
roots.dedup();
|
||||
roots
|
||||
}
|
||||
|
||||
/// Candidate `registry.vdf` locations (Linux/macOS emulated registry).
|
||||
#[cfg(not(windows))]
|
||||
fn registry_vdf_candidates() -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
out.push(home.join(".steam/registry.vdf"));
|
||||
out.push(home.join(".steam/steam/registry.vdf"));
|
||||
out.push(home.join(".var/app/com.valvesoftware.Steam/.steam/registry.vdf"));
|
||||
out.push(home.join("snap/steam/common/.steam/registry.vdf"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Best-effort `RunningAppID` from the on-disk `registry.vdf`. ⚠️ Stale while a
|
||||
/// game runs (Steam rewrites the file only on shutdown), so this is a *fallback*
|
||||
/// behind the live `/proc` `SteamAppId` scan on Linux — not the primary signal.
|
||||
#[cfg(not(windows))]
|
||||
fn registry_running_app_id() -> Option<u32> {
|
||||
for path in registry_vdf_candidates() {
|
||||
if let Some(contents) = read_capped(&path)
|
||||
&& let Some(id) = parse_running_app_id(&contents)
|
||||
{
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse a Steam appid out of a process's raw `environ` blob (NUL-separated
|
||||
/// `KEY=VALUE` pairs), reading the `SteamAppId` variable Steam exports to every
|
||||
/// game process. Returns the appid only when present and nonzero. Pure +
|
||||
/// unit-tested; the `/proc` iteration is the thin edge in
|
||||
/// [`running_app_id_from_environ`].
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn parse_steam_app_id_from_environ(environ: &[u8]) -> Option<u32> {
|
||||
for kv in environ.split(|&b| b == 0) {
|
||||
if let Some(val) = kv.strip_prefix(b"SteamAppId=")
|
||||
&& let Ok(s) = std::str::from_utf8(val)
|
||||
&& let Ok(id) = s.trim().parse::<u32>()
|
||||
&& id != 0
|
||||
{
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The live Steam appid of a running game, found by scanning `/proc/<pid>/environ`
|
||||
/// for the `SteamAppId` Steam exports to the game's process tree. `environ` is
|
||||
/// readable only for our own processes — exactly the ones a Steam game we launched
|
||||
/// runs as — and we skip the rest. The live signal that replaces the stale
|
||||
/// on-disk `registry.vdf` on Linux.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn running_app_id_from_environ() -> Option<u32> {
|
||||
let entries = std::fs::read_dir("/proc").ok()?;
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let Some(name) = name.to_str() else { continue };
|
||||
if !name.bytes().all(|b| b.is_ascii_digit()) {
|
||||
continue;
|
||||
}
|
||||
// Cap the read: an environ is small; this bounds a pathological case.
|
||||
if let Some(environ) = read_capped(&entry.path().join("environ"))
|
||||
&& let Some(id) = parse_steam_app_id_from_environ(environ.as_bytes())
|
||||
{
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod win {
|
||||
//! Windows registry reads via direct Win32 FFI (windows-sys), no `winreg`
|
||||
//! crate. Steam stores both the live `RunningAppID` and its install path under
|
||||
//! `HKCU\Software\Valve\Steam`.
|
||||
use super::{decode_reg_sz, validate_reg_len};
|
||||
use std::path::PathBuf;
|
||||
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
|
||||
use windows_sys::Win32::System::Registry::{
|
||||
RegCloseKey, RegOpenKeyExW, RegQueryValueExW, HKEY, HKEY_CURRENT_USER, KEY_READ,
|
||||
REG_DWORD, REG_SZ,
|
||||
};
|
||||
|
||||
/// UTF-16, NUL-terminated, for a Win32 wide-string argument.
|
||||
fn wide(s: &str) -> Vec<u16> {
|
||||
s.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
/// Open `HKCU\Software\Valve\Steam` for reading; `None` if absent.
|
||||
fn open_steam_key() -> Option<HKEY> {
|
||||
let subkey = wide("Software\\Valve\\Steam");
|
||||
let mut hkey: HKEY = std::ptr::null_mut();
|
||||
// SAFETY: valid HKEY constant, NUL-terminated subkey, out-param for the handle.
|
||||
let rc = unsafe {
|
||||
RegOpenKeyExW(HKEY_CURRENT_USER, subkey.as_ptr(), 0, KEY_READ, &mut hkey)
|
||||
};
|
||||
(rc == ERROR_SUCCESS).then_some(hkey)
|
||||
}
|
||||
|
||||
/// The live `RunningAppID` REG_DWORD, nonzero, or `None`.
|
||||
pub fn running_app_id() -> Option<u32> {
|
||||
let hkey = open_steam_key()?;
|
||||
let name = wide("RunningAppID");
|
||||
let mut kind: u32 = 0;
|
||||
let mut data: u32 = 0;
|
||||
let mut len = std::mem::size_of::<u32>() as u32;
|
||||
// SAFETY: out-params sized for a DWORD; data buffer is a u32 we own.
|
||||
let rc = unsafe {
|
||||
RegQueryValueExW(
|
||||
hkey,
|
||||
name.as_ptr(),
|
||||
std::ptr::null(),
|
||||
&mut kind,
|
||||
&mut data as *mut u32 as *mut u8,
|
||||
&mut len,
|
||||
)
|
||||
};
|
||||
// SAFETY: handle came from RegOpenKeyExW above.
|
||||
unsafe { RegCloseKey(hkey) };
|
||||
if rc == ERROR_SUCCESS && kind == REG_DWORD && data != 0 {
|
||||
Some(data)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The Steam install directory from `HKCU\...\Steam\SteamPath`, if it exists.
|
||||
pub fn install_path() -> Option<PathBuf> {
|
||||
let hkey = open_steam_key()?;
|
||||
let name = wide("SteamPath");
|
||||
let mut kind: u32 = 0;
|
||||
let mut len: u32 = 0;
|
||||
// First query the size.
|
||||
// SAFETY: null data ptr with a zeroed len asks for the required size.
|
||||
let rc = unsafe {
|
||||
RegQueryValueExW(
|
||||
hkey,
|
||||
name.as_ptr(),
|
||||
std::ptr::null(),
|
||||
&mut kind,
|
||||
std::ptr::null_mut(),
|
||||
&mut len,
|
||||
)
|
||||
};
|
||||
if rc != ERROR_SUCCESS || kind != REG_SZ {
|
||||
// SAFETY: valid handle.
|
||||
unsafe { RegCloseKey(hkey) };
|
||||
return None;
|
||||
}
|
||||
let Some(units) = validate_reg_len(len) else {
|
||||
// SAFETY: valid handle.
|
||||
unsafe { RegCloseKey(hkey) };
|
||||
return None;
|
||||
};
|
||||
let mut buf = vec![0u16; units];
|
||||
let mut len2 = len;
|
||||
// SAFETY: buffer sized to the queried byte length.
|
||||
let rc = unsafe {
|
||||
RegQueryValueExW(
|
||||
hkey,
|
||||
name.as_ptr(),
|
||||
std::ptr::null(),
|
||||
&mut kind,
|
||||
buf.as_mut_ptr() as *mut u8,
|
||||
&mut len2,
|
||||
)
|
||||
};
|
||||
// SAFETY: valid handle.
|
||||
unsafe { RegCloseKey(hkey) };
|
||||
if rc != ERROR_SUCCESS || kind != REG_SZ || len2 > len {
|
||||
return None;
|
||||
}
|
||||
Some(PathBuf::from(decode_reg_sz(buf, len2)?))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn registry_string_lengths_are_bounded_and_trimmed() {
|
||||
assert_eq!(validate_reg_len(5), None, "odd byte lengths are invalid UTF-16");
|
||||
assert_eq!(validate_reg_len(MAX_STEAM_PATH_BYTES + 2), None);
|
||||
assert_eq!(validate_reg_len(8), Some(4));
|
||||
|
||||
let raw = "C:\\Steam\0ignored".encode_utf16().collect::<Vec<_>>();
|
||||
let returned_bytes = ("C:\\Steam\0".encode_utf16().count() * 2) as u32;
|
||||
assert_eq!(decode_reg_sz(raw, returned_bytes).as_deref(), Some("C:\\Steam"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_app_id_reads_nonzero_and_rejects_zero() {
|
||||
let running = r#""Registry" { "HKCU" { "Software" { "Valve" { "Steam" {
|
||||
"RunningAppID" "440"
|
||||
} } } } }"#;
|
||||
assert_eq!(parse_running_app_id(running), Some(440));
|
||||
let idle = r#""Registry" { "HKCU" { "Software" { "Valve" { "Steam" {
|
||||
"RunningAppID" "0"
|
||||
} } } } }"#;
|
||||
assert_eq!(parse_running_app_id(idle), None);
|
||||
// Missing key / garbage → None, no panic.
|
||||
assert_eq!(parse_running_app_id(r#""Registry" { }"#), None);
|
||||
assert_eq!(parse_running_app_id("not vdf at all {{{"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_paths_handles_current_and_legacy_shapes() {
|
||||
let current = r#""libraryfolders" {
|
||||
"0" { "path" "/home/eric/.local/share/Steam" "label" "" }
|
||||
"1" { "path" "/mnt/games/SteamLibrary" }
|
||||
"contentstatsid" "12345"
|
||||
}"#;
|
||||
let got = parse_library_paths(current);
|
||||
assert_eq!(got, vec![
|
||||
PathBuf::from("/home/eric/.local/share/Steam"),
|
||||
PathBuf::from("/mnt/games/SteamLibrary"),
|
||||
]);
|
||||
|
||||
// Legacy shape: numeric keys map straight to path strings.
|
||||
let legacy = r#""LibraryFolders" {
|
||||
"TimeNextStatsReport" "9999"
|
||||
"ContentStatsID" "42"
|
||||
"1" "/mnt/old/SteamLibrary"
|
||||
}"#;
|
||||
let got = parse_library_paths(legacy);
|
||||
assert_eq!(got, vec![PathBuf::from("/mnt/old/SteamLibrary")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_paths_empty_on_garbage() {
|
||||
assert!(parse_library_paths("totally broken {{{").is_empty());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn steam_app_id_parsed_from_environ_blob() {
|
||||
// A realistic NUL-separated environ with SteamAppId among other vars.
|
||||
let environ = b"PATH=/usr/bin\0SteamAppId=440\0HOME=/home/x\0SteamGameId=440\0";
|
||||
assert_eq!(parse_steam_app_id_from_environ(environ), Some(440));
|
||||
// Nonzero requirement: SteamAppId=0 (the launcher itself) is ignored.
|
||||
assert_eq!(parse_steam_app_id_from_environ(b"SteamAppId=0\0FOO=bar\0"), None);
|
||||
// Absent → None (a non-Steam process).
|
||||
assert_eq!(parse_steam_app_id_from_environ(b"PATH=/usr/bin\0HOME=/home/x\0"), None);
|
||||
// Not fooled by a different var that merely contains the substring.
|
||||
assert_eq!(parse_steam_app_id_from_environ(b"MY_SteamAppId=999\0"), None);
|
||||
// Garbage value → None, no panic.
|
||||
assert_eq!(parse_steam_app_id_from_environ(b"SteamAppId=notanumber\0"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_name_extracts_and_filters_empty() {
|
||||
let acf = r#""AppState" { "appid" "440" "name" "Team Fortress 2" }"#;
|
||||
assert_eq!(parse_app_name(acf), Some("Team Fortress 2".to_string()));
|
||||
// Empty name → None (don't broadcast a blank).
|
||||
let blank = r#""AppState" { "appid" "440" "name" "" }"#;
|
||||
assert_eq!(parse_app_name(blank), None);
|
||||
// Missing name → None.
|
||||
assert_eq!(parse_app_name(r#""AppState" { "appid" "440" }"#), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
//! A small, defensive parser for Valve's KeyValues / VDF text format, used by
|
||||
//! `appmanifest_<appid>.acf`, `libraryfolders.vdf`, and `~/.steam/registry.vdf`.
|
||||
//!
|
||||
//! Pure (operates on already-read file *contents*) and unit-tested, per the
|
||||
//! testable-seams-first workflow — the file I/O and size caps live in the Steam
|
||||
//! adapter. Deliberately a real recursive-descent KeyValues parser rather than a
|
||||
//! `"name"`-line regex: escapes, nesting, and truncation will eventually break a
|
||||
//! regex (Codex's "use a real VDF parser" hardening). Hardened against hostile
|
||||
//! input with a recursion-depth cap, so a deeply nested file errors instead of
|
||||
//! overflowing the stack, and never panics on malformed/truncated input.
|
||||
|
||||
/// Max object nesting depth accepted before bailing out. Real Steam files nest a
|
||||
/// handful of levels (`registry.vdf` is the deepest at ~6); this is generous while
|
||||
/// still bounding a malicious file.
|
||||
const MAX_DEPTH: usize = 32;
|
||||
|
||||
/// A parsed KeyValues value: either a leaf string or a nested object. Child order
|
||||
/// is preserved and duplicate keys are kept (KeyValues permits them); lookups
|
||||
/// return the first match.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Value {
|
||||
Str(String),
|
||||
Obj(Vec<(String, Value)>),
|
||||
}
|
||||
|
||||
impl Value {
|
||||
/// The leaf string at this node, if it is a string (not an object).
|
||||
pub fn as_str(&self) -> Option<&str> {
|
||||
match self {
|
||||
Value::Str(s) => Some(s),
|
||||
Value::Obj(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The first child value under `key`, if this is an object containing it.
|
||||
/// Case-insensitive on the key (KeyValues keys are conventionally
|
||||
/// case-insensitive, and Steam is inconsistent, e.g. `AppState`/`appid`).
|
||||
pub fn get(&self, key: &str) -> Option<&Value> {
|
||||
match self {
|
||||
Value::Obj(pairs) => pairs
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case(key))
|
||||
.map(|(_, v)| v),
|
||||
Value::Str(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Follow a chain of object keys, returning the value at the end of the path.
|
||||
/// `root.get_path(&["AppState", "name"])`.
|
||||
pub fn get_path<'a>(&'a self, path: &[&str]) -> Option<&'a Value> {
|
||||
let mut cur = self;
|
||||
for key in path {
|
||||
cur = cur.get(key)?;
|
||||
}
|
||||
Some(cur)
|
||||
}
|
||||
|
||||
/// Iterate the (key, value) child pairs if this is an object.
|
||||
pub fn entries(&self) -> &[(String, Value)] {
|
||||
match self {
|
||||
Value::Obj(pairs) => pairs,
|
||||
Value::Str(_) => &[],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse KeyValues/VDF text into a top-level object (the sequence of root
|
||||
/// key→value pairs). Returns `Err` on unbalanced braces, a key with no value, or
|
||||
/// nesting past [`MAX_DEPTH`]. Never panics.
|
||||
pub fn parse(input: &str) -> Result<Value, String> {
|
||||
let mut lexer = Lexer { rest: input };
|
||||
let obj = parse_object(&mut lexer, 0, true)?;
|
||||
Ok(Value::Obj(obj))
|
||||
}
|
||||
|
||||
/// Parse a run of `key value` pairs. `top_level` parses until EOF; otherwise it
|
||||
/// parses until a closing `}` (which it consumes).
|
||||
fn parse_object(
|
||||
lexer: &mut Lexer,
|
||||
depth: usize,
|
||||
top_level: bool,
|
||||
) -> Result<Vec<(String, Value)>, String> {
|
||||
if depth > MAX_DEPTH {
|
||||
return Err("VDF nesting too deep".to_string());
|
||||
}
|
||||
let mut pairs = Vec::new();
|
||||
loop {
|
||||
match lexer.next_token()? {
|
||||
None => {
|
||||
if top_level {
|
||||
return Ok(pairs);
|
||||
}
|
||||
return Err("unexpected end of input inside object".to_string());
|
||||
}
|
||||
Some(Token::Close) => {
|
||||
if top_level {
|
||||
return Err("unexpected '}' at top level".to_string());
|
||||
}
|
||||
return Ok(pairs);
|
||||
}
|
||||
Some(Token::Open) => {
|
||||
return Err("expected key, found '{'".to_string());
|
||||
}
|
||||
Some(Token::Str(key)) => {
|
||||
// A key must be followed by a value: a string or a nested object.
|
||||
match lexer.next_token()? {
|
||||
Some(Token::Str(val)) => pairs.push((key, Value::Str(val))),
|
||||
Some(Token::Open) => {
|
||||
let child = parse_object(lexer, depth + 1, false)?;
|
||||
pairs.push((key, Value::Obj(child)));
|
||||
}
|
||||
Some(Token::Close) => {
|
||||
return Err(format!("key '{key}' has no value (found '}}')"));
|
||||
}
|
||||
None => return Err(format!("key '{key}' has no value (end of input)")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Token {
|
||||
Open,
|
||||
Close,
|
||||
Str(String),
|
||||
}
|
||||
|
||||
struct Lexer<'a> {
|
||||
rest: &'a str,
|
||||
}
|
||||
|
||||
impl Lexer<'_> {
|
||||
/// Produce the next token, skipping whitespace and `//` line comments.
|
||||
fn next_token(&mut self) -> Result<Option<Token>, String> {
|
||||
loop {
|
||||
self.rest = self.rest.trim_start();
|
||||
if self.rest.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
// Line comments: `//` to end of line.
|
||||
if let Some(after) = self.rest.strip_prefix("//") {
|
||||
match after.find('\n') {
|
||||
Some(nl) => self.rest = &after[nl + 1..],
|
||||
None => {
|
||||
self.rest = "";
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let mut chars = self.rest.char_indices();
|
||||
let (_, first) = chars.next().expect("non-empty checked above");
|
||||
return match first {
|
||||
'{' => {
|
||||
self.advance_bytes(first.len_utf8());
|
||||
Ok(Some(Token::Open))
|
||||
}
|
||||
'}' => {
|
||||
self.advance_bytes(first.len_utf8());
|
||||
Ok(Some(Token::Close))
|
||||
}
|
||||
'"' => self.lex_quoted(),
|
||||
_ => Ok(Some(self.lex_bareword())),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn advance_bytes(&mut self, n: usize) {
|
||||
self.rest = &self.rest[n..];
|
||||
}
|
||||
|
||||
/// Lex a `"..."` string, decoding `\\ \" \n \t` escapes. Errors if unterminated.
|
||||
fn lex_quoted(&mut self) -> Result<Option<Token>, String> {
|
||||
// Skip the opening quote.
|
||||
self.advance_bytes(1);
|
||||
let mut out = String::new();
|
||||
let mut chars = self.rest.char_indices();
|
||||
while let Some((i, c)) = chars.next() {
|
||||
match c {
|
||||
'"' => {
|
||||
// Consume through the closing quote.
|
||||
self.rest = &self.rest[i + 1..];
|
||||
return Ok(Some(Token::Str(out)));
|
||||
}
|
||||
'\\' => {
|
||||
// Decode the escape.
|
||||
match chars.next() {
|
||||
Some((_, esc)) => out.push(match esc {
|
||||
'n' => '\n',
|
||||
't' => '\t',
|
||||
'r' => '\r',
|
||||
// `\\`, `\"`, and anything else: take the literal char.
|
||||
other => other,
|
||||
}),
|
||||
None => return Err("unterminated escape in quoted string".to_string()),
|
||||
}
|
||||
}
|
||||
other => out.push(other),
|
||||
}
|
||||
}
|
||||
Err("unterminated quoted string".to_string())
|
||||
}
|
||||
|
||||
/// Lex an unquoted token: run of non-whitespace, non-brace, non-quote chars.
|
||||
fn lex_bareword(&mut self) -> Token {
|
||||
let end = self
|
||||
.rest
|
||||
.find(|c: char| c.is_whitespace() || matches!(c, '{' | '}' | '"'))
|
||||
.unwrap_or(self.rest.len());
|
||||
let word = self.rest[..end].to_string();
|
||||
self.rest = &self.rest[end..];
|
||||
Token::Str(word)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_appmanifest_name() {
|
||||
// A trimmed-down real appmanifest_<id>.acf.
|
||||
let acf = r#"
|
||||
"AppState"
|
||||
{
|
||||
"appid" "730"
|
||||
"name" "Counter-Strike 2"
|
||||
"StateFlags" "4"
|
||||
"installdir" "Counter-Strike Global Offensive"
|
||||
"UserConfig"
|
||||
{
|
||||
"language" "english"
|
||||
}
|
||||
}
|
||||
"#;
|
||||
let root = parse(acf).unwrap();
|
||||
assert_eq!(root.get_path(&["AppState", "name"]).and_then(Value::as_str), Some("Counter-Strike 2"));
|
||||
assert_eq!(root.get_path(&["AppState", "appid"]).and_then(Value::as_str), Some("730"));
|
||||
// Case-insensitive key lookup.
|
||||
assert_eq!(root.get_path(&["appstate", "NAME"]).and_then(Value::as_str), Some("Counter-Strike 2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_libraryfolders_paths_with_escaped_backslashes() {
|
||||
// Windows paths arrive with doubled backslashes (escaped).
|
||||
let vdf = r#"
|
||||
"libraryfolders"
|
||||
{
|
||||
"0"
|
||||
{
|
||||
"path" "C:\\Program Files (x86)\\Steam"
|
||||
"apps"
|
||||
{
|
||||
"730" "35000000000"
|
||||
}
|
||||
}
|
||||
"1"
|
||||
{
|
||||
"path" "/home/eric/.local/share/Steam"
|
||||
}
|
||||
}
|
||||
"#;
|
||||
let root = parse(vdf).unwrap();
|
||||
let lf = root.get("libraryfolders").unwrap();
|
||||
assert_eq!(lf.get_path(&["0", "path"]).and_then(Value::as_str), Some(r"C:\Program Files (x86)\Steam"));
|
||||
assert_eq!(lf.get_path(&["1", "path"]).and_then(Value::as_str), Some("/home/eric/.local/share/Steam"));
|
||||
// The library folder ids are iterable for discovery.
|
||||
let ids: Vec<&str> = lf.entries().iter().map(|(k, _)| k.as_str()).collect();
|
||||
assert_eq!(ids, vec!["0", "1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_registry_running_appid_deep_path() {
|
||||
let reg = r#"
|
||||
"Registry"
|
||||
{
|
||||
"HKCU"
|
||||
{
|
||||
"Software"
|
||||
{
|
||||
"Valve"
|
||||
{
|
||||
"Steam"
|
||||
{
|
||||
"RunningAppID" "570"
|
||||
"language" "english"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"#;
|
||||
let root = parse(reg).unwrap();
|
||||
let appid = root
|
||||
.get_path(&["Registry", "HKCU", "Software", "Valve", "Steam", "RunningAppID"])
|
||||
.and_then(Value::as_str);
|
||||
assert_eq!(appid, Some("570"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_comments_and_barewords() {
|
||||
let vdf = "// a comment\n\"root\"\n{\n\tbarekey barevalue // trailing\n}\n";
|
||||
let root = parse(vdf).unwrap();
|
||||
assert_eq!(root.get_path(&["root", "barekey"]).and_then(Value::as_str), Some("barevalue"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_without_panicking() {
|
||||
// Unbalanced braces.
|
||||
assert!(parse("\"a\" {").is_err());
|
||||
// Stray closing brace.
|
||||
assert!(parse("}").is_err());
|
||||
// Key with no value at EOF.
|
||||
assert!(parse("\"lonely\"").is_err());
|
||||
// Unterminated quoted string.
|
||||
assert!(parse("\"key\" \"unterminated").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_pathologically_deep_nesting() {
|
||||
// Build MAX_DEPTH+5 nested objects; must error, not overflow the stack.
|
||||
let mut s = String::new();
|
||||
for i in 0..(MAX_DEPTH + 5) {
|
||||
s.push_str(&format!("\"k{i}\" {{"));
|
||||
}
|
||||
for _ in 0..(MAX_DEPTH + 5) {
|
||||
s.push('}');
|
||||
}
|
||||
assert!(parse(&s).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_keys_return_none_not_error() {
|
||||
let root = parse("\"AppState\" { \"appid\" \"1\" }").unwrap();
|
||||
assert_eq!(root.get_path(&["AppState", "name"]), None);
|
||||
assert_eq!(root.get_path(&["Nope"]), None);
|
||||
// Treating a string as an object yields None rather than panicking.
|
||||
assert_eq!(root.get_path(&["AppState", "appid", "deeper"]), None);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ pub mod background;
|
||||
pub mod recents;
|
||||
pub mod discovery;
|
||||
pub mod hotkeys;
|
||||
pub mod files;
|
||||
pub mod game;
|
||||
|
||||
use std::fs::File;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -22,6 +22,15 @@ use crate::protocol::GOSSIP_SIG_DOMAIN;
|
||||
/// reasonable cross-peer clock skew without leaving a wide replay window.
|
||||
const GOSSIP_FRESHNESS_MS: u64 = 120_000;
|
||||
|
||||
/// Hard cap on an inbound gossip frame before it is deserialized. The largest
|
||||
/// legitimate payload is an `Announce` carrying a full custom avatar (≤48 KB
|
||||
/// base64, [`crate::avatar::CUSTOM_MAX_B64`]) plus the small presence/signature
|
||||
/// fields — about 49 KB on the wire. This cap sits comfortably above that while
|
||||
/// bounding the work/allocation a hostile peer can force: `serde_json::from_slice`
|
||||
/// allocates while parsing, so post-deserialize string caps do NOT prevent abuse —
|
||||
/// the size must be checked *before* parsing (security hardening, Codex find).
|
||||
const MAX_GOSSIP_FRAME_BYTES: usize = 128 * 1024;
|
||||
|
||||
/// A gossip message plus the authentication envelope that proves who sent it.
|
||||
/// `author` is the claimed sender (an `EndpointId`, which *is* an ed25519 public
|
||||
/// key); `sig` is that key's signature over [`signable_bytes`], so a forged
|
||||
@@ -124,12 +133,13 @@ fn admit_state_mutation(
|
||||
|
||||
fn peer_state_for_log(state: &PeerState) -> String {
|
||||
format!(
|
||||
"name={:?}, muted={}, addr_id={}, addrs={}, sharing={}",
|
||||
"name={:?}, muted={}, addr_id={}, addrs={}, sharing={}, game={:?}",
|
||||
state.name,
|
||||
state.is_muted,
|
||||
crate::short_id(&state.addr.id.to_string()),
|
||||
state.addr.addrs.len(),
|
||||
state.sharing.is_some()
|
||||
state.sharing.is_some(),
|
||||
state.game
|
||||
)
|
||||
}
|
||||
|
||||
@@ -184,9 +194,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 {
|
||||
@@ -336,6 +353,17 @@ impl RoomState for IrohGossipState {
|
||||
match res {
|
||||
Ok(iroh_gossip::api::Event::Received(msg)) => {
|
||||
crate::log_msg(&format!("Gossip received Event::Received from delivery={:?}", msg.delivered_from));
|
||||
// Reject oversized frames BEFORE deserializing: parsing
|
||||
// allocates, so a size check has to precede `from_slice` to
|
||||
// bound the memory a hostile peer can make us hold.
|
||||
if msg.content.len() > MAX_GOSSIP_FRAME_BYTES {
|
||||
crate::log_msg(&format!(
|
||||
"Gossip dropped oversized frame: {} bytes > {} cap",
|
||||
msg.content.len(),
|
||||
MAX_GOSSIP_FRAME_BYTES
|
||||
));
|
||||
continue;
|
||||
}
|
||||
match serde_json::from_slice::<GossipPayload>(&msg.content) {
|
||||
Ok(payload) => {
|
||||
// Authenticate before trusting `author` for ANY
|
||||
@@ -399,6 +427,15 @@ 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);
|
||||
// The game-presence label is untrusted
|
||||
// peer text like the name: sanitize +
|
||||
// length-cap at ingest (strip bidi/control,
|
||||
// 64-char/256-byte cap). An empty result
|
||||
// means "no game" rather than a blank label.
|
||||
state.game = state.game.and_then(|g| {
|
||||
let cleaned = crate::sanitize::sanitize_game_label(&g);
|
||||
(!cleaned.is_empty()).then_some(cleaned)
|
||||
});
|
||||
disconnected_peers.lock().unwrap().remove(&payload.author);
|
||||
let (is_new, state_changed) = {
|
||||
let mut peer_map = peers.lock().unwrap();
|
||||
@@ -438,13 +475,24 @@ impl RoomState for IrohGossipState {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -565,7 +613,11 @@ impl RoomState for IrohGossipState {
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_chat(&self, text: String) -> Result<(), NetError> {
|
||||
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() {
|
||||
@@ -582,7 +634,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
|
||||
@@ -654,6 +706,7 @@ mod tests {
|
||||
addr,
|
||||
sharing: None,
|
||||
avatar: crate::avatar::Avatar::default(),
|
||||
game: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -665,6 +718,16 @@ mod tests {
|
||||
EndpointAddr::from(id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_state_log_includes_game() {
|
||||
let mut state = sample_peer_state_for(fresh_id());
|
||||
state.game = Some("Half-Life 2".to_string());
|
||||
assert!(peer_state_for_log(&state).contains("game=Some(\"Half-Life 2\")"));
|
||||
|
||||
state.game = None;
|
||||
assert!(peer_state_for_log(&state).contains("game=None"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_client_dials_host() {
|
||||
// A non-host (client) with no retained peers dials just the ticket host.
|
||||
@@ -744,13 +807,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");
|
||||
}
|
||||
@@ -760,10 +825,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);
|
||||
@@ -772,6 +838,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();
|
||||
@@ -784,6 +884,7 @@ mod tests {
|
||||
name: "Bob".to_string(),
|
||||
text: "Hi there".to_string(),
|
||||
ts: 987654321,
|
||||
attachment: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -791,7 +892,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);
|
||||
@@ -806,10 +907,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);
|
||||
@@ -849,7 +951,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)
|
||||
@@ -922,8 +1024,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));
|
||||
|
||||
@@ -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,97 @@ impl iroh::protocol::ProtocolHandler for AudioRouter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Protocol handler for the file-transfer plane (`FILES_ALPN`). Mirrors
|
||||
/// [`AudioRouter`]: it's persistent on the router and bound to the active
|
||||
/// session's [`Shared`] on join. On an inbound stream it authenticates the peer
|
||||
/// (iroh ALPN handshake gives us `remote_id`), gates on **live room membership**
|
||||
/// (same invariant as audio admission, so a former member can't pull files),
|
||||
/// reads a single 32-byte attachment id, and streams back the matching blob from
|
||||
/// the session serve store — or nothing if the id is unknown.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct FileRouter {
|
||||
current: Arc<StdMutex<Option<Arc<Shared>>>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for FileRouter {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("FileRouter").finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl FileRouter {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Route inbound file fetches to `transport`'s session (called on join).
|
||||
pub fn bind(&self, transport: &IrohTransport) {
|
||||
*self.current.lock().unwrap() = Some(transport.shared.clone());
|
||||
}
|
||||
|
||||
/// Stop serving files until the next [`bind`](Self::bind) (called on leave).
|
||||
pub fn clear(&self) {
|
||||
*self.current.lock().unwrap() = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Max bytes we'll read for a fetch *request* frame. A request is exactly one
|
||||
/// 32-byte id; this small ceiling rejects a peer trying to stream us a huge
|
||||
/// "request" as a cheap DoS.
|
||||
const FILE_REQUEST_MAX: usize = 64;
|
||||
|
||||
impl iroh::protocol::ProtocolHandler for FileRouter {
|
||||
fn accept(
|
||||
&self,
|
||||
connection: Connection,
|
||||
) -> impl std::future::Future<Output = Result<(), iroh::protocol::AcceptError>> + Send {
|
||||
let peer_id = connection.remote_id();
|
||||
let shared = self.current.lock().unwrap().clone();
|
||||
async move {
|
||||
// No active call → nothing to serve.
|
||||
let Some(shared) = shared else {
|
||||
return Ok(());
|
||||
};
|
||||
// Member gating: only current room members may fetch our files. Reuses
|
||||
// the audio admission roster (the authoritative room membership set).
|
||||
if !shared.audio_sender_admitted(peer_id) {
|
||||
crate::log_msg(&format!(
|
||||
"Transport: rejected file fetch from non-member {}",
|
||||
crate::short_id(&peer_id.to_string())
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
// Accept one bidirectional stream: read the id, write the bytes.
|
||||
let Ok((mut send, mut recv)) = connection.accept_bi().await else {
|
||||
return Ok(());
|
||||
};
|
||||
let Ok(req) = recv.read_to_end(FILE_REQUEST_MAX).await else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(id) = crate::files::parse_request(&req) else {
|
||||
return Ok(());
|
||||
};
|
||||
let blob = shared.served_files.lock().unwrap().get(&id).cloned();
|
||||
if let Some(blob) = blob {
|
||||
let _ = send.write_all(&blob).await;
|
||||
}
|
||||
// Finish either way: an unknown id closes with an empty body, which
|
||||
// the fetcher reads as a zero-length result and treats as "gone".
|
||||
let _ = send.finish();
|
||||
// CRITICAL: `finish()` only marks the stream's EOF — it does NOT wait
|
||||
// for the written bytes to be delivered and acknowledged. If we return
|
||||
// here the `connection` drops, and its CONNECTION_CLOSE can race ahead
|
||||
// of the still-in-flight stream data, so the fetcher's read aborts with
|
||||
// "connection lost". Wait for the fetcher to receive everything and
|
||||
// close the connection itself (it drops `conn` right after read_to_end);
|
||||
// that close is our signal the transfer landed. Bounded so a fetcher
|
||||
// that vanishes can't pin this task forever.
|
||||
let _ = tokio::time::timeout(FILE_FETCH_TIMEOUT, connection.closed()).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct IrohTransport {
|
||||
shared: Arc<Shared>,
|
||||
incoming_rx: tokio::sync::Mutex<Option<mpsc::Receiver<(EndpointId, Bytes)>>>,
|
||||
@@ -426,6 +527,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 +557,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 +586,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]
|
||||
|
||||
@@ -39,6 +39,58 @@ pub struct PeerState {
|
||||
/// peers/configs that predate the field still deserialize (→ monogram).
|
||||
#[serde(default)]
|
||||
pub avatar: crate::avatar::Avatar,
|
||||
/// The game this peer is currently playing, as a display string only (shown as
|
||||
/// `Playing <name>` next to their avatar). Opt-in and **untrusted** like
|
||||
/// `name`: sanitized + length-capped at the gossip ingest boundary. `None` when
|
||||
/// the peer isn't sharing a game (feature off / nothing detected). Only the
|
||||
/// display string rides the wire — never the appid or detection source, to
|
||||
/// avoid fingerprinting and coupling the protocol to detector internals.
|
||||
/// Defaulted so peers/configs predating the field still deserialize.
|
||||
#[serde(default)]
|
||||
pub game: Option<String>,
|
||||
}
|
||||
|
||||
/// The locally-owned, "sticky" pieces of our own presence: the identity fields
|
||||
/// that change only on explicit user action and persist for the whole core
|
||||
/// session. The remaining `PeerState` fields are *volatile* — mute state, current
|
||||
/// `addr`, and the active screen-share ticket are read fresh at each announce — so
|
||||
/// they are passed into [`SelfPresence::to_state`] rather than stored here.
|
||||
///
|
||||
/// This is the single source of truth for building our own `PeerState`: core
|
||||
/// reconstructs self-state in several command branches (join, mute toggle, avatar
|
||||
/// change, screen-share start/stop), and centralizing the `PeerState` literal here
|
||||
/// means a new presence field is added in exactly one place instead of at every
|
||||
/// call site.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SelfPresence {
|
||||
pub name: String,
|
||||
pub avatar: crate::avatar::Avatar,
|
||||
/// The display label of the game we're currently broadcasting, or `None` when
|
||||
/// game presence is off / nothing is detected. Already sanitized + capped
|
||||
/// (see `crate::sanitize::sanitize_game_label`) before being stored here, so
|
||||
/// the outgoing announce carries a safe value.
|
||||
pub game: Option<String>,
|
||||
}
|
||||
|
||||
impl SelfPresence {
|
||||
/// Combine the sticky identity fields with the volatile per-announce fields
|
||||
/// (`is_muted`, current `addr`, active-share `sharing` ticket) into a full
|
||||
/// `PeerState` ready to announce over the gossip presence plane.
|
||||
pub fn to_state(
|
||||
&self,
|
||||
is_muted: bool,
|
||||
addr: iroh::EndpointAddr,
|
||||
sharing: Option<String>,
|
||||
) -> PeerState {
|
||||
PeerState {
|
||||
name: self.name.clone(),
|
||||
is_muted,
|
||||
addr,
|
||||
sharing,
|
||||
avatar: self.avatar.clone(),
|
||||
game: self.game.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -57,7 +109,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
|
||||
@@ -206,8 +266,13 @@ pub trait RoomState: Send + Sync {
|
||||
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>;
|
||||
@@ -237,6 +302,7 @@ mod tests {
|
||||
addr,
|
||||
sharing: None,
|
||||
avatar: crate::avatar::Avatar::default(),
|
||||
game: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,6 +411,29 @@ mod tests {
|
||||
assert_eq!(PeerSpeakTicket::restamp("not-a-ticket", EndpointAddr::from(me)), "not-a-ticket");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_presence_builds_peer_state_with_volatile_fields() {
|
||||
let addr = EndpointAddr::from(SecretKey::generate().public());
|
||||
let presence = SelfPresence {
|
||||
name: "Alice".to_string(),
|
||||
avatar: crate::avatar::Avatar::default(),
|
||||
game: Some("Half-Life 2".to_string()),
|
||||
};
|
||||
// Volatile fields come from the call; sticky fields from the struct.
|
||||
let muted = presence.to_state(true, addr.clone(), Some("ticket".to_string()));
|
||||
assert_eq!(muted.name, "Alice");
|
||||
assert!(muted.is_muted);
|
||||
assert_eq!(muted.addr.id, addr.id);
|
||||
assert_eq!(muted.sharing.as_deref(), Some("ticket"));
|
||||
assert_eq!(muted.avatar, crate::avatar::Avatar::default());
|
||||
assert_eq!(muted.game.as_deref(), Some("Half-Life 2"));
|
||||
// The same sticky presence yields different volatile fields per announce.
|
||||
let unmuted = presence.to_state(false, addr.clone(), None);
|
||||
assert!(!unmuted.is_muted);
|
||||
assert_eq!(unmuted.sharing, None);
|
||||
assert_eq!(unmuted.name, muted.name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_peer_state_serde_round_trip() {
|
||||
let original = sample_peer_state();
|
||||
|
||||
@@ -22,16 +22,32 @@ 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.
|
||||
///
|
||||
/// v3 (0.4.0): `PeerState` gained an optional `game` presence field (the
|
||||
/// `Playing <name>` status). The field is `#[serde(default)]`, so the bump isn't
|
||||
/// strictly required for decoding — but per the versioning discipline a wire-shape
|
||||
/// change is isolated into its own topic + signature domain so v2 and v3 peers
|
||||
/// never share a swarm. Resync everyone, exactly like the W4 avatar bump.
|
||||
pub const GOSSIP_PROTO: u32 = 3;
|
||||
/// 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-v3";
|
||||
|
||||
/// Version-namespace a room topic so peers on different gossip protocol versions
|
||||
/// derive **different subscription topics from the same ticket** and therefore
|
||||
@@ -62,6 +78,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}"));
|
||||
}
|
||||
|
||||
|
||||
@@ -27,19 +27,50 @@ fn is_spoofing_format_char(c: char) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Max characters kept for a broadcast game-presence label after sanitizing
|
||||
/// (game titles run longer than nicknames, so a wider cap than [`NAME_MAX_CHARS`]),
|
||||
/// bounded additionally by [`GAME_LABEL_MAX_BYTES`] so a multibyte-heavy string
|
||||
/// can't blow the presence frame.
|
||||
pub const GAME_LABEL_MAX_CHARS: usize = 64;
|
||||
/// Max UTF-8 bytes kept for a broadcast game-presence label, applied on top of
|
||||
/// [`GAME_LABEL_MAX_CHARS`]. Caps the on-wire size regardless of scalar width.
|
||||
pub const GAME_LABEL_MAX_BYTES: usize = 256;
|
||||
|
||||
/// Shared cleaning for untrusted short labels: strip bidi / zero-width spoofing
|
||||
/// format characters, turn control characters into spaces, collapse any whitespace
|
||||
/// run to a single space, and trim the ends. Length capping is the caller's job.
|
||||
fn clean_label(input: &str) -> String {
|
||||
let cleaned: String = input
|
||||
.chars()
|
||||
.filter(|c| !is_spoofing_format_char(*c))
|
||||
.map(|c| if c.is_control() { ' ' } else { c })
|
||||
.collect();
|
||||
cleaned.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// Sanitize an untrusted peer display name for safe rendering. Strips bidi /
|
||||
/// zero-width format characters, turns control characters into spaces, collapses
|
||||
/// any whitespace run to a single space, trims the ends, and caps the length at
|
||||
/// [`NAME_MAX_CHARS`]. Returns `""` if nothing usable remains (callers may
|
||||
/// substitute a placeholder such as a short id).
|
||||
pub fn sanitize_name(input: &str) -> String {
|
||||
let cleaned: String = input
|
||||
.chars()
|
||||
.filter(|c| !is_spoofing_format_char(*c))
|
||||
.map(|c| if c.is_control() { ' ' } else { c })
|
||||
.collect();
|
||||
let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
collapsed.chars().take(NAME_MAX_CHARS).collect()
|
||||
clean_label(input).chars().take(NAME_MAX_CHARS).collect()
|
||||
}
|
||||
|
||||
/// Sanitize an untrusted game-presence label (the `Playing <name>` status that
|
||||
/// rides the gossip presence plane). Same spoof/control cleaning as
|
||||
/// [`sanitize_name`], but capped at [`GAME_LABEL_MAX_CHARS`] scalars AND
|
||||
/// [`GAME_LABEL_MAX_BYTES`] bytes. Apply on BOTH the outgoing label we detect and
|
||||
/// any incoming peer label. Returns `""` if nothing usable remains (no broadcast).
|
||||
pub fn sanitize_game_label(input: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for c in clean_label(input).chars().take(GAME_LABEL_MAX_CHARS) {
|
||||
if out.len() + c.len_utf8() > GAME_LABEL_MAX_BYTES {
|
||||
break;
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A piece of a chat message after URL detection: literal text or a link.
|
||||
@@ -135,6 +166,46 @@ mod tests {
|
||||
assert_eq!(sanitize_name(&long).chars().count(), NAME_MAX_CHARS);
|
||||
}
|
||||
|
||||
// --- sanitize_game_label ----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn game_label_keeps_ordinary_titles_and_strips_spoofing() {
|
||||
assert_eq!(sanitize_game_label("Half-Life 2"), "Half-Life 2");
|
||||
// Same spoof/control cleaning as names.
|
||||
assert_eq!(sanitize_game_label("Doom\u{202E}txt"), "Doomtxt");
|
||||
assert_eq!(sanitize_game_label("a\u{0}b\r\nc"), "a b c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_label_caps_chars_wider_than_names() {
|
||||
// A game label keeps more than a name's 48 (up to 64), so a title between
|
||||
// the two caps survives in full.
|
||||
let mid = "g".repeat(56);
|
||||
assert_eq!(sanitize_game_label(&mid).chars().count(), 56);
|
||||
let long = "g".repeat(GAME_LABEL_MAX_CHARS + 100);
|
||||
assert_eq!(sanitize_game_label(&long).chars().count(), GAME_LABEL_MAX_CHARS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_label_caps_bytes_for_multibyte_titles() {
|
||||
// Each '世' is 3 bytes; 64 of them = 192 bytes (under 256) → all kept.
|
||||
let cjk = "世".repeat(GAME_LABEL_MAX_CHARS);
|
||||
let out = sanitize_game_label(&cjk);
|
||||
assert_eq!(out.chars().count(), GAME_LABEL_MAX_CHARS);
|
||||
assert!(out.len() <= GAME_LABEL_MAX_BYTES);
|
||||
// Emoji are 4 bytes; the byte cap bites before the char cap (256/4 = 64,
|
||||
// but the leading clean keeps them as a run) — never exceeds the byte cap.
|
||||
let emoji = "🎮".repeat(GAME_LABEL_MAX_CHARS);
|
||||
let out = sanitize_game_label(&emoji);
|
||||
assert!(out.len() <= GAME_LABEL_MAX_BYTES);
|
||||
assert!(out.chars().all(|c| c == '🎮'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_label_empty_when_nothing_usable() {
|
||||
assert_eq!(sanitize_game_label("\u{0}\r\n\t "), "");
|
||||
}
|
||||
|
||||
// --- linkify -----------------------------------------------------------
|
||||
|
||||
/// Concatenating every segment's inner text must reproduce the input exactly.
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
//! End-to-end loopback test for the chat file-transfer plane (`FILES_ALPN`).
|
||||
//!
|
||||
//! Spins up two real iroh endpoints on localhost (relay disabled, addresses
|
||||
//! exchanged directly), registers the production [`FileRouter`] on each, serves a
|
||||
//! multi-megabyte blob on one side, and fetches it from the other through the
|
||||
//! real `serve_attachment`/`fetch_attachment` path.
|
||||
//!
|
||||
//! This is the regression guard for the "file fetch: read failed: connection
|
||||
//! lost" bug: the serve handler used to return (and drop the connection) the
|
||||
//! instant it called `finish()`, so the CONNECTION_CLOSE raced ahead of the
|
||||
//! still-in-flight stream data and the fetcher's `read_to_end` aborted. A blob
|
||||
//! large enough to span many packets makes that race deterministic — the fix
|
||||
//! (waiting on `connection.closed()` before returning) keeps the link up until
|
||||
//! the fetcher has the bytes.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use iroh::address_lookup::memory::MemoryLookup;
|
||||
use iroh::endpoint::presets;
|
||||
use iroh::protocol::Router;
|
||||
use iroh::{Endpoint, RelayMode};
|
||||
|
||||
use peerspeak::files::{ChatAttachment, AttachmentKind};
|
||||
use peerspeak::network::NetworkTransport;
|
||||
use peerspeak::network::iroh_impl::{FileRouter, IrohTransport};
|
||||
use peerspeak::protocol::FILES_ALPN;
|
||||
|
||||
struct Node {
|
||||
endpoint: Endpoint,
|
||||
transport: Arc<IrohTransport>,
|
||||
_router: Router,
|
||||
lookup: MemoryLookup,
|
||||
}
|
||||
|
||||
async fn spawn_node() -> Node {
|
||||
let lookup = MemoryLookup::new();
|
||||
let endpoint = Endpoint::builder(presets::Minimal)
|
||||
.secret_key(iroh::SecretKey::generate())
|
||||
.relay_mode(RelayMode::Disabled)
|
||||
.address_lookup(lookup.clone())
|
||||
.bind()
|
||||
.await
|
||||
.expect("bind endpoint");
|
||||
|
||||
let transport = Arc::new(IrohTransport::new(endpoint.clone()));
|
||||
// Mirror production: a persistent FileRouter bound to this session's transport
|
||||
// is what the router accepts inbound file fetches on.
|
||||
let file_router = FileRouter::new();
|
||||
file_router.bind(&transport);
|
||||
let router = Router::builder(endpoint.clone())
|
||||
.accept(FILES_ALPN, file_router)
|
||||
.spawn();
|
||||
|
||||
Node { endpoint, transport, _router: router, lookup }
|
||||
}
|
||||
|
||||
/// A pseudo-random-ish multi-megabyte payload spanning many QUIC packets, so a
|
||||
/// premature connection close on the serve side reliably corrupts/aborts the read.
|
||||
fn big_blob() -> Vec<u8> {
|
||||
(0..(2 * 1024 * 1024u32))
|
||||
.map(|i| (i.wrapping_mul(2654435761) >> 13) as u8)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn loopback_attachment_round_trips_intact() {
|
||||
let server = spawn_node().await;
|
||||
let client = spawn_node().await;
|
||||
|
||||
// Seed each side with the other's full address so direct dialing works.
|
||||
server.lookup.add_endpoint_info(client.endpoint.addr());
|
||||
client.lookup.add_endpoint_info(server.endpoint.addr());
|
||||
|
||||
let server_id = server.endpoint.id();
|
||||
let client_id = client.endpoint.id();
|
||||
|
||||
// The serve handler gates on room membership (the audio admission roster), so
|
||||
// the server must admit the client before it will answer the fetch.
|
||||
server.transport.admit_audio_sender(client_id);
|
||||
client.transport.admit_audio_sender(server_id);
|
||||
|
||||
// The fetcher dials the retained full address; seed it so fetch_attachment
|
||||
// doesn't have to fall back to a bare-id lookup.
|
||||
client.transport.connect_peer(server.endpoint.addr()).await;
|
||||
|
||||
let blob = big_blob();
|
||||
let id = [42u8; 32];
|
||||
server.transport.serve_attachment(id, Arc::new(blob.clone()));
|
||||
|
||||
let att = ChatAttachment {
|
||||
name: "exterior-landscape.jpg".to_string(),
|
||||
size: blob.len() as u64,
|
||||
kind: AttachmentKind::Image,
|
||||
id,
|
||||
};
|
||||
|
||||
let fetched = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
client.transport.fetch_attachment(server_id, &att),
|
||||
)
|
||||
.await
|
||||
.expect("fetch did not time out")
|
||||
.expect("fetch succeeded");
|
||||
|
||||
assert_eq!(fetched.len(), blob.len(), "fetched the full blob");
|
||||
assert_eq!(fetched, blob, "fetched bytes match served bytes exactly");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn loopback_unknown_id_reports_gone() {
|
||||
let server = spawn_node().await;
|
||||
let client = spawn_node().await;
|
||||
|
||||
server.lookup.add_endpoint_info(client.endpoint.addr());
|
||||
client.lookup.add_endpoint_info(server.endpoint.addr());
|
||||
|
||||
let server_id = server.endpoint.id();
|
||||
let client_id = client.endpoint.id();
|
||||
server.transport.admit_audio_sender(client_id);
|
||||
client.transport.admit_audio_sender(server_id);
|
||||
client.transport.connect_peer(server.endpoint.addr()).await;
|
||||
|
||||
// Never served — the handler closes with an empty body and the fetcher must
|
||||
// surface that as an error, not hang or return empty bytes.
|
||||
let att = ChatAttachment {
|
||||
name: "missing.bin".to_string(),
|
||||
size: 4096,
|
||||
kind: AttachmentKind::File,
|
||||
id: [7u8; 32],
|
||||
};
|
||||
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
client.transport.fetch_attachment(server_id, &att),
|
||||
)
|
||||
.await
|
||||
.expect("fetch did not time out");
|
||||
|
||||
assert!(result.is_err(), "unknown id should error, got {result:?}");
|
||||
}
|
||||
@@ -63,6 +63,7 @@ fn state(name: &str, addr: EndpointAddr) -> PeerState {
|
||||
addr,
|
||||
sharing: None,
|
||||
avatar: Default::default(),
|
||||
game: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||