Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6054f0ecf9 | ||
|
|
c2e27c3367 | ||
|
|
3c678afaf7 | ||
|
|
8cfb5beff9 | ||
|
|
f21a027e78 | ||
|
|
663956deaa | ||
|
|
abc8531cfa | ||
|
|
f0961a2049 | ||
|
|
32ee00178e | ||
|
|
7d9ffbd3a4 | ||
|
|
01150ff249 | ||
|
|
a6d9a8cbd4 | ||
|
|
a4bb6ce0be | ||
|
|
ebfc39de46 | ||
|
|
e3ff778d5b | ||
|
|
9a059e1bb8 | ||
|
|
4dc1bcd546 | ||
|
|
067997f9ba | ||
|
|
660eb27a84 | ||
|
|
913b0b6b20 | ||
|
|
36fb8bfa9a | ||
|
|
2e9164745f | ||
|
|
3b640726d7 | ||
|
|
381e00bc0e | ||
|
|
1a3c481f4c | ||
|
|
f927567105 | ||
|
|
5c11947bd7 | ||
|
|
7349744d16 | ||
|
|
a6a88d15c0 | ||
|
|
a17b930524 | ||
|
|
6100abef33 | ||
|
|
49bd2ba687 | ||
|
|
6b0b23ef69 | ||
|
|
f422150c84 | ||
|
|
86d333d4dc | ||
|
|
fad65a4fcf | ||
|
|
3878e716dd | ||
|
|
961705ffa9 | ||
|
|
7d44808a5e | ||
|
|
e31d3db986 | ||
|
|
87a2209a85 | ||
|
|
9e8c8b4ace |
@@ -0,0 +1,33 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to PeerSpeak are documented here.
|
||||
|
||||
## [0.5.1] — 2026-06-27
|
||||
|
||||
### Added
|
||||
- **Volume control for inline chat audio clips.** A master volume slider plus a **Universal volume** toggle now sit in the Chat panel header, and every audio attachment gets its own 🔊 slider on its play row. With Universal volume on (the default), one level applies to all clips and persists across sessions; turn it off to give each clip its own independent level.
|
||||
|
||||
## [0.5.0] — 2026-06-27
|
||||
|
||||
### Added
|
||||
- **Right-click context menu** (Cut / Copy / Paste / Select All) on every text-entry field. (A9)
|
||||
- **Selectable, copyable text** for values that used to be read-only: your full node ID and the room ticket can now be click-selected and copied, and **chat messages are drag-selectable** (highlight + Ctrl+C / Ctrl+A) while clickable links keep working. (W21 Phase 1 + 2)
|
||||
- **Clock-skew warning**: when a peer can't be seen because the two systems' clocks differ by more than the replay-protection window, PeerSpeak now shows a "your clocks are out of sync" banner instead of failing silently. (A25)
|
||||
- **Per-application audio capture for screen-share**, removing the call-audio loopback echo when sharing a window, plus surfacing of pixelpass startup errors. (A23)
|
||||
|
||||
### Changed / Fixed
|
||||
- **Critical commands are now delivered reliably under load** — muting, releasing push-to-talk, and leaving a room can no longer be silently dropped while a slider is being dragged (prevents a hot-mic state mismatch). (A15)
|
||||
- Screen-share now passes `--strict-audio` and surfaces app-audio drop warnings; the source picker state machine and pactl handling were hardened. (A23)
|
||||
- Per-peer volume control path verified and covered by regression tests. (A24)
|
||||
|
||||
### Security
|
||||
- Hardened against insider resource-exhaustion: bounded + author-keyed chat attachment cache, capped recovery-identity state, and other Tier-C caps (F-01 / F-02 / F-03 / F-12).
|
||||
|
||||
### Packaging
|
||||
- Added Debian/Ubuntu `.deb` packaging (cargo-deb metadata); the official `.deb` is now built on **Debian 12 (bookworm)** for wide compatibility.
|
||||
- Arch `PKGBUILD` clones over anonymous HTTPS.
|
||||
|
||||
[0.5.1]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.5.1
|
||||
[0.5.0]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.5.0
|
||||
|
||||
Earlier releases: see git tags `v0.4.0`, `v0.3.0`, `v0.2.0`.
|
||||
Generated
+2
-1
@@ -4871,7 +4871,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "peerspeak"
|
||||
version = "0.3.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -4894,6 +4894,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+40
-2
@@ -1,11 +1,40 @@
|
||||
[package]
|
||||
name = "peerspeak"
|
||||
version = "0.3.0"
|
||||
version = "0.5.1"
|
||||
edition = "2024"
|
||||
description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
||||
# Application crate, not a crates.io library — refuse `cargo publish` and let
|
||||
# cargo-deny's [licenses.private] skip the missing-license check.
|
||||
publish = false
|
||||
|
||||
# Debian/Ubuntu packaging (cargo-deb). Mirrors packaging/PKGBUILD: only the main
|
||||
# `peerspeak` binary ships (not test_net/specview), plus the desktop entry and the
|
||||
# hicolor icon set. Runtime shared-lib deps (libpipewire, libopus, libc, …) are
|
||||
# resolved by dpkg-shlibdeps via `depends = "$auto"`. Build inside a Debian/Ubuntu
|
||||
# distrobox so the binary links that distro's glibc, then `cargo deb --no-build`.
|
||||
[package.metadata.deb]
|
||||
maintainer = "mollusk <jitty+lc1iz0dc@protonmail.com>"
|
||||
copyright = "2026, mollusk. Private build — not for redistribution."
|
||||
section = "net"
|
||||
priority = "optional"
|
||||
depends = "$auto"
|
||||
# pixelpass = in-room screen sharing; mpv = the screen-share viewer (vlc fallback).
|
||||
recommends = "pixelpass, mpv"
|
||||
extended-description = "Decentralized peer-to-peer voice chat over iroh (QUIC) with PipeWire audio, the Opus codec, and an iced GUI. Full-mesh, no central server."
|
||||
assets = [
|
||||
["target/release/peerspeak", "usr/bin/", "755"],
|
||||
["packaging/peerspeak.desktop", "usr/share/applications/", "644"],
|
||||
["assets/icons/peerspeak.svg", "usr/share/icons/hicolor/scalable/apps/peerspeak.svg", "644"],
|
||||
["assets/icons/peerspeak-16.png", "usr/share/icons/hicolor/16x16/apps/peerspeak.png", "644"],
|
||||
["assets/icons/peerspeak-24.png", "usr/share/icons/hicolor/24x24/apps/peerspeak.png", "644"],
|
||||
["assets/icons/peerspeak-32.png", "usr/share/icons/hicolor/32x32/apps/peerspeak.png", "644"],
|
||||
["assets/icons/peerspeak-48.png", "usr/share/icons/hicolor/48x48/apps/peerspeak.png", "644"],
|
||||
["assets/icons/peerspeak-64.png", "usr/share/icons/hicolor/64x64/apps/peerspeak.png", "644"],
|
||||
["assets/icons/peerspeak-128.png", "usr/share/icons/hicolor/128x128/apps/peerspeak.png", "644"],
|
||||
["assets/icons/peerspeak-256.png", "usr/share/icons/hicolor/256x256/apps/peerspeak.png", "644"],
|
||||
["assets/icons/peerspeak-512.png", "usr/share/icons/hicolor/512x512/apps/peerspeak.png", "644"],
|
||||
]
|
||||
|
||||
[lib]
|
||||
name = "peerspeak"
|
||||
path = "src/lib.rs"
|
||||
@@ -28,7 +57,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", "tokio"] }
|
||||
iced = { version = "0.14.0", features = ["advanced", "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).
|
||||
@@ -65,3 +94,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",
|
||||
] }
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
|
||||
pkgname=peerspeak-git
|
||||
_pkgname=peerspeak
|
||||
pkgver=0.3.0.r229.g7fb1c96
|
||||
pkgver=0.5.0.r0.g0000000
|
||||
pkgrel=1
|
||||
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
||||
arch=('x86_64')
|
||||
@@ -14,7 +14,7 @@ optdepends=('pixelpass: screen sharing inside a room'
|
||||
provides=('peerspeak')
|
||||
conflicts=('peerspeak')
|
||||
options=('!lto' '!debug')
|
||||
source=("$_pkgname::git+ssh://git@gitbutter.xyz/mollusk/peerspeak.git")
|
||||
source=("$_pkgname::git+https://gitbutter.xyz/mollusk/peerspeak.git")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
pkgver() {
|
||||
|
||||
@@ -8,7 +8,7 @@ it once, then you and I connect directly to each other.
|
||||
|
||||
## 1. Install it
|
||||
|
||||
1. Double-click **`peerspeak-0.3.0-setup.exe`** (the file I sent you).
|
||||
1. Double-click **`peerspeak-0.4.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
|
||||
|
||||
@@ -12,7 +12,7 @@ runtime, so there are no extra DLLs to bundle. The installer payload is just the
|
||||
## 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.
|
||||
**0.4.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
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
|
||||
|
||||
#define MyAppName "PeerSpeak"
|
||||
#define MyAppVersion "0.3.0"
|
||||
#define MyAppVersion "0.5.0"
|
||||
#define MyAppPublisher "mollusk"
|
||||
#define MyAppExeName "peerspeak.exe"
|
||||
|
||||
|
||||
+1779
-193
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,7 @@ enum ClipCommand {
|
||||
Resume,
|
||||
Seek(Duration),
|
||||
Stop,
|
||||
SetVolume(f32),
|
||||
}
|
||||
|
||||
/// Cheap, `Send` command handle for the dedicated playback thread.
|
||||
@@ -51,13 +52,16 @@ pub struct ClipPlayer {
|
||||
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) {
|
||||
///
|
||||
/// `initial_volume` is the universal gain (`1.0` = unity) applied to every
|
||||
/// clip, restored from config so the level persists across sessions.
|
||||
pub fn new(initial_volume: f32) -> (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))
|
||||
.spawn(move || playback_worker(command_rx, worker_status, initial_volume))
|
||||
.expect("failed to spawn clip playback thread");
|
||||
(
|
||||
Self {
|
||||
@@ -94,11 +98,24 @@ impl ClipPlayer {
|
||||
pub fn stop(&self) {
|
||||
let _ = self.command_tx.send(ClipCommand::Stop);
|
||||
}
|
||||
|
||||
/// Set the universal playback gain (`1.0` = unity). Applies to the current
|
||||
/// clip immediately and to every clip played afterwards.
|
||||
pub fn set_volume(&self, volume: f32) {
|
||||
let _ = self.command_tx.send(ClipCommand::SetVolume(volume));
|
||||
}
|
||||
}
|
||||
|
||||
fn playback_worker(command_rx: mpsc::Receiver<ClipCommand>, status: SharedClipStatus) {
|
||||
fn playback_worker(
|
||||
command_rx: mpsc::Receiver<ClipCommand>,
|
||||
status: SharedClipStatus,
|
||||
initial_volume: f32,
|
||||
) {
|
||||
let mut output: Option<MixerDeviceSink> = None;
|
||||
let mut player: Option<Player> = None;
|
||||
// Universal gain remembered across clips so a level set on one upload
|
||||
// carries to the next; reapplied to each freshly connected player.
|
||||
let mut volume = initial_volume.max(0.0);
|
||||
|
||||
loop {
|
||||
match command_rx.recv_timeout(Duration::from_millis(100)) {
|
||||
@@ -124,7 +141,9 @@ fn playback_worker(command_rx: mpsc::Receiver<ClipCommand>, status: SharedClipSt
|
||||
if output.is_none() {
|
||||
match DeviceSinkBuilder::open_default_sink() {
|
||||
Ok(sink) => {
|
||||
player = Some(Player::connect_new(sink.mixer()));
|
||||
let new_player = Player::connect_new(sink.mixer());
|
||||
new_player.set_volume(volume);
|
||||
player = Some(new_player);
|
||||
output = Some(sink);
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -177,6 +196,12 @@ fn playback_worker(command_rx: mpsc::Receiver<ClipCommand>, status: SharedClipSt
|
||||
}
|
||||
reset(&status);
|
||||
}
|
||||
Ok(ClipCommand::SetVolume(level)) => {
|
||||
volume = level.max(0.0);
|
||||
if let Some(player) = player.as_ref() {
|
||||
player.set_volume(volume);
|
||||
}
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
}
|
||||
|
||||
+84
-13
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
+73
-10
@@ -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]
|
||||
|
||||
+57
-9
@@ -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,
|
||||
mic_fifo: VecDeque::new(),
|
||||
path,
|
||||
})
|
||||
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();
|
||||
|
||||
+119
@@ -185,10 +185,129 @@ pub fn initials(name: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// A small content-addressed LRU cache mapping image bytes to a built value
|
||||
/// (e.g. an `iced` image handle), so the SAME value is reused across redraws
|
||||
/// instead of rebuilt every frame. Two Tier C F-03 properties beyond a plain
|
||||
/// hash map:
|
||||
///
|
||||
/// 1. **Bounded** — at most `cap` entries, evicting the least-recently-used on
|
||||
/// overflow, so a peer can't grow the cache without limit by publishing an
|
||||
/// endless stream of distinct valid avatars.
|
||||
/// 2. **Collision-safe** — a hit requires full byte equality, not just a matching
|
||||
/// 64-bit hash, so a hash collision can never return a different image's value.
|
||||
///
|
||||
/// Linear scan; intended for small `cap` (tens of entries).
|
||||
pub struct ByteLru<V> {
|
||||
cap: usize,
|
||||
/// `(content hash, content bytes, value)`; back = most recently used.
|
||||
entries: Vec<(u64, Vec<u8>, V)>,
|
||||
}
|
||||
|
||||
impl<V: Clone> ByteLru<V> {
|
||||
/// Create an LRU holding at most `cap` entries (`cap` is clamped to >= 1).
|
||||
pub fn new(cap: usize) -> Self {
|
||||
Self { cap: cap.max(1), entries: Vec::new() }
|
||||
}
|
||||
|
||||
/// Return the cached value for these exact `bytes`, building and inserting it
|
||||
/// on a miss (evicting the least-recently-used entry once over `cap`). A hit
|
||||
/// verifies full byte equality, so a 64-bit hash collision never returns the
|
||||
/// wrong value. A hit also refreshes the entry's recency.
|
||||
pub fn get_or_insert(&mut self, bytes: &[u8], build: impl FnOnce() -> V) -> V {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
bytes.hash(&mut hasher);
|
||||
self.get_or_insert_hashed(hasher.finish(), bytes, build)
|
||||
}
|
||||
|
||||
/// Inner seam with the content `hash` supplied explicitly. Production callers
|
||||
/// use [`get_or_insert`]; tests use this to force a hash collision (different
|
||||
/// bytes, same hash) and exercise the byte-equality guard.
|
||||
fn get_or_insert_hashed(&mut self, hash: u64, bytes: &[u8], build: impl FnOnce() -> V) -> V {
|
||||
if let Some(idx) = self
|
||||
.entries
|
||||
.iter()
|
||||
.position(|(h, b, _)| *h == hash && b.as_slice() == bytes)
|
||||
{
|
||||
// LRU touch: move the hit entry to the back (most recent).
|
||||
let entry = self.entries.remove(idx);
|
||||
let val = entry.2.clone();
|
||||
self.entries.push(entry);
|
||||
return val;
|
||||
}
|
||||
|
||||
let val = build();
|
||||
if self.entries.len() >= self.cap {
|
||||
self.entries.remove(0); // evict least-recently-used
|
||||
}
|
||||
self.entries.push((hash, bytes.to_vec(), val.clone()));
|
||||
val
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn byte_lru_reuses_value_for_identical_bytes() {
|
||||
let mut lru: ByteLru<u32> = ByteLru::new(4);
|
||||
let mut next = 0u32;
|
||||
let mut build = |lru: &mut ByteLru<u32>, b: &[u8]| {
|
||||
lru.get_or_insert(b, || {
|
||||
next += 1;
|
||||
next
|
||||
})
|
||||
};
|
||||
// Same bytes → same value, built only once.
|
||||
assert_eq!(build(&mut lru, b"alice"), 1);
|
||||
assert_eq!(build(&mut lru, b"alice"), 1);
|
||||
// Different bytes → a freshly built value.
|
||||
assert_eq!(build(&mut lru, b"bob"), 2);
|
||||
assert_eq!(lru.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_lru_evicts_least_recently_used() {
|
||||
let mut lru: ByteLru<u32> = ByteLru::new(2);
|
||||
let mut n = 0u32;
|
||||
let mut ins = |lru: &mut ByteLru<u32>, b: &[u8]| {
|
||||
lru.get_or_insert(b, || {
|
||||
n += 1;
|
||||
n
|
||||
})
|
||||
};
|
||||
ins(&mut lru, b"a"); // -> 1
|
||||
ins(&mut lru, b"b"); // -> 2, cache = [a, b]
|
||||
ins(&mut lru, b"a"); // touch a, cache = [b, a]
|
||||
ins(&mut lru, b"c"); // evicts LRU (b), cache = [a, c]
|
||||
assert_eq!(lru.len(), 2);
|
||||
// `a` survived (recently touched) → still value 1, not rebuilt.
|
||||
assert_eq!(ins(&mut lru, b"a"), 1);
|
||||
// `b` was evicted → rebuilt with a new value.
|
||||
assert_eq!(ins(&mut lru, b"b"), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_lru_byte_equality_survives_a_hash_collision() {
|
||||
// Force the SAME 64-bit hash for two DIFFERENT byte strings (the case a
|
||||
// bare-hash cache would alias — Tier C F-03 collision bug).
|
||||
let mut lru: ByteLru<u32> = ByteLru::new(4);
|
||||
assert_eq!(lru.get_or_insert_hashed(42, b"alice", || 1), 1);
|
||||
// `bob` collides on the hash but differs in bytes → a MISS, built fresh,
|
||||
// NOT aliased to alice's value.
|
||||
assert_eq!(lru.get_or_insert_hashed(42, b"bob", || 2), 2);
|
||||
// Both coexist; each re-lookup returns its own value (build closure unused).
|
||||
assert_eq!(lru.get_or_insert_hashed(42, b"alice", || 99), 1);
|
||||
assert_eq!(lru.get_or_insert_hashed(42, b"bob", || 99), 2);
|
||||
assert_eq!(lru.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initials_takes_first_two_words() {
|
||||
assert_eq!(initials("Alice"), "A");
|
||||
|
||||
@@ -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.");
|
||||
|
||||
+107
-6
@@ -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;
|
||||
|
||||
@@ -156,6 +156,14 @@ pub struct AppConfig {
|
||||
/// App-internal playback gain applied to the mixed output (1.0 = unity).
|
||||
#[serde(default = "default_volume")]
|
||||
pub output_volume: f32,
|
||||
/// Universal playback gain for inline chat audio clips (1.0 = unity). One
|
||||
/// level shared by every uploaded clip so the slider sticks across plays.
|
||||
#[serde(default = "default_volume")]
|
||||
pub clip_volume: f32,
|
||||
/// When true, `clip_volume` governs every clip. When false, each clip keeps
|
||||
/// its own (in-memory) level and the universal slider is inactive.
|
||||
#[serde(default = "default_true")]
|
||||
pub clip_volume_universal: bool,
|
||||
#[serde(default)]
|
||||
pub network_mode: NetworkMode,
|
||||
/// Presence posture for the friends idle listener (W7): invisible / normal /
|
||||
@@ -199,6 +207,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,
|
||||
@@ -292,6 +320,8 @@ impl Default for AppConfig {
|
||||
noise_gate_threshold: 0.01,
|
||||
input_volume: 1.0,
|
||||
output_volume: 1.0,
|
||||
clip_volume: 1.0,
|
||||
clip_volume_universal: true,
|
||||
network_mode: NetworkMode::default(),
|
||||
presence_mode: crate::presence::PresenceMode::default(),
|
||||
echo_cancellation_enabled: false,
|
||||
@@ -305,6 +335,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,
|
||||
@@ -375,17 +408,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)
|
||||
@@ -480,6 +527,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.
|
||||
@@ -575,23 +667,32 @@ mod tests {
|
||||
let def = AppConfig::default();
|
||||
assert_eq!(def.input_volume, 1.0);
|
||||
assert_eq!(def.output_volume, 1.0);
|
||||
assert_eq!(def.clip_volume, 1.0);
|
||||
assert!(def.clip_volume_universal);
|
||||
|
||||
// Missing in JSON → unity (serde default).
|
||||
let missing = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
|
||||
let cfg_missing: AppConfig = serde_json::from_str(missing).unwrap();
|
||||
assert_eq!(cfg_missing.input_volume, 1.0);
|
||||
assert_eq!(cfg_missing.output_volume, 1.0);
|
||||
assert_eq!(cfg_missing.clip_volume, 1.0);
|
||||
// Configs predating the toggle default to universal mode.
|
||||
assert!(cfg_missing.clip_volume_universal);
|
||||
|
||||
// Explicit non-unity values are preserved across a round-trip.
|
||||
let cfg = AppConfig {
|
||||
input_volume: 1.5,
|
||||
output_volume: 0.25,
|
||||
clip_volume: 0.7,
|
||||
clip_volume_universal: false,
|
||||
..AppConfig::default()
|
||||
};
|
||||
let round_tripped: AppConfig =
|
||||
serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
|
||||
assert_eq!(round_tripped.input_volume, 1.5);
|
||||
assert_eq!(round_tripped.output_volume, 0.25);
|
||||
assert_eq!(round_tripped.clip_volume, 0.7);
|
||||
assert!(!round_tripped.clip_volume_universal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+181
-5
@@ -64,9 +64,16 @@ pub enum CoreCommand {
|
||||
/// Set the pixelpass binary location (config override, empty = use `$PATH`).
|
||||
/// Sent at startup so screen-share can resolve the binary.
|
||||
SetPixelpassPath(Option<String>),
|
||||
/// Enumerate apps currently producing audio (for the screen-share audio
|
||||
/// picker, A23). Replies with [`UiEvent::AudioAppsListed`]. Cheap shell-out;
|
||||
/// safe to call each time the picker opens.
|
||||
ListAudioApps,
|
||||
/// Start sharing our screen: spawn a pixelpass host and announce its ticket
|
||||
/// on our presence so the room can watch. No-op when not in a call.
|
||||
StartScreenShare,
|
||||
/// `audio_app` selects which app's audio to capture: `Some(name)` captures
|
||||
/// only that app (avoiding the call-loopback echo, A23); `None` shares the
|
||||
/// whole desktop audio (the legacy behavior).
|
||||
StartScreenShare { audio_app: Option<String> },
|
||||
/// Stop sharing our screen: kill the pixelpass host and clear the presence
|
||||
/// ticket. No-op when not sharing.
|
||||
StopScreenShare,
|
||||
@@ -89,12 +96,100 @@ 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, Copy, PartialEq, Eq)]
|
||||
pub enum DeliveryClass {
|
||||
Reliable,
|
||||
BestEffort,
|
||||
}
|
||||
|
||||
/// Route a command by how bad it is to drop it. Discrete, human-paced user
|
||||
/// actions are Reliable (must land). The only high-frequency commands are the
|
||||
/// continuous audio sliders, where dropping intermediate values is harmless;
|
||||
/// those are BestEffort.
|
||||
pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
|
||||
match cmd {
|
||||
CoreCommand::SetPeerVolume(_, _)
|
||||
| CoreCommand::SetPeerPan(_, _)
|
||||
| CoreCommand::SetPeerGate(_, _)
|
||||
| CoreCommand::SetPeerEq(_, _)
|
||||
| CoreCommand::SetInputVolume(_)
|
||||
| CoreCommand::SetOutputVolume(_)
|
||||
| CoreCommand::SetNoiseGateThreshold(_) => DeliveryClass::BestEffort,
|
||||
|
||||
CoreCommand::Join {
|
||||
name: _,
|
||||
ticket: _,
|
||||
room_name: _,
|
||||
input_device: _,
|
||||
output_device: _,
|
||||
echo_cancellation: _,
|
||||
avatar: _,
|
||||
}
|
||||
| CoreCommand::Leave
|
||||
| CoreCommand::Shutdown
|
||||
| CoreCommand::ToggleMute
|
||||
| CoreCommand::SetAvatar(_)
|
||||
| CoreCommand::ToggleDeafen
|
||||
| CoreCommand::SetPttMode(_)
|
||||
| CoreCommand::SetPttActive(_)
|
||||
| CoreCommand::SetPeerMuted(_, _)
|
||||
| CoreCommand::SetMicMonitor {
|
||||
enabled: _,
|
||||
input_device: _,
|
||||
}
|
||||
| CoreCommand::SetNetworkMode(_)
|
||||
| CoreCommand::SetRecording(_)
|
||||
| CoreCommand::SetRecordingMode(_)
|
||||
| CoreCommand::SendChat(_)
|
||||
| CoreCommand::SendChatFile {
|
||||
text: _,
|
||||
attachment: _,
|
||||
data: _,
|
||||
}
|
||||
| CoreCommand::FetchAttachment {
|
||||
from: _,
|
||||
attachment: _,
|
||||
}
|
||||
| CoreCommand::SetPixelpassPath(_)
|
||||
| CoreCommand::ListAudioApps
|
||||
| CoreCommand::StartScreenShare { audio_app: _ }
|
||||
| CoreCommand::StopScreenShare
|
||||
| CoreCommand::ViewShare(_)
|
||||
| CoreCommand::RegenerateIdentity
|
||||
| CoreCommand::AddFriend {
|
||||
id: _,
|
||||
name: _,
|
||||
addr: _,
|
||||
}
|
||||
| CoreCommand::RemoveFriend(_)
|
||||
| CoreCommand::RenameFriend(_, _)
|
||||
| CoreCommand::SetPresenceMode(_)
|
||||
| CoreCommand::SetGamePresenceEnabled(_)
|
||||
| CoreCommand::SetGameOverride(_)
|
||||
| CoreCommand::SetGameProcessMap(_) => DeliveryClass::Reliable,
|
||||
}
|
||||
}
|
||||
|
||||
#[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
|
||||
@@ -119,15 +214,33 @@ pub enum UiEvent {
|
||||
/// string, used to key their avatar (W4).
|
||||
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> },
|
||||
/// fetched on demand for files). Keyed by `(from, id)`: the id is
|
||||
/// attacker-chosen, so a malicious peer can reuse a victim's id — the author
|
||||
/// disambiguates whose bytes these are and stops content aliasing (Tier C
|
||||
/// F-12).
|
||||
AttachmentReady { from: EndpointId, 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 },
|
||||
AttachmentFailed { from: EndpointId, id: crate::files::AttachmentId, error: String },
|
||||
/// The apps currently producing audio, for the screen-share audio picker
|
||||
/// (A23). Sorted, deduplicated `application.name`s; empty when nothing is
|
||||
/// playing or enumeration isn't available. `app_audio_supported` reports
|
||||
/// whether the resolved pixelpass understands `--strict-audio`: when `false`
|
||||
/// (an older pixelpass) the picker must offer whole-desktop audio only, since
|
||||
/// a per-app share would pass a flag that older binary rejects (audit P2).
|
||||
AudioAppsListed { apps: Vec<String>, app_audio_supported: bool },
|
||||
/// Our own screen share started; the UI flips the Share button to "Stop".
|
||||
ScreenShareStarted,
|
||||
/// Our own screen share stopped (or failed to start).
|
||||
ScreenShareStopped,
|
||||
/// Per-app screen-share audio routing state (A23). `true` = the app we chose
|
||||
/// is now reaching viewers; `false` = its audio stopped, so under our strict
|
||||
/// run viewers currently hear silence. The UI shows a transient warning while
|
||||
/// `false`. Only meaningful while sharing a specific app (not whole-desktop).
|
||||
ShareAudioActive(bool),
|
||||
/// A validly signed peer cannot be admitted because its gossip timestamp is
|
||||
/// outside the replay freshness window. `peer_ahead` describes the peer's
|
||||
/// sender-stamped timestamp relative to this machine's clock.
|
||||
ClockSkewWarning { skew_secs: u64, peer_ahead: bool },
|
||||
/// Our node identity (W7): the current node id string, and whether it is
|
||||
/// PERSISTED to disk. Sent once at startup and again after a regenerate.
|
||||
/// `persisted = false` means the key file couldn't be read/written and we're
|
||||
@@ -150,7 +263,70 @@ 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),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{delivery_class, CoreCommand, DeliveryClass};
|
||||
use crate::audio::eq::EqSettings;
|
||||
use crate::presence::PresenceMode;
|
||||
use iroh::{EndpointId, SecretKey};
|
||||
|
||||
fn endpoint_id() -> EndpointId {
|
||||
SecretKey::generate().public()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuous_audio_controls_are_best_effort() {
|
||||
let peer = endpoint_id();
|
||||
let commands = [
|
||||
CoreCommand::SetPeerVolume(peer, 0.7),
|
||||
CoreCommand::SetPeerPan(peer, -0.2),
|
||||
CoreCommand::SetPeerGate(peer, 0.1),
|
||||
CoreCommand::SetPeerEq(peer, EqSettings::default()),
|
||||
CoreCommand::SetInputVolume(0.8),
|
||||
CoreCommand::SetOutputVolume(0.9),
|
||||
CoreCommand::SetNoiseGateThreshold(0.02),
|
||||
];
|
||||
|
||||
for cmd in commands {
|
||||
assert_eq!(delivery_class(&cmd), DeliveryClass::BestEffort);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discrete_user_actions_are_reliable() {
|
||||
let peer = endpoint_id();
|
||||
let commands = [
|
||||
CoreCommand::ToggleMute,
|
||||
CoreCommand::SetPttActive(false),
|
||||
CoreCommand::Leave,
|
||||
CoreCommand::RegenerateIdentity,
|
||||
CoreCommand::Join {
|
||||
name: "Peer".to_string(),
|
||||
ticket: "create".to_string(),
|
||||
room_name: "Room".to_string(),
|
||||
input_device: None,
|
||||
output_device: None,
|
||||
echo_cancellation: true,
|
||||
avatar: crate::avatar::Avatar::default(),
|
||||
},
|
||||
CoreCommand::SetPeerMuted(peer, true),
|
||||
CoreCommand::SetPresenceMode(PresenceMode::Normal),
|
||||
CoreCommand::SendChat("hello".to_string()),
|
||||
];
|
||||
|
||||
for cmd in commands {
|
||||
assert_eq!(delivery_class(&cmd), DeliveryClass::Reliable);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+627
-121
File diff suppressed because it is too large
Load Diff
+68
-8
@@ -22,6 +22,27 @@ fn recovery_delay(attempt: usize) -> Duration {
|
||||
RECOVERY_DELAYS[attempt.min(RECOVERY_DELAYS.len() - 1)]
|
||||
}
|
||||
|
||||
/// Terminal retry budget for background recovery. After this many failed attempts
|
||||
/// the coordinator gives up: it drops the entry, frees the active slot, and signals
|
||||
/// the event task to forget the retained address (Tier C recovery-identity cap).
|
||||
///
|
||||
/// With the [`RECOVERY_DELAYS`] backoff this is roughly seven minutes of dialing
|
||||
/// (1+2+4+8+15+30+60s, then 60s steps), far beyond any normal transient outage. A
|
||||
/// genuine peer returning after a longer outage still rejoins on its own via a
|
||||
/// gossip announce, so giving up only stops us from dialing a peer that is not
|
||||
/// coming back — it does not break legitimate reconnect-after-outage.
|
||||
const RECOVERY_TERMINAL_ATTEMPTS: usize = 12;
|
||||
|
||||
/// Capacity of the terminal-eviction notification channel. Bounded; on the rare
|
||||
/// event of saturation the entry is still removed (the dial work stops) and only
|
||||
/// the retained-address forget is skipped, which the per-topic retain cap bounds.
|
||||
const RECOVERY_TERMINAL_CAPACITY: usize = 64;
|
||||
|
||||
/// Whether `attempt` completed recoveries have exhausted the terminal budget.
|
||||
fn recovery_is_terminal(attempt: usize, max_attempts: usize) -> bool {
|
||||
attempt >= max_attempts
|
||||
}
|
||||
|
||||
enum RecoveryCommand {
|
||||
Start {
|
||||
peer_id: EndpointId,
|
||||
@@ -60,19 +81,24 @@ pub(super) struct RecoveryCoordinator {
|
||||
}
|
||||
|
||||
impl RecoveryCoordinator {
|
||||
pub(super) fn spawn(room_state: Arc<IrohGossipState>) -> (Self, JoinHandle<()>) {
|
||||
pub(super) fn spawn(
|
||||
room_state: Arc<IrohGossipState>,
|
||||
) -> (Self, JoinHandle<()>, mpsc::Receiver<EndpointId>) {
|
||||
Self::spawn_inner(room_state)
|
||||
}
|
||||
|
||||
fn spawn_inner(room_state: Arc<dyn RecoveryRoom>) -> (Self, JoinHandle<()>) {
|
||||
fn spawn_inner(
|
||||
room_state: Arc<dyn RecoveryRoom>,
|
||||
) -> (Self, JoinHandle<()>, mpsc::Receiver<EndpointId>) {
|
||||
let (tx, rx) = mpsc::channel(RECOVERY_COMMAND_CAPACITY);
|
||||
let (terminal_tx, terminal_rx) = mpsc::channel(RECOVERY_TERMINAL_CAPACITY);
|
||||
let active = Arc::new(Mutex::new(HashSet::new()));
|
||||
let handle = Self {
|
||||
tx,
|
||||
active: active.clone(),
|
||||
};
|
||||
let task = tokio::spawn(run_coordinator(room_state, active, rx));
|
||||
(handle, task)
|
||||
let task = tokio::spawn(run_coordinator(room_state, active, rx, terminal_tx));
|
||||
(handle, task, terminal_rx)
|
||||
}
|
||||
|
||||
/// Reserve one recovery slot before grace-expiry teardown begins. Returns
|
||||
@@ -116,6 +142,7 @@ async fn run_coordinator(
|
||||
room_state: Arc<dyn RecoveryRoom>,
|
||||
active: Arc<Mutex<HashSet<EndpointId>>>,
|
||||
mut rx: mpsc::Receiver<RecoveryCommand>,
|
||||
terminal_tx: mpsc::Sender<EndpointId>,
|
||||
) {
|
||||
let mut entries: HashMap<EndpointId, RecoveryEntry> = HashMap::new();
|
||||
|
||||
@@ -155,9 +182,24 @@ async fn run_coordinator(
|
||||
entries.remove(&peer_id);
|
||||
continue;
|
||||
}
|
||||
if let Some(entry) = entries.get_mut(&peer_id) {
|
||||
// Advance the backoff, then check the terminal budget.
|
||||
// `attempt` counts completed attempts, so the delay
|
||||
// uses the current value before it is incremented.
|
||||
let terminal = if let Some(entry) = entries.get_mut(&peer_id) {
|
||||
entry.next_attempt = scheduled_at + recovery_delay(entry.attempt);
|
||||
entry.attempt = entry.attempt.saturating_add(1);
|
||||
recovery_is_terminal(entry.attempt, RECOVERY_TERMINAL_ATTEMPTS)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if terminal {
|
||||
// Give up on a peer that has not returned within the
|
||||
// budget: drop its entry, free the active slot, and
|
||||
// signal the event task to forget its retained
|
||||
// address so the per-topic retain table drains.
|
||||
entries.remove(&peer_id);
|
||||
active.lock().unwrap().remove(&peer_id);
|
||||
let _ = terminal_tx.try_send(peer_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,6 +252,23 @@ mod tests {
|
||||
assert_eq!(actual, vec![1, 2, 4, 8, 15, 30, 60, 60, 60, 60]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_budget_is_terminal_only_at_or_past_the_cap() {
|
||||
assert!(!recovery_is_terminal(0, RECOVERY_TERMINAL_ATTEMPTS));
|
||||
assert!(!recovery_is_terminal(
|
||||
RECOVERY_TERMINAL_ATTEMPTS - 1,
|
||||
RECOVERY_TERMINAL_ATTEMPTS
|
||||
));
|
||||
assert!(recovery_is_terminal(
|
||||
RECOVERY_TERMINAL_ATTEMPTS,
|
||||
RECOVERY_TERMINAL_ATTEMPTS
|
||||
));
|
||||
assert!(recovery_is_terminal(
|
||||
RECOVERY_TERMINAL_ATTEMPTS + 5,
|
||||
RECOVERY_TERMINAL_ATTEMPTS
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_slots_are_deduplicated_and_cancel_immediately() {
|
||||
let (tx, mut rx) = mpsc::channel(4);
|
||||
@@ -244,9 +303,10 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn coordinator_attempts_rebootstrap_immediately() {
|
||||
let (attempts_tx, mut attempts_rx) = mpsc::unbounded_channel();
|
||||
let (coordinator, task) = RecoveryCoordinator::spawn_inner(Arc::new(RecordingRoom {
|
||||
attempts: attempts_tx,
|
||||
}));
|
||||
let (coordinator, task, _terminal_rx) =
|
||||
RecoveryCoordinator::spawn_inner(Arc::new(RecordingRoom {
|
||||
attempts: attempts_tx,
|
||||
}));
|
||||
let peer_id = SecretKey::generate().public();
|
||||
let addr = EndpointAddr::from(peer_id);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+483
@@ -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);
|
||||
}
|
||||
}
|
||||
+340
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ pub mod recents;
|
||||
pub mod discovery;
|
||||
pub mod hotkeys;
|
||||
pub mod files;
|
||||
pub mod game;
|
||||
pub mod widget;
|
||||
|
||||
use std::fs::File;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
+472
-14
@@ -1,11 +1,11 @@
|
||||
use crate::network::{RoomState, NetError, PeerState, RoomEvent, PeerSpeakTicket};
|
||||
use iroh::{Endpoint, EndpointAddr, EndpointId, SecretKey, Signature};
|
||||
use iroh::{Endpoint, EndpointAddr, EndpointId, SecretKey, Signature, TransportAddr};
|
||||
use iroh_gossip::net::Gossip;
|
||||
use iroh_gossip::proto::TopicId;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
use async_trait::async_trait;
|
||||
use tokio_stream::StreamExt;
|
||||
use serde::{Serialize, Deserialize};
|
||||
@@ -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
|
||||
@@ -122,14 +131,192 @@ fn admit_state_mutation(
|
||||
true
|
||||
}
|
||||
|
||||
/// Size at which we prune stale entries from the replay-tracking map (Tier C
|
||||
/// F-01 audit). `admit_state_mutation` records `(author, kind)` for every signed
|
||||
/// mutation, so an insider sending validly signed `Leave`s from unlimited
|
||||
/// generated keys would otherwise grow it for the room's lifetime. A mutation
|
||||
/// older than the freshness window can never be the deciding `last_ts` for an
|
||||
/// in-window message — `verify_gossip`'s timestamp check rejects such a replay
|
||||
/// first — so dropping those entries cannot weaken replay protection; it bounds
|
||||
/// the map to roughly the authors seen within one freshness window.
|
||||
const STATE_MUTATIONS_SOFT_CAP: usize = 256;
|
||||
|
||||
/// Drop replay-tracking entries whose timestamp is older than `window_ms` before
|
||||
/// `now_ms` (see [`STATE_MUTATIONS_SOFT_CAP`]). Pure → unit-testable.
|
||||
fn prune_stale_mutations(
|
||||
seen: &mut HashMap<(EndpointId, StateMutationKind), u64>,
|
||||
now_ms: u64,
|
||||
window_ms: u64,
|
||||
) {
|
||||
let floor = now_ms.saturating_sub(window_ms);
|
||||
seen.retain(|_, last_ts| *last_ts >= floor);
|
||||
}
|
||||
|
||||
/// Three signed, out-of-window payloads inside one minute is enough to distinguish
|
||||
/// a persistently skewed clock from a single delayed gossip frame without making
|
||||
/// the user wait long. Repeats are suppressed for five minutes per author.
|
||||
const CLOCK_SKEW_OBSERVATION_WINDOW_MS: u64 = 60_000;
|
||||
const CLOCK_SKEW_WARNING_THRESHOLD: usize = 3;
|
||||
const CLOCK_SKEW_COOLDOWN_MS: u64 = 5 * 60_000;
|
||||
const CLOCK_SKEW_AUTHORS_SOFT_CAP: usize = 256;
|
||||
const CLOCK_SKEW_AUTHORS_HARD_CAP: usize = 512;
|
||||
const CLOCK_SKEW_AUTHOR_TTL_MS: u64 = CLOCK_SKEW_COOLDOWN_MS;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct ClockSkewWarning {
|
||||
author: EndpointId,
|
||||
/// Positive means the peer's sender-stamped clock is ahead of ours.
|
||||
skew_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ClockSkewMonitor {
|
||||
authors: HashMap<EndpointId, ClockSkewAuthorState>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ClockSkewAuthorState {
|
||||
observed_at: Vec<u64>,
|
||||
last_seen_ms: u64,
|
||||
last_warned_ms: Option<u64>,
|
||||
}
|
||||
|
||||
impl ClockSkewMonitor {
|
||||
fn observe(
|
||||
&mut self,
|
||||
author: EndpointId,
|
||||
skew_ms: i64,
|
||||
now_ms: u64,
|
||||
) -> Option<ClockSkewWarning> {
|
||||
if self.authors.len() > CLOCK_SKEW_AUTHORS_SOFT_CAP {
|
||||
self.prune_stale_authors(now_ms);
|
||||
}
|
||||
|
||||
let warning = {
|
||||
let state = self.authors.entry(author).or_default();
|
||||
state.last_seen_ms = now_ms;
|
||||
let floor = now_ms.saturating_sub(CLOCK_SKEW_OBSERVATION_WINDOW_MS);
|
||||
state.observed_at.retain(|ts| *ts >= floor);
|
||||
state.observed_at.push(now_ms);
|
||||
if state.observed_at.len() > CLOCK_SKEW_WARNING_THRESHOLD {
|
||||
let excess = state.observed_at.len() - CLOCK_SKEW_WARNING_THRESHOLD;
|
||||
state.observed_at.drain(0..excess);
|
||||
}
|
||||
|
||||
let threshold_met = state.observed_at.len() >= CLOCK_SKEW_WARNING_THRESHOLD;
|
||||
let in_cooldown = state
|
||||
.last_warned_ms
|
||||
.is_some_and(|last| now_ms.saturating_sub(last) < CLOCK_SKEW_COOLDOWN_MS);
|
||||
if threshold_met && !in_cooldown {
|
||||
state.last_warned_ms = Some(now_ms);
|
||||
Some(ClockSkewWarning { author, skew_ms })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if self.authors.len() > CLOCK_SKEW_AUTHORS_HARD_CAP {
|
||||
self.drop_oldest_authors();
|
||||
}
|
||||
|
||||
warning
|
||||
}
|
||||
|
||||
fn prune_stale_authors(&mut self, now_ms: u64) {
|
||||
let stale_before = now_ms.saturating_sub(CLOCK_SKEW_AUTHOR_TTL_MS);
|
||||
self.authors.retain(|_, state| {
|
||||
let last_warning_live = state
|
||||
.last_warned_ms
|
||||
.is_some_and(|last| now_ms.saturating_sub(last) < CLOCK_SKEW_COOLDOWN_MS);
|
||||
last_warning_live || state.last_seen_ms >= stale_before
|
||||
});
|
||||
}
|
||||
|
||||
fn drop_oldest_authors(&mut self) {
|
||||
let remove_count = self.authors.len().saturating_sub(CLOCK_SKEW_AUTHORS_SOFT_CAP);
|
||||
let mut by_age: Vec<_> = self
|
||||
.authors
|
||||
.iter()
|
||||
.map(|(author, state)| (*author, state.last_seen_ms))
|
||||
.collect();
|
||||
by_age.sort_by_key(|(_, last_seen_ms)| *last_seen_ms);
|
||||
for (author, _) in by_age.into_iter().take(remove_count) {
|
||||
self.authors.remove(&author);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum number of distinct peers we hold in a room roster at once.
|
||||
///
|
||||
/// Everyone with the room ticket is an authenticated *insider*: a signature only
|
||||
/// proves ownership of the generated keypair it was made with, not that the
|
||||
/// author is a distinct human. A malicious member can therefore mint many valid
|
||||
/// signed identities. Voice is full-mesh (each peer dials every other), so a real
|
||||
/// room is realistically well under this bound; the cap exists purely so a flood
|
||||
/// of sock-puppet `Announce`s can't grow our peer map / audio supervisors / dials
|
||||
/// without limit (Tier C F-01).
|
||||
const MAX_ACTIVE_PEERS: usize = 32;
|
||||
|
||||
/// Maximum transport addresses we retain from a single peer announce. iroh
|
||||
/// normally advertises a handful (a few LAN/WAN IP candidates plus one home
|
||||
/// relay); the cap stops an insider stuffing a large unique address set into each
|
||||
/// announce to inflate the address lookup and the dialer's candidate list.
|
||||
const MAX_PEER_ADDRS: usize = 8;
|
||||
|
||||
/// Maximum byte length of a relay URL we accept inside a peer address. A relay
|
||||
/// URL is normal-length; anything longer is dropped rather than retained.
|
||||
const MAX_RELAY_URL_LEN: usize = 256;
|
||||
|
||||
/// Bound an untrusted peer's advertised address set before we retain it / hand it
|
||||
/// to the address lookup and dialer (Tier C F-01). Drops transport kinds we never
|
||||
/// use (`Custom`) and over-long relay URLs, then truncates to at most
|
||||
/// [`MAX_PEER_ADDRS`] addresses. `BTreeSet` iteration is deterministic, so the
|
||||
/// kept subset is stable. Pure → unit-testable.
|
||||
fn sanitize_endpoint_addr(addr: &EndpointAddr) -> EndpointAddr {
|
||||
let addrs: BTreeSet<TransportAddr> = addr
|
||||
.addrs
|
||||
.iter()
|
||||
.filter(|a| match a {
|
||||
TransportAddr::Relay(url) => url.as_str().len() <= MAX_RELAY_URL_LEN,
|
||||
TransportAddr::Ip(_) => true,
|
||||
// `TransportAddr` is #[non_exhaustive]; we only speak IP + relay, so
|
||||
// anything else (Custom / future kinds) is dropped, not retained.
|
||||
_ => false,
|
||||
})
|
||||
.take(MAX_PEER_ADDRS)
|
||||
.cloned()
|
||||
.collect();
|
||||
EndpointAddr { id: addr.id, addrs }
|
||||
}
|
||||
|
||||
/// Whether an `Announce` may enter the roster. Only a brand-new author
|
||||
/// (`subject_to_cap`) is gated by [`MAX_ACTIVE_PEERS`]; updates to an
|
||||
/// already-present peer AND re-announces from a peer mid-reconnect (which
|
||||
/// already held a slot) always pass — exempting reconnects keeps a full room
|
||||
/// from rejecting a legitimately reconnecting member and orphaning its recovery
|
||||
/// state (Tier C F-01 audit). Pure → unit-testable.
|
||||
fn admit_into_roster(roster_len: usize, subject_to_cap: bool, max_peers: usize) -> bool {
|
||||
!subject_to_cap || roster_len < max_peers
|
||||
}
|
||||
|
||||
/// Whether a received `Announce`'s author is gated by the roster cap. A peer
|
||||
/// already in the roster (`is_new == false`, an ordinary update) or one
|
||||
/// mid-reconnect (`is_reconnecting`, it already held a slot) is exempt; only a
|
||||
/// brand-new author counts against [`MAX_ACTIVE_PEERS`] (Tier C F-01 audit).
|
||||
/// Pure → unit-testable.
|
||||
fn announce_subject_to_cap(is_new: bool, is_reconnecting: bool) -> bool {
|
||||
is_new && !is_reconnecting
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -320,6 +507,7 @@ impl RoomState for IrohGossipState {
|
||||
let handle = tokio::spawn(async move {
|
||||
crate::log_msg(&format!("Spawned gossip topic loop for self_id={:?}", self_id));
|
||||
let mut state_mutations_seen = HashMap::new();
|
||||
let mut clock_skew_monitor = ClockSkewMonitor::default();
|
||||
|
||||
// Broadcast initial state
|
||||
let initial_payload = {
|
||||
@@ -343,15 +531,45 @@ 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
|
||||
// action: a forged/stale payload is dropped here
|
||||
// so it can't impersonate, evict, or poison
|
||||
// presence/address-book (security S2).
|
||||
if let Err(reason) =
|
||||
verify_gossip(&payload, &topic_bytes, now_millis(), GOSSIP_FRESHNESS_MS)
|
||||
{
|
||||
let received_now_ms = now_millis();
|
||||
if let Err(reason) = verify_gossip(
|
||||
&payload,
|
||||
&topic_bytes,
|
||||
received_now_ms,
|
||||
GOSSIP_FRESHNESS_MS,
|
||||
) {
|
||||
if reason == GossipReject::OutOfWindow {
|
||||
let skew_ms = payload.ts as i64 - received_now_ms as i64;
|
||||
if let Some(warning) = clock_skew_monitor.observe(
|
||||
payload.author,
|
||||
skew_ms,
|
||||
received_now_ms,
|
||||
) {
|
||||
let _ = event_tx
|
||||
.send(RoomEvent::ClockSkewSuspected {
|
||||
author: warning.author,
|
||||
skew_ms: warning.skew_ms,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
crate::log_msg(&format!(
|
||||
"Gossip dropped unauthenticated/stale payload claiming author={:?}: {:?}",
|
||||
payload.author, reason
|
||||
@@ -369,6 +587,18 @@ impl RoomState for IrohGossipState {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Keep the replay-tracking map bounded: prune entries
|
||||
// older than the freshness window once it grows past the
|
||||
// soft cap (Tier C F-01 audit). Stale entries can't gate
|
||||
// an in-window message, so this never weakens replay
|
||||
// protection.
|
||||
if state_mutations_seen.len() > STATE_MUTATIONS_SOFT_CAP {
|
||||
prune_stale_mutations(
|
||||
&mut state_mutations_seen,
|
||||
now_millis(),
|
||||
GOSSIP_FRESHNESS_MS,
|
||||
);
|
||||
}
|
||||
if !admit_state_mutation(
|
||||
&mut state_mutations_seen,
|
||||
payload.author,
|
||||
@@ -406,16 +636,58 @@ impl RoomState for IrohGossipState {
|
||||
// peer-supplied: cap/validate once at ingest
|
||||
// so invalid offers never render a Watch button.
|
||||
state.sharing = state.sharing.and_then(crate::screenshare::sanitize_ticket);
|
||||
disconnected_peers.lock().unwrap().remove(&payload.author);
|
||||
let (is_new, state_changed) = {
|
||||
// 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)
|
||||
});
|
||||
// Bound an insider's advertised address set
|
||||
// before we retain it / hand it to the dialer
|
||||
// (Tier C F-01).
|
||||
state.addr = sanitize_endpoint_addr(&state.addr);
|
||||
// A peer reconnecting from a transient drop sits
|
||||
// in `disconnected_peers` (not the live roster);
|
||||
// it already held a slot, so it must be re-admitted
|
||||
// regardless of the cap, and its disconnect marker
|
||||
// cleared ONLY once re-admitted — clearing it before
|
||||
// a possible reject would orphan its recovery state
|
||||
// (Tier C F-01 audit).
|
||||
let is_reconnecting =
|
||||
disconnected_peers.lock().unwrap().contains(&payload.author);
|
||||
let admitted = {
|
||||
let mut peer_map = peers.lock().unwrap();
|
||||
let is_new = !peer_map.contains_key(&payload.author);
|
||||
let state_changed = peer_map.get(&payload.author) != Some(&state);
|
||||
if is_new || state_changed {
|
||||
peer_map.insert(payload.author, state.clone());
|
||||
// Cap the roster so a flood of signed
|
||||
// sock-puppet identities can't grow our
|
||||
// memory/tasks/dials without bound (Tier C
|
||||
// F-01). Existing-peer updates and reconnects
|
||||
// are exempt; only brand-new authors are gated.
|
||||
let subject_to_cap = announce_subject_to_cap(is_new, is_reconnecting);
|
||||
if !admit_into_roster(peer_map.len(), subject_to_cap, MAX_ACTIVE_PEERS) {
|
||||
None
|
||||
} else {
|
||||
let state_changed = peer_map.get(&payload.author) != Some(&state);
|
||||
if is_new || state_changed {
|
||||
peer_map.insert(payload.author, state.clone());
|
||||
}
|
||||
Some((is_new, state_changed))
|
||||
}
|
||||
(is_new, state_changed)
|
||||
};
|
||||
let Some((is_new, state_changed)) = admitted else {
|
||||
crate::log_msg(&format!(
|
||||
"Gossip roster full ({MAX_ACTIVE_PEERS}); rejecting new peer {}",
|
||||
crate::short_id(&payload.author.to_string())
|
||||
));
|
||||
continue;
|
||||
};
|
||||
// Admitted — now it is safe to clear any reconnect
|
||||
// marker (a rejected announce above leaves it intact
|
||||
// so a later signed Leave still cleans up).
|
||||
disconnected_peers.lock().unwrap().remove(&payload.author);
|
||||
|
||||
if is_new {
|
||||
crate::log_msg(&format!(
|
||||
@@ -423,7 +695,12 @@ impl RoomState for IrohGossipState {
|
||||
crate::short_id(&payload.author.to_string()),
|
||||
peer_state_for_log(&state)
|
||||
));
|
||||
address_lookup.add_endpoint_info(state.addr.clone());
|
||||
// Replace (not union) the lookup's record for
|
||||
// this id with the authenticated, sanitized
|
||||
// address set, so leave/re-announce cycles
|
||||
// can't accumulate attacker-supplied history
|
||||
// (Tier C F-01).
|
||||
let _ = address_lookup.set_endpoint_info(state.addr.clone());
|
||||
let _ = event_tx.send(RoomEvent::PeerJoined(payload.author, state)).await;
|
||||
} else if state_changed {
|
||||
crate::log_msg(&format!(
|
||||
@@ -436,6 +713,11 @@ impl RoomState for IrohGossipState {
|
||||
}
|
||||
GossipMessage::Leave => {
|
||||
crate::log_msg(&format!("Gossip peer leave request from author={:?}", payload.author));
|
||||
// Drop this id's address-lookup entry so cycling
|
||||
// distinct identities through Announce→Leave can't
|
||||
// grow the lookup for the room's lifetime (Tier C
|
||||
// F-01 audit). Re-announce re-populates it.
|
||||
let _ = address_lookup.remove_endpoint_info(payload.author);
|
||||
let removed = peers.lock().unwrap().remove(&payload.author).is_some();
|
||||
let was_disconnected = disconnected_peers
|
||||
.lock()
|
||||
@@ -676,6 +958,7 @@ mod tests {
|
||||
addr,
|
||||
sharing: None,
|
||||
avatar: crate::avatar::Avatar::default(),
|
||||
game: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -687,6 +970,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.
|
||||
@@ -727,6 +1020,171 @@ mod tests {
|
||||
assert!(!bootstrap.contains(&me));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admit_into_roster_caps_new_authors_but_not_updates() {
|
||||
// New authors are admitted while there's room...
|
||||
assert!(admit_into_roster(0, true, 3));
|
||||
assert!(admit_into_roster(2, true, 3));
|
||||
// ...rejected once the roster is full...
|
||||
assert!(!admit_into_roster(3, true, 3));
|
||||
assert!(!admit_into_roster(10, true, 3));
|
||||
// ...but an existing peer's update always passes, even at/over the cap.
|
||||
assert!(admit_into_roster(3, false, 3));
|
||||
assert!(admit_into_roster(99, false, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconnecting_and_existing_peers_are_exempt_from_the_cap() {
|
||||
// A brand-new author counts against the cap...
|
||||
assert!(announce_subject_to_cap(/* is_new */ true, /* is_reconnecting */ false));
|
||||
// ...but an ordinary update from an in-roster peer does not...
|
||||
assert!(!announce_subject_to_cap(false, false));
|
||||
// ...and neither does a re-announce from a peer mid-reconnect, even
|
||||
// though it was removed from the live roster (the F-01-audit fix: a full
|
||||
// room must not reject a legitimately reconnecting member).
|
||||
assert!(!announce_subject_to_cap(true, true));
|
||||
// Combined with admit_into_roster: a reconnecting author passes at a full
|
||||
// roster, a brand-new one does not.
|
||||
assert!(admit_into_roster(3, announce_subject_to_cap(true, true), 3));
|
||||
assert!(!admit_into_roster(3, announce_subject_to_cap(true, false), 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_stale_mutations_drops_only_out_of_window_entries() {
|
||||
let a = fresh_id();
|
||||
let b = fresh_id();
|
||||
let mut seen = HashMap::new();
|
||||
seen.insert((a, StateMutationKind::Announce), 10_000u64);
|
||||
seen.insert((b, StateMutationKind::Leave), 250_000u64);
|
||||
// now = 300_000, window = 120_000 → floor 180_000. The 10_000 entry is
|
||||
// stale (and could never gate an in-window message), the 250_000 is live.
|
||||
prune_stale_mutations(&mut seen, 300_000, GOSSIP_FRESHNESS_MS);
|
||||
assert_eq!(seen.len(), 1);
|
||||
assert!(seen.contains_key(&(b, StateMutationKind::Leave)));
|
||||
assert!(!seen.contains_key(&(a, StateMutationKind::Announce)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clock_skew_monitor_single_drop_does_not_warn() {
|
||||
let author = fresh_id();
|
||||
let mut monitor = ClockSkewMonitor::default();
|
||||
|
||||
assert_eq!(monitor.observe(author, -121_000, 10_000), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clock_skew_monitor_three_drops_in_window_warn_once() {
|
||||
let author = fresh_id();
|
||||
let mut monitor = ClockSkewMonitor::default();
|
||||
|
||||
assert_eq!(monitor.observe(author, -121_000, 10_000), None);
|
||||
assert_eq!(monitor.observe(author, -122_000, 40_000), None);
|
||||
assert_eq!(
|
||||
monitor.observe(author, -123_000, 69_999),
|
||||
Some(ClockSkewWarning { author, skew_ms: -123_000 })
|
||||
);
|
||||
assert_eq!(monitor.observe(author, -124_000, 70_000), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clock_skew_monitor_cooldown_suppresses_repeats() {
|
||||
let author = fresh_id();
|
||||
let mut monitor = ClockSkewMonitor::default();
|
||||
|
||||
assert_eq!(monitor.observe(author, 121_000, 0), None);
|
||||
assert_eq!(monitor.observe(author, 122_000, 10_000), None);
|
||||
assert!(monitor.observe(author, 123_000, 20_000).is_some());
|
||||
|
||||
assert_eq!(monitor.observe(author, 124_000, 30_000), None);
|
||||
assert_eq!(monitor.observe(author, 125_000, 310_000), None);
|
||||
assert_eq!(monitor.observe(author, 126_000, 319_000), None);
|
||||
assert_eq!(monitor.observe(author, 127_000, 319_999), None);
|
||||
assert_eq!(
|
||||
monitor.observe(author, 128_000, 320_000),
|
||||
Some(ClockSkewWarning { author, skew_ms: 128_000 })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clock_skew_monitor_tracks_distinct_authors_independently() {
|
||||
let a = fresh_id();
|
||||
let b = fresh_id();
|
||||
let mut monitor = ClockSkewMonitor::default();
|
||||
|
||||
assert_eq!(monitor.observe(a, -121_000, 0), None);
|
||||
assert_eq!(monitor.observe(a, -121_000, 1_000), None);
|
||||
assert_eq!(monitor.observe(b, 121_000, 0), None);
|
||||
assert_eq!(monitor.observe(b, 121_000, 1_000), None);
|
||||
assert_eq!(
|
||||
monitor.observe(b, 121_000, 2_000),
|
||||
Some(ClockSkewWarning { author: b, skew_ms: 121_000 })
|
||||
);
|
||||
assert_eq!(
|
||||
monitor.observe(a, -121_000, 2_000),
|
||||
Some(ClockSkewWarning { author: a, skew_ms: -121_000 })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clock_skew_monitor_prunes_stale_authors_when_over_cap() {
|
||||
let mut monitor = ClockSkewMonitor::default();
|
||||
for _ in 0..=CLOCK_SKEW_AUTHORS_SOFT_CAP {
|
||||
assert_eq!(monitor.observe(fresh_id(), -121_000, 1), None);
|
||||
}
|
||||
assert!(monitor.authors.len() > CLOCK_SKEW_AUTHORS_SOFT_CAP);
|
||||
|
||||
let current = fresh_id();
|
||||
assert_eq!(
|
||||
monitor.observe(current, -121_000, CLOCK_SKEW_AUTHOR_TTL_MS + 2),
|
||||
None
|
||||
);
|
||||
assert_eq!(monitor.authors.len(), 1);
|
||||
assert!(monitor.authors.contains_key(¤t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clock_skew_monitor_hard_cap_bounds_fresh_author_growth() {
|
||||
let mut monitor = ClockSkewMonitor::default();
|
||||
for now_ms in 0..(CLOCK_SKEW_AUTHORS_HARD_CAP as u64 + 10) {
|
||||
let _ = monitor.observe(fresh_id(), -121_000, now_ms);
|
||||
assert!(monitor.authors.len() <= CLOCK_SKEW_AUTHORS_HARD_CAP);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_endpoint_addr_caps_address_count() {
|
||||
use std::net::SocketAddr;
|
||||
let id = fresh_id();
|
||||
// An insider stuffs far more addresses than MAX_PEER_ADDRS into one announce.
|
||||
let many: Vec<TransportAddr> = (0..(MAX_PEER_ADDRS as u16 + 50))
|
||||
.map(|i| TransportAddr::Ip(SocketAddr::from(([127, 0, 0, 1], 1000 + i))))
|
||||
.collect();
|
||||
let addr = EndpointAddr::from_parts(id, many);
|
||||
let out = sanitize_endpoint_addr(&addr);
|
||||
assert_eq!(out.id, id);
|
||||
assert_eq!(out.addrs.len(), MAX_PEER_ADDRS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_endpoint_addr_drops_overlong_relay_url() {
|
||||
use std::str::FromStr;
|
||||
let id = fresh_id();
|
||||
let short = iroh::RelayUrl::from_str("https://relay.example/").unwrap();
|
||||
let long = iroh::RelayUrl::from_str(&format!(
|
||||
"https://relay.example/{}",
|
||||
"a".repeat(MAX_RELAY_URL_LEN)
|
||||
))
|
||||
.unwrap();
|
||||
assert!(long.as_str().len() > MAX_RELAY_URL_LEN);
|
||||
let addr = EndpointAddr::from_parts(
|
||||
id,
|
||||
[TransportAddr::Relay(short.clone()), TransportAddr::Relay(long)],
|
||||
);
|
||||
let out = sanitize_endpoint_addr(&addr);
|
||||
let relays: Vec<_> = out.relay_urls().cloned().collect();
|
||||
assert_eq!(relays, vec![short], "over-long relay URL must be dropped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gossip_message_leave_round_trip() {
|
||||
let original = GossipMessage::Leave;
|
||||
|
||||
@@ -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)]
|
||||
@@ -54,6 +106,10 @@ pub enum RoomEvent {
|
||||
/// from `PeerLeft` precisely so a momentary `NeighborDown` can't tear down the
|
||||
/// reconnect path the way it used to.
|
||||
PeerConnectionLost(EndpointId),
|
||||
/// A validly signed gossip payload was rejected only because its timestamp is
|
||||
/// outside the replay-protection window. The peer is not in the roster yet,
|
||||
/// so this surfaces as a room-level warning instead of a peer-card state.
|
||||
ClockSkewSuspected { author: EndpointId, skew_ms: i64 },
|
||||
/// 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.
|
||||
@@ -250,6 +306,7 @@ mod tests {
|
||||
addr,
|
||||
sharing: None,
|
||||
avatar: crate::avatar::Avatar::default(),
|
||||
game: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,6 +415,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();
|
||||
|
||||
+8
-2
@@ -26,7 +26,13 @@ pub const FRIENDS_PROTO: u32 = 1;
|
||||
/// v2 (0.3.0): `GossipMessage::Chat` gained an optional file attachment
|
||||
/// (`ChatAttachment`), so a pre-v2 peer can't interpret/serve chat files — bumped
|
||||
/// to fail fast rather than half-work.
|
||||
pub const GOSSIP_PROTO: u32 = 2;
|
||||
///
|
||||
/// 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;
|
||||
@@ -41,7 +47,7 @@ pub const FILES_ALPN: &[u8] = b"peerspeak/files/1";
|
||||
/// ed25519 gossip signature domain: `peerspeak-gossip-v<GOSSIP_PROTO>`. Carries
|
||||
/// the gossip protocol version into every signed payload — a version mismatch
|
||||
/// fails verification (cryptographic separation between gossip versions).
|
||||
pub const GOSSIP_SIG_DOMAIN: &str = "peerspeak-gossip-v2";
|
||||
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
|
||||
|
||||
+78
-7
@@ -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.
|
||||
|
||||
+405
-15
@@ -39,6 +39,10 @@ fn pixelpass_path_candidates(dir: &Path) -> [PathBuf; 1] {
|
||||
/// growth, but reject unbounded gossip payloads before the UI offers "Watch".
|
||||
const MAX_TICKET_LEN: usize = 512;
|
||||
|
||||
/// Upper bound on a PipeWire `application.name` we'll pass to `--app`. Real names
|
||||
/// are short ("Firefox", "mpv"); this only guards against a pathological value.
|
||||
const MAX_APP_NAME_LEN: usize = 256;
|
||||
|
||||
/// How long to wait for the host to emit its ticket / the viewer to connect
|
||||
/// before giving up and killing the child. Startup is normally sub-second; this
|
||||
/// is only a safety net so a hung pixelpass can't wedge the caller forever.
|
||||
@@ -64,6 +68,12 @@ pub enum PixelpassEvent {
|
||||
CaptureStarted,
|
||||
/// Host: capture pipeline torn down (on last viewer).
|
||||
CaptureStopped,
|
||||
/// Host (per-app audio): the chosen app's audio is now reaching viewers.
|
||||
AppAudioRouted,
|
||||
/// Host (per-app audio): the chosen app's last audio stream went away. Under
|
||||
/// our `--strict-audio` run this means viewers now hear silence (not the call
|
||||
/// echo) until the app produces audio again — we surface it as a warning.
|
||||
AppAudioLost,
|
||||
/// A recognized event we don't act on (e.g. `host_info`).
|
||||
Other,
|
||||
}
|
||||
@@ -98,6 +108,11 @@ pub fn parse_pixelpass_event(line: &str) -> Option<PixelpassEvent> {
|
||||
Some("stopped") => PixelpassEvent::CaptureStopped,
|
||||
_ => PixelpassEvent::Other,
|
||||
},
|
||||
"app_audio" => match v.get("state").and_then(|s| s.as_str()) {
|
||||
Some("routed") => PixelpassEvent::AppAudioRouted,
|
||||
Some("lost") => PixelpassEvent::AppAudioLost,
|
||||
_ => PixelpassEvent::Other,
|
||||
},
|
||||
_ => PixelpassEvent::Other,
|
||||
};
|
||||
Some(ev)
|
||||
@@ -107,6 +122,144 @@ fn json_u32(v: &serde_json::Value, key: &str) -> u32 {
|
||||
v.get(key).and_then(|x| x.as_u64()).unwrap_or(0) as u32
|
||||
}
|
||||
|
||||
/// Build the argv for a pixelpass *host*. Always `--host --output json`; when
|
||||
/// `audio_app` is `Some`, append `--app=<name> --strict-audio` so pixelpass
|
||||
/// captures only that app's audio instead of the whole desktop sink monitor
|
||||
/// (which contains our own call playout → the viewer would hear themselves
|
||||
/// echoed back, backlog A23).
|
||||
///
|
||||
/// `--strict-audio` is what makes the fix a guarantee rather than best-effort:
|
||||
/// without it, pixelpass falls back to the whole-desktop loopback before the
|
||||
/// app's first stream routes and again if the app's audio later stops — both of
|
||||
/// which reintroduce the echo. With it, the viewer hears only the chosen app (or
|
||||
/// silence), and pixelpass emits `app_audio` events we surface as a warning.
|
||||
///
|
||||
/// The name is passed in the single-token `--app=<name>` form so a value that
|
||||
/// happens to begin with `-` can never be reparsed as a pixelpass flag (clap
|
||||
/// otherwise rejects hyphen-leading option values). The name is locally chosen
|
||||
/// (our own enumeration / the user's pick), not peer-supplied, but is still
|
||||
/// sanitized via [`sanitize_app_name`] before reaching here. Pure: no I/O.
|
||||
pub fn host_args(audio_app: Option<&str>) -> Vec<String> {
|
||||
let mut args = vec![
|
||||
"--host".to_string(),
|
||||
"--output".to_string(),
|
||||
"json".to_string(),
|
||||
];
|
||||
if let Some(name) = audio_app.and_then(sanitize_app_name) {
|
||||
args.push(format!("--app={name}"));
|
||||
args.push("--strict-audio".to_string());
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
/// Validate a locally-chosen audio app name before it becomes a `--app` value:
|
||||
/// trim, reject empty / overlong, and reject names carrying control characters
|
||||
/// (newlines etc.) that have no place in a real `application.name`. `None` means
|
||||
/// "no valid app selected" — the caller then shares the whole desktop audio.
|
||||
pub fn sanitize_app_name(name: &str) -> Option<String> {
|
||||
let name = name.trim();
|
||||
let ok = !name.is_empty()
|
||||
&& name.len() <= MAX_APP_NAME_LEN
|
||||
&& !name.chars().any(|c| c.is_control());
|
||||
ok.then(|| name.to_string())
|
||||
}
|
||||
|
||||
/// Hard cap on how long enumeration waits for `pactl`. It runs inline on the core
|
||||
/// command loop (the picker awaits it before opening), so a wedged/slow `pactl`
|
||||
/// must not stall mute/deafen/leave/stop. On timeout we treat it like any other
|
||||
/// failure: empty list → "All system audio" only.
|
||||
const LIST_APPS_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Enumerate the apps currently sending audio to a sink, deduplicated by
|
||||
/// `application.name`. Mirrors how pixelpass itself builds its interactive
|
||||
/// picker (`pactl -f json list sink-inputs`), so the names we return are exactly
|
||||
/// the ones `--app` matches against. Returns an empty list on any error (pactl
|
||||
/// missing, non-PipeWire host, nothing playing, or [`LIST_APPS_TIMEOUT`] elapsed)
|
||||
/// — a normal, handled state that leaves the picker showing only "All system
|
||||
/// audio".
|
||||
pub async fn list_audio_apps() -> Vec<String> {
|
||||
let run = Command::new("pactl")
|
||||
.args(["-f", "json", "list", "sink-inputs"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
// On [`LIST_APPS_TIMEOUT`] the `output()` future is dropped, which drops
|
||||
// the child — `kill_on_drop(true)` then SIGKILLs and reaps it so a wedged
|
||||
// `pactl` can't linger/accumulate across picker opens (audit P3).
|
||||
.kill_on_drop(true)
|
||||
.output();
|
||||
match tokio::time::timeout(LIST_APPS_TIMEOUT, run).await {
|
||||
Ok(Ok(o)) if o.status.success() => parse_audio_apps(&o.stdout),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Hard cap on the capability probe (`pixelpass --help`). Conservative: a slow or
|
||||
/// hung pixelpass degrades to "strict audio unsupported" → whole-desktop-only
|
||||
/// picker (safe), never a stalled core loop.
|
||||
const HELP_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Whether the resolved pixelpass understands `--strict-audio` (added in pixelpass
|
||||
/// `85fdebe`). peerspeak only offers per-app audio capture when it does: a per-app
|
||||
/// share always appends `--strict-audio`, and an **older** pixelpass would have
|
||||
/// clap reject the unknown flag → the host spawn hard-fails and the share is
|
||||
/// broken (audit P2, version skew). When unsupported the picker degrades to
|
||||
/// whole-desktop audio only — we never silently drop to best-effort `--app`, which
|
||||
/// would reintroduce the call echo (A23).
|
||||
///
|
||||
/// Any probe failure/timeout returns `false` (degrade to the safe path). The
|
||||
/// `--help` child is `kill_on_drop` so a hung pixelpass can't linger.
|
||||
pub async fn supports_strict_audio(bin: &Path) -> bool {
|
||||
let run = Command::new(bin)
|
||||
.arg("--help")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.kill_on_drop(true)
|
||||
.output();
|
||||
match tokio::time::timeout(HELP_PROBE_TIMEOUT, run).await {
|
||||
Ok(Ok(o)) => help_mentions_strict_audio(&o.stdout),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure check: does `pixelpass --help` advertise `--strict-audio`? Matches the
|
||||
/// flag token rather than a whole line, since clap may wrap/realign help text.
|
||||
pub fn help_mentions_strict_audio(help_stdout: &[u8]) -> bool {
|
||||
String::from_utf8_lossy(help_stdout).contains("--strict-audio")
|
||||
}
|
||||
|
||||
/// Parse `pactl -f json list sink-inputs` stdout into a sorted, deduplicated list
|
||||
/// of `application.name`s. Pure: no I/O. Unparseable input yields an empty list.
|
||||
/// Each name is passed through [`sanitize_app_name`] so the picker only ever
|
||||
/// offers names that will actually survive [`host_args`]; otherwise a name that
|
||||
/// parses here but fails sanitization later would be selectable yet silently
|
||||
/// drop the `--app` flag and revert the share to whole-desktop audio (A23 echo).
|
||||
pub fn parse_audio_apps(stdout: &[u8]) -> Vec<String> {
|
||||
let Ok(entries) = serde_json::from_slice::<Vec<SinkInput>>(stdout) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut names: Vec<String> = entries
|
||||
.into_iter()
|
||||
.filter_map(|e| e.properties.application_name)
|
||||
.filter_map(|n| sanitize_app_name(&n))
|
||||
.collect();
|
||||
names.sort_unstable();
|
||||
names.dedup();
|
||||
names
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SinkInput {
|
||||
properties: SinkInputProperties,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SinkInputProperties {
|
||||
#[serde(rename = "application.name")]
|
||||
application_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Build the argv for a pixelpass *viewer*. The `ticket` is peer-supplied (it
|
||||
/// rides gossip presence, which is untrusted and spoofable), so flags come first
|
||||
/// and the ticket is passed as a positional **after a `--` end-of-options
|
||||
@@ -162,20 +315,30 @@ pub fn is_available(config_override: Option<&str>) -> bool {
|
||||
pixelpass_path(config_override).is_some()
|
||||
}
|
||||
|
||||
/// Spawn a pixelpass host (`pixelpass --host --output json`), wait for its
|
||||
/// startup ticket, and return the live child plus the ticket. The child keeps
|
||||
/// Spawn a pixelpass host (`pixelpass --host --output json [--app=<name>]`), wait
|
||||
/// for its startup ticket, and return the live child plus the ticket. When
|
||||
/// `audio_app` is `Some`, pixelpass captures only that app's audio instead of the
|
||||
/// whole desktop sink, which avoids the call-loopback echo (A23). The child keeps
|
||||
/// running (streaming to viewers) until killed or dropped; remaining stdout is
|
||||
/// drained in a background task so a full pipe can't stall the host. We do
|
||||
/// **not** pass `--max-viewers`: pixelpass bandwidth-measures its own safe cap,
|
||||
/// protecting the sharer's uplink, and refuses extras with `viewer_refused`.
|
||||
pub async fn spawn_host(bin: &Path) -> std::io::Result<(Child, String)> {
|
||||
pub async fn spawn_host(
|
||||
bin: &Path,
|
||||
audio_app: Option<&str>,
|
||||
notices: Option<tokio::sync::mpsc::UnboundedSender<PixelpassEvent>>,
|
||||
) -> std::io::Result<(Child, String)> {
|
||||
let mut child = Command::new(bin)
|
||||
.arg("--host")
|
||||
.arg("--output")
|
||||
.arg("json")
|
||||
.args(host_args(audio_app))
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
// Capture stderr (not null): pixelpass prints its startup precondition
|
||||
// failures there — a missing GStreamer plugin / `pactl`, each with an
|
||||
// actionable "Install hint: sudo apt install ..." line. If the host dies
|
||||
// before its ticket we fold that tail into our error so the user sees
|
||||
// *what to install* instead of a dead-end "exited before a ticket". On
|
||||
// the success path we drain it in the background so the pipe can't fill.
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()?;
|
||||
|
||||
@@ -183,6 +346,7 @@ pub async fn spawn_host(bin: &Path) -> std::io::Result<(Child, String)> {
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| std::io::Error::other("pixelpass host stdout missing"))?;
|
||||
let stderr = child.stderr.take();
|
||||
let mut lines = BufReader::new(stdout).lines();
|
||||
|
||||
let ticket = match read_until(&mut lines, |e| match e {
|
||||
@@ -194,9 +358,10 @@ pub async fn spawn_host(bin: &Path) -> std::io::Result<(Child, String)> {
|
||||
Ok(Some(t)) => t,
|
||||
Ok(None) => {
|
||||
let _ = child.kill().await;
|
||||
return Err(std::io::Error::other(
|
||||
"pixelpass host exited before emitting a ticket",
|
||||
));
|
||||
let detail = read_stderr_tail(stderr).await;
|
||||
return Err(std::io::Error::other(format!(
|
||||
"pixelpass host exited before emitting a ticket{detail}"
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = child.kill().await;
|
||||
@@ -204,10 +369,65 @@ pub async fn spawn_host(bin: &Path) -> std::io::Result<(Child, String)> {
|
||||
}
|
||||
};
|
||||
|
||||
drain_in_background(lines, "host");
|
||||
if let Some(stderr) = stderr {
|
||||
drain_stderr_in_background(stderr);
|
||||
}
|
||||
drain_in_background(lines, "host", notices);
|
||||
Ok((child, ticket))
|
||||
}
|
||||
|
||||
/// Read a killed pixelpass child's stderr to EOF and reduce it to a short,
|
||||
/// user-facing diagnostic tail via [`pixelpass_failure_detail`]. Bounded: the
|
||||
/// caller kills the child first, so the pipe EOFs promptly. Returns an empty
|
||||
/// string when stderr was already taken or carried nothing useful.
|
||||
async fn read_stderr_tail(stderr: Option<tokio::process::ChildStderr>) -> String {
|
||||
use tokio::io::AsyncReadExt;
|
||||
let Some(mut stderr) = stderr else {
|
||||
return String::new();
|
||||
};
|
||||
let mut buf = Vec::new();
|
||||
let _ = stderr.read_to_end(&mut buf).await;
|
||||
pixelpass_failure_detail(&String::from_utf8_lossy(&buf))
|
||||
}
|
||||
|
||||
/// Discard a running pixelpass child's stderr in the background so its pipe
|
||||
/// can't fill and stall the host (mirrors [`drain_in_background`] for stdout).
|
||||
fn drain_stderr_in_background(mut stderr: tokio::process::ChildStderr) {
|
||||
use tokio::io::AsyncReadExt;
|
||||
tokio::spawn(async move {
|
||||
let mut buf = [0u8; 4096];
|
||||
while let Ok(n) = stderr.read(&mut buf).await {
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Extract a human-useful tail from a failed pixelpass child's stderr to append
|
||||
/// to our error. pixelpass writes actionable startup errors there (a missing
|
||||
/// GStreamer element / `pactl` plus an `Install hint: sudo apt install ...`
|
||||
/// line), which is exactly what a freshly-installed host needs to see. The
|
||||
/// decorative host banner (box-drawing) is dropped — it only prints on the
|
||||
/// success path, but we filter it defensively. Pure: no I/O. Returns an empty
|
||||
/// string when there's nothing worth surfacing (so callers can append blindly).
|
||||
pub fn pixelpass_failure_detail(stderr: &str) -> String {
|
||||
let useful: Vec<&str> = stderr
|
||||
.lines()
|
||||
.map(str::trim_end)
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.filter(|l| !l.trim_start().starts_with(['│', '┌', '└', '├']))
|
||||
.collect();
|
||||
if useful.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
// The anyhow error and its install hint are the *last* lines printed, so
|
||||
// keep the tail rather than the head.
|
||||
const MAX_LINES: usize = 12;
|
||||
let start = useful.len().saturating_sub(MAX_LINES);
|
||||
format!("\n\npixelpass reported:\n{}", useful[start..].join("\n"))
|
||||
}
|
||||
|
||||
/// Spawn a pixelpass viewer for `ticket`, wait for it to connect, and open the
|
||||
/// stream in a local player (mpv, falling back to vlc). Returns the live viewer
|
||||
/// child so the caller can kill it on room-leave; it also self-exits when the
|
||||
@@ -251,7 +471,7 @@ pub async fn spawn_viewer(bin: &Path, ticket: &str) -> std::io::Result<Child> {
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
drain_in_background(lines, "viewer");
|
||||
drain_in_background(lines, "viewer", None);
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
@@ -286,15 +506,24 @@ where
|
||||
}
|
||||
|
||||
/// Keep reading the child's stdout to EOF in the background so a full pipe can't
|
||||
/// stall it; log notable events for diagnostics.
|
||||
fn drain_in_background<R>(mut lines: tokio::io::Lines<BufReader<R>>, role: &'static str)
|
||||
where
|
||||
/// stall it; log notable events for diagnostics. When `notices` is `Some`, each
|
||||
/// parsed event is also forwarded to the caller (the core, which translates the
|
||||
/// `app_audio` ones into a UI warning); a send failure (receiver dropped) just
|
||||
/// stops forwarding, draining continues. The task ends on EOF (child exited).
|
||||
fn drain_in_background<R>(
|
||||
mut lines: tokio::io::Lines<BufReader<R>>,
|
||||
role: &'static str,
|
||||
notices: Option<tokio::sync::mpsc::UnboundedSender<PixelpassEvent>>,
|
||||
) where
|
||||
R: tokio::io::AsyncRead + Unpin + Send + 'static,
|
||||
{
|
||||
tokio::spawn(async move {
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if let Some(ev) = parse_pixelpass_event(&line) {
|
||||
crate::log_msg(&format!("pixelpass {role}: {}", event_for_log(&ev)));
|
||||
if let Some(tx) = ¬ices {
|
||||
let _ = tx.send(ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -313,6 +542,8 @@ fn event_for_log(ev: &PixelpassEvent) -> String {
|
||||
PixelpassEvent::Refused(reason) => format!("viewer_refused reason={reason:?}"),
|
||||
PixelpassEvent::CaptureStarted => "capture_started".to_string(),
|
||||
PixelpassEvent::CaptureStopped => "capture_stopped".to_string(),
|
||||
PixelpassEvent::AppAudioRouted => "app_audio_routed".to_string(),
|
||||
PixelpassEvent::AppAudioLost => "app_audio_lost".to_string(),
|
||||
PixelpassEvent::Other => "other".to_string(),
|
||||
}
|
||||
}
|
||||
@@ -384,6 +615,142 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_args_without_app_shares_whole_desktop() {
|
||||
// No app selected → no --app flag → pixelpass keeps its default
|
||||
// (whole-desktop) audio capture.
|
||||
assert_eq!(host_args(None), vec!["--host", "--output", "json"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_args_with_app_appends_single_token_flag() {
|
||||
// The chosen app rides in the `--app=<name>` single-token form so a
|
||||
// name beginning with `-` can never be reparsed as a flag (A23), plus
|
||||
// `--strict-audio` so pixelpass never falls back to whole-desktop audio.
|
||||
assert_eq!(
|
||||
host_args(Some("Firefox")),
|
||||
vec!["--host", "--output", "json", "--app=Firefox", "--strict-audio"]
|
||||
);
|
||||
// The hyphen-leading name is still bound to --app as a single token;
|
||||
// --strict-audio is the trailing flag.
|
||||
let args = host_args(Some("-rm -rf"));
|
||||
assert_eq!(args[3], "--app=-rm -rf");
|
||||
assert_eq!(args[4], "--strict-audio");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_args_blank_or_control_app_is_dropped() {
|
||||
// An empty / whitespace / control-laden selection is sanitized away,
|
||||
// falling back to whole-desktop capture rather than a broken flag.
|
||||
assert_eq!(host_args(Some(" ")), vec!["--host", "--output", "json"]);
|
||||
assert_eq!(host_args(Some("bad\nname")), vec!["--host", "--output", "json"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_app_name_trims_and_rejects_garbage() {
|
||||
assert_eq!(sanitize_app_name(" Firefox \n"), Some("Firefox".to_string()));
|
||||
assert_eq!(sanitize_app_name(""), None);
|
||||
assert_eq!(sanitize_app_name(" "), None);
|
||||
assert_eq!(sanitize_app_name("a\tb"), None);
|
||||
assert_eq!(sanitize_app_name(&"x".repeat(MAX_APP_NAME_LEN + 1)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_audio_apps_dedups_and_sorts_by_application_name() {
|
||||
let stdout = br#"[
|
||||
{"index":1,"properties":{"application.name":"Firefox"}},
|
||||
{"index":2,"properties":{"application.name":"mpv"}},
|
||||
{"index":3,"properties":{"application.name":"Firefox"}},
|
||||
{"index":4,"properties":{"application.name":" Spotify "}},
|
||||
{"index":5,"properties":{"application.name":""}},
|
||||
{"index":6,"properties":{"other":"no name here"}}
|
||||
]"#;
|
||||
assert_eq!(
|
||||
parse_audio_apps(stdout),
|
||||
vec!["Firefox".to_string(), "Spotify".to_string(), "mpv".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_audio_apps_empty_or_garbage_is_empty() {
|
||||
assert_eq!(parse_audio_apps(b""), Vec::<String>::new());
|
||||
assert_eq!(parse_audio_apps(b"not json"), Vec::<String>::new());
|
||||
assert_eq!(parse_audio_apps(b"[]"), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_audio_apps_drops_names_host_args_would_reject() {
|
||||
// Names that parse from pactl but fail `sanitize_app_name` (control chars,
|
||||
// overlong) must NOT be offered in the picker — otherwise the user could
|
||||
// pick one, `host_args` would silently drop `--app`, and the share would
|
||||
// revert to whole-desktop audio (A23 echo) with no signal. The valid name
|
||||
// survives; the control-char and overlong ones are filtered out.
|
||||
let overlong = "x".repeat(MAX_APP_NAME_LEN + 1);
|
||||
let stdout = format!(
|
||||
r#"[
|
||||
{{"index":1,"properties":{{"application.name":"mpv"}}}},
|
||||
{{"index":2,"properties":{{"application.name":"bad\nname"}}}},
|
||||
{{"index":3,"properties":{{"application.name":"{overlong}"}}}}
|
||||
]"#
|
||||
);
|
||||
assert_eq!(parse_audio_apps(stdout.as_bytes()), vec!["mpv".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_detail_surfaces_install_hint_and_drops_banner() {
|
||||
// The real shape of a fresh-host failure: anyhow error + install hint on
|
||||
// stderr. We must keep those (so the user knows what to apt install) and
|
||||
// drop the decorative banner box-drawing lines.
|
||||
let stderr = "\
|
||||
┌─ PixelPass · host ─────────────────────────────────────────
|
||||
│ display server : Wayland
|
||||
└────────────────────────────────────────────────────────────
|
||||
Error: GStreamer element `vah264enc` not available.
|
||||
Install hint: sudo apt install gstreamer1.0-plugins-bad
|
||||
";
|
||||
let detail = pixelpass_failure_detail(stderr);
|
||||
assert!(detail.starts_with("\n\npixelpass reported:\n"));
|
||||
assert!(detail.contains("vah264enc` not available"));
|
||||
assert!(detail.contains("sudo apt install gstreamer1.0-plugins-bad"));
|
||||
assert!(!detail.contains('│'), "banner box-drawing must be dropped");
|
||||
assert!(!detail.contains('┌'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_detail_empty_when_nothing_useful() {
|
||||
// Blank / banner-only stderr yields an empty string so the caller can
|
||||
// append it to the base message unconditionally without trailing noise.
|
||||
assert_eq!(pixelpass_failure_detail(""), "");
|
||||
assert_eq!(pixelpass_failure_detail(" \n \n"), "");
|
||||
assert_eq!(
|
||||
pixelpass_failure_detail("│ display server : Wayland\n│ capture : x\n"),
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_detail_keeps_only_the_tail() {
|
||||
// A long stderr is truncated to its last lines (where the real error
|
||||
// and hint live), not its head.
|
||||
let body: String = (0..30).map(|i| format!("line {i}\n")).collect();
|
||||
let detail = pixelpass_failure_detail(&body);
|
||||
assert!(detail.contains("line 29"));
|
||||
assert!(!detail.contains("line 0\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_probe_detects_strict_audio_flag() {
|
||||
// A new pixelpass advertises the flag; an old one doesn't. The probe must
|
||||
// match the token even when clap wraps the option onto its own line.
|
||||
let new_help = b"Options:\n --app <APP>\n --strict-audio\n With --app, never fall back...";
|
||||
assert!(help_mentions_strict_audio(new_help));
|
||||
let old_help = b"Options:\n --app <APP>\n --output <OUTPUT>\n -h, --help";
|
||||
assert!(!help_mentions_strict_audio(old_help));
|
||||
// Garbage / empty output degrades to "unsupported" (safe path).
|
||||
assert!(!help_mentions_strict_audio(b""));
|
||||
assert!(!help_mentions_strict_audio(&[0xff, 0xfe, 0x00]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_ticket_accepts_pixelpass_endpoint_ticket_shape() {
|
||||
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm";
|
||||
@@ -470,6 +837,29 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_app_audio_states() {
|
||||
// The wire contract from pixelpass's --strict-audio run (A23): routed =
|
||||
// the chosen app's audio is live; lost = it stopped (viewers now silent).
|
||||
assert_eq!(
|
||||
parse_pixelpass_event(r#"{"event":"app_audio","state":"routed"}"#),
|
||||
Some(PixelpassEvent::AppAudioRouted)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_pixelpass_event(r#"{"event":"app_audio","state":"lost"}"#),
|
||||
Some(PixelpassEvent::AppAudioLost)
|
||||
);
|
||||
// Unknown / missing state is recognized-but-unused, not a parse failure.
|
||||
assert_eq!(
|
||||
parse_pixelpass_event(r#"{"event":"app_audio","state":"weird"}"#),
|
||||
Some(PixelpassEvent::Other)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_pixelpass_event(r#"{"event":"app_audio"}"#),
|
||||
Some(PixelpassEvent::Other)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognized_but_unused_event_is_other() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -0,0 +1,943 @@
|
||||
use iced::advanced::clipboard::{self, Clipboard};
|
||||
use iced::advanced::layout;
|
||||
use iced::advanced::mouse;
|
||||
use iced::advanced::overlay;
|
||||
use iced::advanced::renderer;
|
||||
use iced::advanced::text;
|
||||
use iced::advanced::widget::tree::{self, Tree};
|
||||
use iced::advanced::widget::{self, Widget};
|
||||
use iced::advanced::{Layout, Shell};
|
||||
use iced::widget::text_input;
|
||||
use iced::{
|
||||
alignment, Background, Border, Color, Element, Event, Length, Padding,
|
||||
Pixels, Point, Rectangle, Shadow, Size, Vector,
|
||||
};
|
||||
use std::rc::Rc;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Edit {
|
||||
pub value: String,
|
||||
pub cursor: usize,
|
||||
}
|
||||
|
||||
pub fn copy_selection(value: &str, start: usize, end: usize) -> Option<String> {
|
||||
let value = text_input::Value::new(value);
|
||||
let (start, end) = normalized_range(&value, start, end);
|
||||
|
||||
(start != end).then(|| value.select(start, end).to_string())
|
||||
}
|
||||
|
||||
pub fn cut_selection(
|
||||
value: &str,
|
||||
start: usize,
|
||||
end: usize,
|
||||
) -> (Edit, Option<String>) {
|
||||
let mut value = text_input::Value::new(value);
|
||||
let (start, end) = normalized_range(&value, start, end);
|
||||
|
||||
if start == end {
|
||||
return (
|
||||
Edit {
|
||||
value: value.to_string(),
|
||||
cursor: start,
|
||||
},
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
let selected = value.select(start, end).to_string();
|
||||
value.remove_many(start, end);
|
||||
|
||||
(
|
||||
Edit {
|
||||
value: value.to_string(),
|
||||
cursor: start,
|
||||
},
|
||||
Some(selected),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn paste(value: &str, start: usize, end: usize, clip: &str) -> Edit {
|
||||
let mut value = text_input::Value::new(value);
|
||||
let (start, end) = normalized_range(&value, start, end);
|
||||
let clip = text_input::Value::new(clip);
|
||||
let cursor = start + clip.len();
|
||||
|
||||
if start != end {
|
||||
value.remove_many(start, end);
|
||||
}
|
||||
value.insert_many(start, clip);
|
||||
|
||||
Edit {
|
||||
value: value.to_string(),
|
||||
cursor,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_all_range(value: &str) -> (usize, usize) {
|
||||
let value = text_input::Value::new(value);
|
||||
|
||||
(0, value.len())
|
||||
}
|
||||
|
||||
fn normalized_range(
|
||||
value: &text_input::Value,
|
||||
start: usize,
|
||||
end: usize,
|
||||
) -> (usize, usize) {
|
||||
let len = value.len();
|
||||
|
||||
(start.min(end).min(len), start.max(end).min(len))
|
||||
}
|
||||
|
||||
type InputStyleFn<'a, Theme> =
|
||||
Rc<dyn Fn(&Theme, text_input::Status) -> text_input::Style + 'a>;
|
||||
|
||||
pub fn context_input<'a, Message, Theme, Renderer>(
|
||||
placeholder: &str,
|
||||
value: &str,
|
||||
) -> ContextInput<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Message: Clone + 'a,
|
||||
Theme: text_input::Catalog + 'a,
|
||||
Renderer: text::Renderer,
|
||||
{
|
||||
ContextInput::new(placeholder, value)
|
||||
}
|
||||
|
||||
pub fn locked_value<'a, Message, Theme, Renderer>(
|
||||
value: &str,
|
||||
noop: Message,
|
||||
) -> ContextInput<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Message: Clone + 'a,
|
||||
Theme: text_input::Catalog + 'a,
|
||||
Renderer: text::Renderer,
|
||||
{
|
||||
ContextInput::new("", value)
|
||||
.on_input(move |_| noop.clone())
|
||||
.locked(true)
|
||||
}
|
||||
|
||||
pub struct ContextInput<
|
||||
'a,
|
||||
Message,
|
||||
Theme = iced::Theme,
|
||||
Renderer = iced::Renderer,
|
||||
> where
|
||||
Theme: text_input::Catalog,
|
||||
Renderer: text::Renderer,
|
||||
{
|
||||
input: text_input::TextInput<'a, Message, Theme, Renderer>,
|
||||
value: String,
|
||||
is_secure: bool,
|
||||
locked: bool,
|
||||
on_input: Option<Rc<dyn Fn(String) -> Message + 'a>>,
|
||||
on_paste: Option<Rc<dyn Fn(String) -> Message + 'a>>,
|
||||
style: Option<InputStyleFn<'a, Theme>>,
|
||||
}
|
||||
|
||||
impl<'a, Message, Theme, Renderer>
|
||||
ContextInput<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Message: Clone + 'a,
|
||||
Theme: text_input::Catalog + 'a,
|
||||
Renderer: text::Renderer,
|
||||
{
|
||||
pub fn new(placeholder: &str, value: &str) -> Self {
|
||||
Self {
|
||||
input: text_input::TextInput::new(placeholder, value),
|
||||
value: value.to_owned(),
|
||||
is_secure: false,
|
||||
locked: false,
|
||||
on_input: None,
|
||||
on_paste: None,
|
||||
style: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(mut self, id: impl Into<widget::Id>) -> Self {
|
||||
self.input = self.input.id(id);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn secure(mut self, is_secure: bool) -> Self {
|
||||
self.is_secure = is_secure;
|
||||
self.input = self.input.secure(is_secure);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn locked(mut self, yes: bool) -> Self {
|
||||
self.locked = yes;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_input(
|
||||
mut self,
|
||||
on_input: impl Fn(String) -> Message + 'a,
|
||||
) -> Self {
|
||||
let on_input: Rc<dyn Fn(String) -> Message + 'a> =
|
||||
Rc::new(on_input);
|
||||
let input_callback = Rc::clone(&on_input);
|
||||
|
||||
self.input =
|
||||
self.input.on_input(move |value| input_callback.as_ref()(value));
|
||||
self.on_input = Some(on_input);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_submit(mut self, message: Message) -> Self {
|
||||
self.input = self.input.on_submit(message);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_submit_maybe(mut self, message: Option<Message>) -> Self {
|
||||
self.input = self.input.on_submit_maybe(message);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_paste(
|
||||
mut self,
|
||||
on_paste: impl Fn(String) -> Message + 'a,
|
||||
) -> Self {
|
||||
let on_paste: Rc<dyn Fn(String) -> Message + 'a> =
|
||||
Rc::new(on_paste);
|
||||
let paste_callback = Rc::clone(&on_paste);
|
||||
|
||||
self.input =
|
||||
self.input.on_paste(move |value| paste_callback.as_ref()(value));
|
||||
self.on_paste = Some(on_paste);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn font(mut self, font: Renderer::Font) -> Self {
|
||||
self.input = self.input.font(font);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn icon(mut self, icon: text_input::Icon<Renderer::Font>) -> Self {
|
||||
self.input = self.input.icon(icon);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn width(mut self, width: impl Into<Length>) -> Self {
|
||||
self.input = self.input.width(width);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn padding<P: Into<Padding>>(mut self, padding: P) -> Self {
|
||||
self.input = self.input.padding(padding);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn size(mut self, size: impl Into<Pixels>) -> Self {
|
||||
self.input = self.input.size(size);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn line_height(
|
||||
mut self,
|
||||
line_height: impl Into<text::LineHeight>,
|
||||
) -> Self {
|
||||
self.input = self.input.line_height(line_height);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn align_x(
|
||||
mut self,
|
||||
alignment: impl Into<alignment::Horizontal>,
|
||||
) -> Self {
|
||||
self.input = self.input.align_x(alignment);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn style(
|
||||
mut self,
|
||||
style: impl Fn(&Theme, text_input::Status) -> text_input::Style + 'a,
|
||||
) -> Self
|
||||
where
|
||||
Theme::Class<'a>: From<text_input::StyleFn<'a, Theme>>,
|
||||
{
|
||||
let style: InputStyleFn<'a, Theme> = Rc::new(style);
|
||||
let input_style = Rc::clone(&style);
|
||||
|
||||
self.input = self
|
||||
.input
|
||||
.style(move |theme, status| input_style.as_ref()(theme, status));
|
||||
self.style = Some(style);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn class(mut self, class: impl Into<Theme::Class<'a>>) -> Self {
|
||||
self.input = self.input.class(class);
|
||||
self.style = None;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ContextInputState {
|
||||
menu: Option<MenuState>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct MenuState {
|
||||
anchor: Point,
|
||||
selection: (usize, usize),
|
||||
}
|
||||
|
||||
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
|
||||
for ContextInput<'_, Message, Theme, Renderer>
|
||||
where
|
||||
Message: Clone,
|
||||
Theme: text_input::Catalog,
|
||||
Renderer: text::Renderer,
|
||||
{
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<ContextInputState>()
|
||||
}
|
||||
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(ContextInputState::default())
|
||||
}
|
||||
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
vec![Tree::new(&self.input as &dyn Widget<_, _, _>)]
|
||||
}
|
||||
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
if tree.children.is_empty() {
|
||||
tree.children
|
||||
.push(Tree::new(&self.input as &dyn Widget<_, _, _>));
|
||||
} else {
|
||||
tree.children[0].diff(&self.input as &dyn Widget<_, _, _>);
|
||||
tree.children.truncate(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn size(&self) -> Size<Length> {
|
||||
Widget::size(&self.input)
|
||||
}
|
||||
|
||||
fn size_hint(&self) -> Size<Length> {
|
||||
Widget::size_hint(&self.input)
|
||||
}
|
||||
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
Widget::layout(&mut self.input, &mut tree.children[0], renderer, limits)
|
||||
}
|
||||
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &Renderer,
|
||||
operation: &mut dyn widget::Operation,
|
||||
) {
|
||||
Widget::operate(
|
||||
&mut self.input,
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
operation,
|
||||
);
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &Renderer,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let right_click_on_input = matches!(
|
||||
event,
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right))
|
||||
) && cursor.is_over(layout.bounds());
|
||||
|
||||
if right_click_on_input {
|
||||
let value = text_input::Value::new(&self.value);
|
||||
let input_state = tree.children[0]
|
||||
.state
|
||||
.downcast_ref::<text_input::State<Renderer::Paragraph>>();
|
||||
let selection = match input_state.cursor().state(&value) {
|
||||
text_input::cursor::State::Index(index) => {
|
||||
let index = index.min(value.len());
|
||||
(index, index)
|
||||
}
|
||||
text_input::cursor::State::Selection { start, end } => {
|
||||
normalized_range(&value, start, end)
|
||||
}
|
||||
};
|
||||
|
||||
tree.state.downcast_mut::<ContextInputState>().menu =
|
||||
cursor.position().map(|anchor| MenuState {
|
||||
anchor,
|
||||
selection,
|
||||
});
|
||||
|
||||
shell.capture_event();
|
||||
shell.request_redraw();
|
||||
return;
|
||||
}
|
||||
|
||||
Widget::update(
|
||||
&mut self.input,
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut Renderer,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
Widget::draw(
|
||||
&self.input,
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
Widget::mouse_interaction(
|
||||
&self.input,
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
|
||||
fn overlay<'a>(
|
||||
&'a mut self,
|
||||
tree: &'a mut Tree,
|
||||
_layout: Layout<'a>,
|
||||
_renderer: &Renderer,
|
||||
_viewport: &Rectangle,
|
||||
_translation: Vector,
|
||||
) -> Option<overlay::Element<'a, Message, Theme, Renderer>> {
|
||||
let Tree {
|
||||
state, children, ..
|
||||
} = tree;
|
||||
let menu = &mut state.downcast_mut::<ContextInputState>().menu;
|
||||
|
||||
if menu.is_none() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let input_state = children[0]
|
||||
.state
|
||||
.downcast_mut::<text_input::State<Renderer::Paragraph>>();
|
||||
|
||||
Some(overlay::Element::new(Box::new(ContextMenuOverlay {
|
||||
menu,
|
||||
input_state,
|
||||
value: &self.value,
|
||||
is_secure: self.is_secure,
|
||||
locked: self.locked,
|
||||
on_input: self.on_input.clone(),
|
||||
on_paste: self.on_paste.clone(),
|
||||
style: self.style.clone(),
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, Message, Theme, Renderer>
|
||||
From<ContextInput<'a, Message, Theme, Renderer>>
|
||||
for Element<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Message: Clone + 'a,
|
||||
Theme: text_input::Catalog + 'a,
|
||||
Renderer: text::Renderer + 'a,
|
||||
{
|
||||
fn from(
|
||||
input: ContextInput<'a, Message, Theme, Renderer>,
|
||||
) -> Element<'a, Message, Theme, Renderer> {
|
||||
Element::new(input)
|
||||
}
|
||||
}
|
||||
|
||||
struct ContextMenuOverlay<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Theme: text_input::Catalog,
|
||||
Renderer: text::Renderer,
|
||||
{
|
||||
menu: &'a mut Option<MenuState>,
|
||||
input_state: &'a mut text_input::State<Renderer::Paragraph>,
|
||||
value: &'a str,
|
||||
is_secure: bool,
|
||||
locked: bool,
|
||||
on_input: Option<Rc<dyn Fn(String) -> Message + 'a>>,
|
||||
on_paste: Option<Rc<dyn Fn(String) -> Message + 'a>>,
|
||||
style: Option<InputStyleFn<'a, Theme>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum MenuAction {
|
||||
Cut,
|
||||
Copy,
|
||||
Paste,
|
||||
SelectAll,
|
||||
}
|
||||
|
||||
impl MenuAction {
|
||||
const ALL: [Self; 4] = [
|
||||
Self::Cut,
|
||||
Self::Copy,
|
||||
Self::Paste,
|
||||
Self::SelectAll,
|
||||
];
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Cut => "Cut",
|
||||
Self::Copy => "Copy",
|
||||
Self::Paste => "Paste",
|
||||
Self::SelectAll => "Select All",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MENU_WIDTH: f32 = 136.0;
|
||||
const ITEM_HEIGHT: f32 = 28.0;
|
||||
const TEXT_SIZE: f32 = 13.0;
|
||||
const MENU_PADDING_X: f32 = 10.0;
|
||||
|
||||
impl<Message, Theme, Renderer> overlay::Overlay<Message, Theme, Renderer>
|
||||
for ContextMenuOverlay<'_, Message, Theme, Renderer>
|
||||
where
|
||||
Theme: text_input::Catalog,
|
||||
Renderer: text::Renderer,
|
||||
{
|
||||
fn layout(&mut self, _renderer: &Renderer, bounds: Size) -> layout::Node {
|
||||
let size = Size::new(MENU_WIDTH, ITEM_HEIGHT * MenuAction::ALL.len() as f32);
|
||||
let Some(menu) = self.menu.as_ref() else {
|
||||
return layout::Node::new(Size::ZERO);
|
||||
};
|
||||
let x = menu.anchor.x.min((bounds.width - size.width).max(0.0));
|
||||
let y = menu.anchor.y.min((bounds.height - size.height).max(0.0));
|
||||
|
||||
layout::Node::new(size).move_to(Point::new(x.max(0.0), y.max(0.0)))
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
renderer: &mut Renderer,
|
||||
theme: &Theme,
|
||||
_style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
) {
|
||||
let active_style = input_style(theme, self.style.as_ref(), text_input::Status::Active);
|
||||
let hovered_style =
|
||||
input_style(theme, self.style.as_ref(), text_input::Status::Hovered);
|
||||
let bounds = layout.bounds();
|
||||
let viewport = Rectangle::INFINITE;
|
||||
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds,
|
||||
border: Border {
|
||||
radius: 5.0.into(),
|
||||
width: 1.0,
|
||||
color: active_style.border.color,
|
||||
},
|
||||
shadow: Shadow {
|
||||
color: Color::from_rgba(0.0, 0.0, 0.0, 0.22),
|
||||
offset: Vector::new(0.0, 4.0),
|
||||
blur_radius: 10.0,
|
||||
},
|
||||
..renderer::Quad::default()
|
||||
},
|
||||
active_style.background,
|
||||
);
|
||||
|
||||
for (index, action) in MenuAction::ALL.iter().copied().enumerate() {
|
||||
let item_bounds = item_bounds(bounds, index);
|
||||
let enabled = self.enabled(action);
|
||||
let hovered = enabled && cursor.is_over(item_bounds);
|
||||
|
||||
if hovered {
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: item_bounds,
|
||||
border: Border {
|
||||
radius: 3.0.into(),
|
||||
..Border::default()
|
||||
},
|
||||
..renderer::Quad::default()
|
||||
},
|
||||
Background::Color(hovered_style.selection),
|
||||
);
|
||||
}
|
||||
|
||||
renderer.fill_text(
|
||||
text::Text {
|
||||
content: action.label().to_owned(),
|
||||
bounds: Size::new(item_bounds.width - MENU_PADDING_X * 2.0, item_bounds.height),
|
||||
size: Pixels(TEXT_SIZE),
|
||||
line_height: text::LineHeight::default(),
|
||||
font: renderer.default_font(),
|
||||
align_x: text::Alignment::Default,
|
||||
align_y: alignment::Vertical::Center,
|
||||
shaping: text::Shaping::Advanced,
|
||||
wrapping: text::Wrapping::default(),
|
||||
},
|
||||
Point::new(item_bounds.x + MENU_PADDING_X, item_bounds.center_y()),
|
||||
if enabled {
|
||||
active_style.value
|
||||
} else {
|
||||
disabled_color(active_style.value)
|
||||
},
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
_renderer: &Renderer,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
) {
|
||||
match event {
|
||||
Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key: iced::keyboard::Key::Named(
|
||||
iced::keyboard::key::Named::Escape,
|
||||
),
|
||||
..
|
||||
}) => {
|
||||
self.close(shell);
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
|
||||
let Some(position) = cursor.position() else {
|
||||
self.close(shell);
|
||||
return;
|
||||
};
|
||||
let bounds = layout.bounds();
|
||||
|
||||
if !bounds.contains(position) {
|
||||
self.close(shell);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(action) = self.hit_action(bounds, position) {
|
||||
if self.enabled(action) {
|
||||
self.perform(action, clipboard, shell);
|
||||
}
|
||||
self.close(shell);
|
||||
}
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonPressed(_)) => {
|
||||
let should_close = cursor
|
||||
.position()
|
||||
.is_none_or(|position| !layout.bounds().contains(position));
|
||||
|
||||
if should_close {
|
||||
self.close(shell);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
_renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
let Some(position) = cursor.position() else {
|
||||
return mouse::Interaction::default();
|
||||
};
|
||||
|
||||
if self.hit_action(layout.bounds(), position).is_some() {
|
||||
mouse::Interaction::Pointer
|
||||
} else {
|
||||
mouse::Interaction::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<Message, Theme, Renderer> ContextMenuOverlay<'_, Message, Theme, Renderer>
|
||||
where
|
||||
Theme: text_input::Catalog,
|
||||
Renderer: text::Renderer,
|
||||
{
|
||||
fn enabled(&self, action: MenuAction) -> bool {
|
||||
let Some(menu) = self.menu.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
let has_selection = menu.selection.0 != menu.selection.1;
|
||||
let has_value = !text_input::Value::new(self.value).is_empty();
|
||||
|
||||
menu_action_enabled(
|
||||
action,
|
||||
has_selection,
|
||||
has_value,
|
||||
self.is_secure,
|
||||
self.locked,
|
||||
)
|
||||
}
|
||||
|
||||
fn hit_action(
|
||||
&self,
|
||||
bounds: Rectangle,
|
||||
position: Point,
|
||||
) -> Option<MenuAction> {
|
||||
if !bounds.contains(position) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let index = ((position.y - bounds.y) / ITEM_HEIGHT).floor() as usize;
|
||||
|
||||
MenuAction::ALL.get(index).copied()
|
||||
}
|
||||
|
||||
fn perform(
|
||||
&mut self,
|
||||
action: MenuAction,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
) {
|
||||
let Some(menu) = self.menu.as_ref().copied() else {
|
||||
return;
|
||||
};
|
||||
let (start, end) = menu.selection;
|
||||
|
||||
match action {
|
||||
MenuAction::Cut => {
|
||||
let (edit, selected) = cut_selection(self.value, start, end);
|
||||
|
||||
if let Some(selected) = selected {
|
||||
clipboard.write(clipboard::Kind::Standard, selected);
|
||||
self.publish_edit(edit, shell);
|
||||
}
|
||||
}
|
||||
MenuAction::Copy => {
|
||||
if let Some(selected) = copy_selection(self.value, start, end) {
|
||||
clipboard.write(clipboard::Kind::Standard, selected);
|
||||
}
|
||||
}
|
||||
MenuAction::Paste => {
|
||||
let clip = clipboard
|
||||
.read(clipboard::Kind::Standard)
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.filter(|c| !c.is_control())
|
||||
.collect::<String>();
|
||||
let edit = paste(self.value, start, end, &clip);
|
||||
|
||||
self.publish_paste(edit, shell);
|
||||
}
|
||||
MenuAction::SelectAll => {
|
||||
let (start, end) = select_all_range(self.value);
|
||||
|
||||
self.input_state.select_range(start, end);
|
||||
shell.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn publish_edit(&mut self, edit: Edit, shell: &mut Shell<'_, Message>) {
|
||||
if let Some(on_input) = &self.on_input {
|
||||
self.input_state.move_cursor_to(edit.cursor);
|
||||
shell.publish(on_input.as_ref()(edit.value));
|
||||
shell.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
fn publish_paste(&mut self, edit: Edit, shell: &mut Shell<'_, Message>) {
|
||||
self.input_state.move_cursor_to(edit.cursor);
|
||||
|
||||
if let Some(on_paste) = &self.on_paste {
|
||||
shell.publish(on_paste.as_ref()(edit.value));
|
||||
} else if let Some(on_input) = &self.on_input {
|
||||
shell.publish(on_input.as_ref()(edit.value));
|
||||
}
|
||||
|
||||
shell.request_redraw();
|
||||
}
|
||||
|
||||
fn close(&mut self, shell: &mut Shell<'_, Message>) {
|
||||
*self.menu = None;
|
||||
shell.capture_event();
|
||||
shell.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
fn item_bounds(menu_bounds: Rectangle, index: usize) -> Rectangle {
|
||||
Rectangle {
|
||||
x: menu_bounds.x + 3.0,
|
||||
y: menu_bounds.y + 3.0 + ITEM_HEIGHT * index as f32,
|
||||
width: menu_bounds.width - 6.0,
|
||||
height: ITEM_HEIGHT,
|
||||
}
|
||||
}
|
||||
|
||||
fn input_style<Theme: text_input::Catalog>(
|
||||
theme: &Theme,
|
||||
style: Option<&InputStyleFn<'_, Theme>>,
|
||||
status: text_input::Status,
|
||||
) -> text_input::Style {
|
||||
if let Some(style) = style {
|
||||
style.as_ref()(theme, status)
|
||||
} else {
|
||||
let class = <Theme as text_input::Catalog>::default();
|
||||
|
||||
theme.style(&class, status)
|
||||
}
|
||||
}
|
||||
|
||||
fn disabled_color(color: Color) -> Color {
|
||||
Color {
|
||||
a: color.a * 0.45,
|
||||
..color
|
||||
}
|
||||
}
|
||||
|
||||
fn menu_action_enabled(
|
||||
action: MenuAction,
|
||||
has_selection: bool,
|
||||
has_value: bool,
|
||||
is_secure: bool,
|
||||
locked: bool,
|
||||
) -> bool {
|
||||
match action {
|
||||
MenuAction::Cut => has_selection && !is_secure && !locked,
|
||||
MenuAction::Copy => has_selection && !is_secure,
|
||||
MenuAction::Paste => !locked,
|
||||
MenuAction::SelectAll => has_value,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn copy_selection_returns_middle_substring_and_empty_none() {
|
||||
assert_eq!(copy_selection("abcdef", 2, 5), Some("cde".to_owned()));
|
||||
assert_eq!(copy_selection("abcdef", 3, 3), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_selection_removes_range_and_copies_selection() {
|
||||
let (edit, clip) = cut_selection("abcdef", 2, 5);
|
||||
|
||||
assert_eq!(
|
||||
edit,
|
||||
Edit {
|
||||
value: "abf".to_owned(),
|
||||
cursor: 2,
|
||||
}
|
||||
);
|
||||
assert_eq!(clip, Some("cde".to_owned()));
|
||||
|
||||
let (edit, clip) = cut_selection("abcdef", 3, 3);
|
||||
assert_eq!(
|
||||
edit,
|
||||
Edit {
|
||||
value: "abcdef".to_owned(),
|
||||
cursor: 3,
|
||||
}
|
||||
);
|
||||
assert_eq!(clip, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_replaces_selection_or_inserts_at_cursor() {
|
||||
assert_eq!(
|
||||
paste("abcdef", 2, 5, "XY"),
|
||||
Edit {
|
||||
value: "abXYf".to_owned(),
|
||||
cursor: 4,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
paste("abcdef", 3, 3, "XY"),
|
||||
Edit {
|
||||
value: "abcXYdef".to_owned(),
|
||||
cursor: 5,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_all_range_uses_grapheme_length() {
|
||||
assert_eq!(select_all_range(""), (0, 0));
|
||||
assert_eq!(select_all_range("abé🦀"), (0, 4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_selection_boundaries_are_grapheme_correct() {
|
||||
assert_eq!(copy_selection("aé🦀z", 1, 3), Some("é🦀".to_owned()));
|
||||
|
||||
let (edit, clip) = cut_selection("aé🦀z", 2, 3);
|
||||
assert_eq!(clip, Some("🦀".to_owned()));
|
||||
assert_eq!(
|
||||
edit,
|
||||
Edit {
|
||||
value: "aéz".to_owned(),
|
||||
cursor: 2,
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
paste("aéz", 2, 2, "🦀"),
|
||||
Edit {
|
||||
value: "aé🦀z".to_owned(),
|
||||
cursor: 3,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_menu_allows_copy_and_select_all_only() {
|
||||
assert!(!menu_action_enabled(MenuAction::Cut, true, true, false, true));
|
||||
assert!(menu_action_enabled(MenuAction::Copy, true, true, false, true));
|
||||
assert!(!menu_action_enabled(MenuAction::Paste, true, true, false, true));
|
||||
assert!(menu_action_enabled(MenuAction::SelectAll, true, true, false, true));
|
||||
|
||||
assert!(!menu_action_enabled(MenuAction::Copy, false, true, false, true));
|
||||
assert!(!menu_action_enabled(MenuAction::SelectAll, false, false, false, true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod context_input;
|
||||
pub mod selectable_text;
|
||||
@@ -0,0 +1,890 @@
|
||||
use iced::advanced::clipboard::{self, Clipboard};
|
||||
use iced::advanced::layout;
|
||||
use iced::advanced::mouse;
|
||||
use iced::advanced::renderer;
|
||||
use iced::advanced::text::{self as advanced_text, Paragraph, Span};
|
||||
use iced::advanced::widget::tree::{self, Tree};
|
||||
use iced::advanced::widget::Widget;
|
||||
use iced::advanced::{Layout, Shell};
|
||||
use iced::widget::text::{
|
||||
self as widget_text, Alignment, Catalog, LineHeight, Shaping, Style, StyleFn,
|
||||
Wrapping,
|
||||
};
|
||||
use iced::{
|
||||
alignment, Background, Border, Color, Element, Event, Length, Pixels, Point,
|
||||
Rectangle, Size, Vector, keyboard,
|
||||
};
|
||||
|
||||
const DRAG_THRESHOLD: f32 = 3.0;
|
||||
const HIT_SEARCH_STEPS: usize = 24;
|
||||
|
||||
// Offsets here are paragraph-global BYTE offsets. `Paragraph::hit_test` returns
|
||||
// `Hit::CharOffset(cursor.index)`, and cosmic-text's `cursor.index` is a byte
|
||||
// offset WITHIN its buffer line — it discards the line number. That equals the
|
||||
// global byte offset only when the text is a single logical line. Chat bodies
|
||||
// satisfy this because `app::sanitize_chat` turns every control char (incl. `\n`
|
||||
// and `\r`) into a space and collapses whitespace, so a stored message can never
|
||||
// contain a newline. If that sanitizer ever starts preserving newlines, this
|
||||
// widget's per-line offsets would stop being global and selection/copy across
|
||||
// lines would break — revisit then.
|
||||
|
||||
pub fn selected_substring(
|
||||
text: &str,
|
||||
anchor: usize,
|
||||
cursor: usize,
|
||||
) -> Option<String> {
|
||||
let (start, end) = normalized_byte_range(text, anchor, cursor);
|
||||
|
||||
(start != end).then(|| text[start..end].to_owned())
|
||||
}
|
||||
|
||||
pub fn select_all(text: &str) -> (usize, usize) {
|
||||
(0, text.len())
|
||||
}
|
||||
|
||||
fn normalized_byte_range(
|
||||
text: &str,
|
||||
anchor: usize,
|
||||
cursor: usize,
|
||||
) -> (usize, usize) {
|
||||
let start = clamp_to_char_boundary(text, anchor.min(cursor));
|
||||
let end = clamp_to_char_boundary(text, anchor.max(cursor));
|
||||
|
||||
(start.min(end), start.max(end))
|
||||
}
|
||||
|
||||
fn clamp_to_char_boundary(text: &str, offset: usize) -> usize {
|
||||
let mut offset = offset.min(text.len());
|
||||
|
||||
while offset > 0 && !text.is_char_boundary(offset) {
|
||||
offset -= 1;
|
||||
}
|
||||
|
||||
offset
|
||||
}
|
||||
|
||||
pub fn selectable_rich_text<'a, Link, Message, Theme, Renderer>(
|
||||
spans: impl AsRef<[Span<'a, Link, Renderer::Font>]> + 'a,
|
||||
) -> SelectableRichText<'a, Link, Message, Theme, Renderer>
|
||||
where
|
||||
Link: Clone + 'static,
|
||||
Theme: Catalog + 'a,
|
||||
Renderer: advanced_text::Renderer,
|
||||
Renderer::Font: 'a,
|
||||
{
|
||||
SelectableRichText::with_spans(spans)
|
||||
}
|
||||
|
||||
pub struct SelectableRichText<
|
||||
'a,
|
||||
Link,
|
||||
Message,
|
||||
Theme = iced::Theme,
|
||||
Renderer = iced::Renderer,
|
||||
> where
|
||||
Link: Clone + 'static,
|
||||
Theme: Catalog,
|
||||
Renderer: advanced_text::Renderer,
|
||||
{
|
||||
spans: Box<dyn AsRef<[Span<'a, Link, Renderer::Font>]> + 'a>,
|
||||
size: Option<Pixels>,
|
||||
line_height: LineHeight,
|
||||
width: Length,
|
||||
height: Length,
|
||||
font: Option<Renderer::Font>,
|
||||
align_x: Alignment,
|
||||
align_y: alignment::Vertical,
|
||||
wrapping: Wrapping,
|
||||
class: Theme::Class<'a>,
|
||||
hovered_link: Option<usize>,
|
||||
on_link_click: Option<Box<dyn Fn(Link) -> Message + 'a>>,
|
||||
selection_color: Color,
|
||||
}
|
||||
|
||||
impl<'a, Link, Message, Theme, Renderer>
|
||||
SelectableRichText<'a, Link, Message, Theme, Renderer>
|
||||
where
|
||||
Link: Clone + 'static,
|
||||
Theme: Catalog,
|
||||
Renderer: advanced_text::Renderer,
|
||||
Renderer::Font: 'a,
|
||||
{
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
spans: Box::new([]),
|
||||
size: None,
|
||||
line_height: LineHeight::default(),
|
||||
width: Length::Shrink,
|
||||
height: Length::Shrink,
|
||||
font: None,
|
||||
align_x: Alignment::Default,
|
||||
align_y: alignment::Vertical::Top,
|
||||
wrapping: Wrapping::default(),
|
||||
class: Theme::default(),
|
||||
hovered_link: None,
|
||||
on_link_click: None,
|
||||
selection_color: Color::from_rgba(0.35, 0.55, 0.95, 0.35),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_spans(
|
||||
spans: impl AsRef<[Span<'a, Link, Renderer::Font>]> + 'a,
|
||||
) -> Self {
|
||||
Self {
|
||||
spans: Box::new(spans),
|
||||
..Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn size(mut self, size: impl Into<Pixels>) -> Self {
|
||||
self.size = Some(size.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn line_height(mut self, line_height: impl Into<LineHeight>) -> Self {
|
||||
self.line_height = line_height.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn font(mut self, font: impl Into<Renderer::Font>) -> Self {
|
||||
self.font = Some(font.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn width(mut self, width: impl Into<Length>) -> Self {
|
||||
self.width = width.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn height(mut self, height: impl Into<Length>) -> Self {
|
||||
self.height = height.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn align_x(mut self, alignment: impl Into<Alignment>) -> Self {
|
||||
self.align_x = alignment.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn align_y(
|
||||
mut self,
|
||||
alignment: impl Into<alignment::Vertical>,
|
||||
) -> Self {
|
||||
self.align_y = alignment.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn wrapping(mut self, wrapping: Wrapping) -> Self {
|
||||
self.wrapping = wrapping;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_link_click(
|
||||
mut self,
|
||||
on_link_click: impl Fn(Link) -> Message + 'a,
|
||||
) -> Self {
|
||||
self.on_link_click = Some(Box::new(on_link_click));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn selection_color(mut self, color: Color) -> Self {
|
||||
self.selection_color = Color {
|
||||
a: color.a.min(0.35),
|
||||
..color
|
||||
};
|
||||
self
|
||||
}
|
||||
|
||||
pub fn style(mut self, style: impl Fn(&Theme) -> Style + 'a) -> Self
|
||||
where
|
||||
Theme::Class<'a>: From<StyleFn<'a, Theme>>,
|
||||
{
|
||||
self.class = (Box::new(style) as StyleFn<'a, Theme>).into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn color(self, color: impl Into<Color>) -> Self
|
||||
where
|
||||
Theme::Class<'a>: From<StyleFn<'a, Theme>>,
|
||||
{
|
||||
self.color_maybe(Some(color))
|
||||
}
|
||||
|
||||
pub fn color_maybe(self, color: Option<impl Into<Color>>) -> Self
|
||||
where
|
||||
Theme::Class<'a>: From<StyleFn<'a, Theme>>,
|
||||
{
|
||||
let color = color.map(Into::into);
|
||||
|
||||
self.style(move |_theme| Style { color })
|
||||
}
|
||||
|
||||
pub fn class(mut self, class: impl Into<Theme::Class<'a>>) -> Self {
|
||||
self.class = class.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, Link, Message, Theme, Renderer> Default
|
||||
for SelectableRichText<'a, Link, Message, Theme, Renderer>
|
||||
where
|
||||
Link: Clone + 'static,
|
||||
Theme: Catalog,
|
||||
Renderer: advanced_text::Renderer,
|
||||
Renderer::Font: 'a,
|
||||
{
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
struct SelectableTextState<Link, P: Paragraph> {
|
||||
spans: Vec<Span<'static, Link, P::Font>>,
|
||||
span_pressed: Option<usize>,
|
||||
paragraph: P,
|
||||
selection: Option<(usize, usize)>,
|
||||
dragging: bool,
|
||||
active: bool,
|
||||
press_position: Option<Point>,
|
||||
}
|
||||
|
||||
impl<Link, P: Paragraph> SelectableTextState<Link, P> {
|
||||
fn selection_range(&self, text: &str) -> Option<(usize, usize)> {
|
||||
let (anchor, cursor) = self.selection?;
|
||||
let (start, end) = normalized_byte_range(text, anchor, cursor);
|
||||
|
||||
(start != end).then_some((start, end))
|
||||
}
|
||||
}
|
||||
|
||||
impl<Link, Message, Theme, Renderer> Widget<Message, Theme, Renderer>
|
||||
for SelectableRichText<'_, Link, Message, Theme, Renderer>
|
||||
where
|
||||
Link: Clone + 'static,
|
||||
Theme: Catalog,
|
||||
Renderer: advanced_text::Renderer,
|
||||
{
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<SelectableTextState<Link, Renderer::Paragraph>>()
|
||||
}
|
||||
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(SelectableTextState::<Link, _> {
|
||||
spans: Vec::new(),
|
||||
span_pressed: None,
|
||||
paragraph: Renderer::Paragraph::default(),
|
||||
selection: None,
|
||||
dragging: false,
|
||||
active: false,
|
||||
press_position: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn size(&self) -> Size<Length> {
|
||||
Size {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
}
|
||||
}
|
||||
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
layout_text(
|
||||
tree.state
|
||||
.downcast_mut::<SelectableTextState<Link, Renderer::Paragraph>>(),
|
||||
renderer,
|
||||
limits,
|
||||
TextLayout {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
spans: self.spans.as_ref().as_ref(),
|
||||
line_height: self.line_height,
|
||||
size: self.size,
|
||||
font: self.font,
|
||||
align_x: self.align_x,
|
||||
align_y: self.align_y,
|
||||
wrapping: self.wrapping,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut Renderer,
|
||||
theme: &Theme,
|
||||
defaults: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
_cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
if !layout.bounds().intersects(viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
let state = tree
|
||||
.state
|
||||
.downcast_ref::<SelectableTextState<Link, Renderer::Paragraph>>();
|
||||
let spans = self.spans.as_ref().as_ref();
|
||||
let flat_text = flatten_spans(spans);
|
||||
let style = theme.style(&self.class);
|
||||
let translation = layout.position() - Point::ORIGIN;
|
||||
|
||||
if let Some((start, end)) = state.selection_range(&flat_text) {
|
||||
let mut rects = selection_rects(&state.paragraph, spans.len(), start, end);
|
||||
if rects.is_empty() {
|
||||
rects = visual_lines(&state.paragraph, spans.len());
|
||||
}
|
||||
|
||||
for bounds in rects {
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: bounds + translation,
|
||||
border: Border {
|
||||
radius: 2.0.into(),
|
||||
..Border::default()
|
||||
},
|
||||
..renderer::Quad::default()
|
||||
},
|
||||
Background::Color(self.selection_color),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (index, span) in spans.iter().enumerate() {
|
||||
let is_hovered_link = self.on_link_click.is_some()
|
||||
&& Some(index) == self.hovered_link;
|
||||
|
||||
if span.highlight.is_some()
|
||||
|| span.underline
|
||||
|| span.strikethrough
|
||||
|| is_hovered_link
|
||||
{
|
||||
let regions = state.paragraph.span_bounds(index);
|
||||
|
||||
if let Some(highlight) = span.highlight {
|
||||
for bounds in ®ions {
|
||||
let bounds = Rectangle::new(
|
||||
bounds.position()
|
||||
- Vector::new(
|
||||
span.padding.left,
|
||||
span.padding.top,
|
||||
),
|
||||
bounds.size()
|
||||
+ Size::new(span.padding.x(), span.padding.y()),
|
||||
);
|
||||
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: bounds + translation,
|
||||
border: highlight.border,
|
||||
..Default::default()
|
||||
},
|
||||
highlight.background,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if span.underline || span.strikethrough || is_hovered_link {
|
||||
let size = span
|
||||
.size
|
||||
.or(self.size)
|
||||
.unwrap_or(renderer.default_size());
|
||||
|
||||
let line_height = span
|
||||
.line_height
|
||||
.unwrap_or(self.line_height)
|
||||
.to_absolute(size);
|
||||
|
||||
let color = span
|
||||
.color
|
||||
.or(style.color)
|
||||
.unwrap_or(defaults.text_color);
|
||||
|
||||
let baseline = translation
|
||||
+ Vector::new(
|
||||
0.0,
|
||||
size.0 + (line_height.0 - size.0) / 2.0,
|
||||
);
|
||||
|
||||
if span.underline || is_hovered_link {
|
||||
for bounds in ®ions {
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: Rectangle::new(
|
||||
bounds.position() + baseline
|
||||
- Vector::new(0.0, size.0 * 0.08),
|
||||
Size::new(bounds.width, 1.0),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
color,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if span.strikethrough {
|
||||
for bounds in ®ions {
|
||||
renderer.fill_quad(
|
||||
renderer::Quad {
|
||||
bounds: Rectangle::new(
|
||||
bounds.position() + baseline
|
||||
- Vector::new(0.0, size.0 / 2.0),
|
||||
Size::new(bounds.width, 1.0),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
color,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
widget_text::draw(
|
||||
renderer,
|
||||
defaults,
|
||||
layout.bounds(),
|
||||
&state.paragraph,
|
||||
style,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
_renderer: &Renderer,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
_viewport: &Rectangle,
|
||||
) {
|
||||
let bounds = layout.bounds();
|
||||
let local_position = cursor.position_in(bounds);
|
||||
let state = tree
|
||||
.state
|
||||
.downcast_mut::<SelectableTextState<Link, Renderer::Paragraph>>();
|
||||
let spans = self.spans.as_ref().as_ref();
|
||||
let flat_text = flatten_spans(spans);
|
||||
|
||||
let was_hovered = self.hovered_link.is_some();
|
||||
self.hovered_link = local_position.and_then(|position| {
|
||||
state.paragraph.hit_span(position).and_then(|span| {
|
||||
if spans.get(span)?.link.is_some() {
|
||||
Some(span)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if was_hovered != self.hovered_link.is_some() {
|
||||
shell.request_redraw();
|
||||
}
|
||||
|
||||
match event {
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
|
||||
if let Some(position) = local_position {
|
||||
state.active = true;
|
||||
state.dragging = true;
|
||||
state.press_position = Some(position);
|
||||
state.span_pressed = self.hovered_link;
|
||||
state.selection = state
|
||||
.paragraph
|
||||
.hit_test(position)
|
||||
.map(|hit| {
|
||||
let offset = hit.cursor().min(flat_text.len());
|
||||
(offset, offset)
|
||||
});
|
||||
shell.capture_event();
|
||||
shell.request_redraw();
|
||||
} else if state.active || state.selection.is_some() {
|
||||
state.active = false;
|
||||
state.dragging = false;
|
||||
state.press_position = None;
|
||||
state.span_pressed = None;
|
||||
state.selection = None;
|
||||
shell.request_redraw();
|
||||
}
|
||||
}
|
||||
Event::Mouse(mouse::Event::CursorMoved { .. }) => {
|
||||
if state.dragging
|
||||
&& let Some(position) = clamped_position(cursor, bounds)
|
||||
&& let Some(hit) = state.paragraph.hit_test(position)
|
||||
&& let Some((anchor, _)) = state.selection
|
||||
{
|
||||
state.selection =
|
||||
Some((anchor, hit.cursor().min(flat_text.len())));
|
||||
shell.request_redraw();
|
||||
}
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
||||
if state.dragging {
|
||||
let release_position = clamped_position(cursor, bounds)
|
||||
.or(local_position)
|
||||
.or(state.press_position);
|
||||
let dragged = state
|
||||
.press_position
|
||||
.zip(release_position)
|
||||
.is_some_and(|(start, end)| point_distance(start, end) > DRAG_THRESHOLD);
|
||||
|
||||
if let Some(position) = release_position
|
||||
&& let Some(hit) = state.paragraph.hit_test(position)
|
||||
&& let Some((anchor, _)) = state.selection
|
||||
{
|
||||
state.selection =
|
||||
Some((anchor, hit.cursor().min(flat_text.len())));
|
||||
}
|
||||
|
||||
if !dragged {
|
||||
if let (Some(on_link_clicked), Some(span)) =
|
||||
(&self.on_link_click, state.span_pressed)
|
||||
&& Some(span) == self.hovered_link
|
||||
&& let Some(link) =
|
||||
spans.get(span).and_then(|span| span.link.clone())
|
||||
{
|
||||
shell.publish(on_link_clicked(link));
|
||||
}
|
||||
state.selection = None;
|
||||
} else if state.selection_range(&flat_text).is_none() {
|
||||
state.selection = None;
|
||||
}
|
||||
|
||||
state.dragging = false;
|
||||
state.span_pressed = None;
|
||||
state.press_position = None;
|
||||
shell.capture_event();
|
||||
shell.request_redraw();
|
||||
}
|
||||
}
|
||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key,
|
||||
physical_key,
|
||||
modifiers,
|
||||
..
|
||||
}) if state.active && modifiers.command() => {
|
||||
match key.to_latin(*physical_key) {
|
||||
Some('c') | Some('C') => {
|
||||
if let Some((anchor, cursor)) = state.selection
|
||||
&& let Some(selected) =
|
||||
selected_substring(&flat_text, anchor, cursor)
|
||||
{
|
||||
clipboard.write(clipboard::Kind::Standard, selected);
|
||||
shell.capture_event();
|
||||
}
|
||||
}
|
||||
Some('a') | Some('A') => {
|
||||
state.selection = Some(select_all(&flat_text));
|
||||
shell.capture_event();
|
||||
shell.request_redraw();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
_viewport: &Rectangle,
|
||||
_renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
let state = tree
|
||||
.state
|
||||
.downcast_ref::<SelectableTextState<Link, Renderer::Paragraph>>();
|
||||
|
||||
if state.dragging {
|
||||
mouse::Interaction::Text
|
||||
} else if self.hovered_link.is_some() {
|
||||
mouse::Interaction::Pointer
|
||||
} else if cursor.is_over(layout.bounds()) {
|
||||
mouse::Interaction::Text
|
||||
} else {
|
||||
mouse::Interaction::None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TextLayout<'a, 'span, Link, Font> {
|
||||
width: Length,
|
||||
height: Length,
|
||||
spans: &'a [Span<'span, Link, Font>],
|
||||
line_height: LineHeight,
|
||||
size: Option<Pixels>,
|
||||
font: Option<Font>,
|
||||
align_x: Alignment,
|
||||
align_y: alignment::Vertical,
|
||||
wrapping: Wrapping,
|
||||
}
|
||||
|
||||
fn layout_text<Link, Renderer>(
|
||||
state: &mut SelectableTextState<Link, Renderer::Paragraph>,
|
||||
renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
config: TextLayout<'_, '_, Link, Renderer::Font>,
|
||||
) -> layout::Node
|
||||
where
|
||||
Link: Clone,
|
||||
Renderer: advanced_text::Renderer,
|
||||
{
|
||||
layout::sized(limits, config.width, config.height, |limits| {
|
||||
let bounds = limits.max();
|
||||
let size = config.size.unwrap_or_else(|| renderer.default_size());
|
||||
let font = config.font.unwrap_or_else(|| renderer.default_font());
|
||||
|
||||
let text_with_spans = || advanced_text::Text {
|
||||
content: config.spans,
|
||||
bounds,
|
||||
size,
|
||||
line_height: config.line_height,
|
||||
font,
|
||||
align_x: config.align_x,
|
||||
align_y: config.align_y,
|
||||
shaping: Shaping::Advanced,
|
||||
wrapping: config.wrapping,
|
||||
};
|
||||
|
||||
if state.spans != config.spans {
|
||||
state.paragraph =
|
||||
Renderer::Paragraph::with_spans(text_with_spans());
|
||||
state.spans = config
|
||||
.spans
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(Span::to_static)
|
||||
.collect();
|
||||
} else {
|
||||
match state.paragraph.compare(advanced_text::Text {
|
||||
content: (),
|
||||
bounds,
|
||||
size,
|
||||
line_height: config.line_height,
|
||||
font,
|
||||
align_x: config.align_x,
|
||||
align_y: config.align_y,
|
||||
shaping: Shaping::Advanced,
|
||||
wrapping: config.wrapping,
|
||||
}) {
|
||||
advanced_text::Difference::None => {}
|
||||
advanced_text::Difference::Bounds => {
|
||||
state.paragraph.resize(bounds);
|
||||
}
|
||||
advanced_text::Difference::Shape => {
|
||||
state.paragraph =
|
||||
Renderer::Paragraph::with_spans(text_with_spans());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.paragraph.min_bounds()
|
||||
})
|
||||
}
|
||||
|
||||
fn flatten_spans<Link, Font>(spans: &[Span<'_, Link, Font>]) -> String {
|
||||
spans
|
||||
.iter()
|
||||
.map(|span| span.text.as_ref())
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
fn selection_rects<P: Paragraph>(
|
||||
paragraph: &P,
|
||||
span_count: usize,
|
||||
start: usize,
|
||||
end: usize,
|
||||
) -> Vec<Rectangle> {
|
||||
visual_lines(paragraph, span_count)
|
||||
.into_iter()
|
||||
.filter_map(|line| selection_rect_for_line(paragraph, line, start, end))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn selection_rect_for_line<P: Paragraph>(
|
||||
paragraph: &P,
|
||||
line: Rectangle,
|
||||
start: usize,
|
||||
end: usize,
|
||||
) -> Option<Rectangle> {
|
||||
let y = line.center_y();
|
||||
let paragraph_width = paragraph.bounds().width.max(line.x + line.width + 1.0);
|
||||
let left_probe = line.x.max(0.0);
|
||||
let right_probe = (line.x + line.width + 1.0).min(paragraph_width.max(1.0));
|
||||
let line_start = paragraph
|
||||
.hit_test(Point::new(left_probe, y))
|
||||
.map(advanced_text::Hit::cursor)?;
|
||||
let line_end = paragraph
|
||||
.hit_test(Point::new(right_probe, y))
|
||||
.map(advanced_text::Hit::cursor)
|
||||
.unwrap_or(line_start);
|
||||
let (line_start, line_end) = if line_start <= line_end {
|
||||
(line_start, line_end)
|
||||
} else {
|
||||
(line_end, line_start)
|
||||
};
|
||||
let overlap_start = start.max(line_start);
|
||||
let overlap_end = end.min(line_end);
|
||||
|
||||
if overlap_start >= overlap_end {
|
||||
return None;
|
||||
}
|
||||
|
||||
let x_start = if overlap_start <= line_start {
|
||||
line.x
|
||||
} else {
|
||||
x_for_offset(paragraph, y, overlap_start, line.x, line.x + line.width)
|
||||
};
|
||||
let x_end = if overlap_end >= line_end {
|
||||
line.x + line.width
|
||||
} else {
|
||||
x_for_offset(paragraph, y, overlap_end, line.x, line.x + line.width)
|
||||
};
|
||||
let left = x_start.min(x_end);
|
||||
let right = x_start.max(x_end);
|
||||
|
||||
(right > left).then(|| {
|
||||
Rectangle::new(
|
||||
Point::new(left, line.y),
|
||||
Size::new(right - left, line.height),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn x_for_offset<P: Paragraph>(
|
||||
paragraph: &P,
|
||||
y: f32,
|
||||
offset: usize,
|
||||
left: f32,
|
||||
right: f32,
|
||||
) -> f32 {
|
||||
let mut low = left;
|
||||
let mut high = right.max(left);
|
||||
|
||||
for _ in 0..HIT_SEARCH_STEPS {
|
||||
let mid = (low + high) / 2.0;
|
||||
let hit = paragraph
|
||||
.hit_test(Point::new(mid, y))
|
||||
.map(advanced_text::Hit::cursor);
|
||||
|
||||
match hit {
|
||||
Some(hit) if hit < offset => low = mid,
|
||||
Some(_) => high = mid,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
high
|
||||
}
|
||||
|
||||
fn visual_lines<P: Paragraph>(
|
||||
paragraph: &P,
|
||||
span_count: usize,
|
||||
) -> Vec<Rectangle> {
|
||||
let mut lines: Vec<Rectangle> = Vec::new();
|
||||
|
||||
for span in 0..span_count {
|
||||
for bounds in paragraph.span_bounds(span) {
|
||||
if bounds.width <= 0.0 || bounds.height <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(line) = lines
|
||||
.iter_mut()
|
||||
.find(|line| (line.center_y() - bounds.center_y()).abs() < 1.0)
|
||||
{
|
||||
*line = union(*line, bounds);
|
||||
} else {
|
||||
lines.push(bounds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.sort_by(|a, b| a.y.total_cmp(&b.y));
|
||||
lines
|
||||
}
|
||||
|
||||
fn union(a: Rectangle, b: Rectangle) -> Rectangle {
|
||||
let left = a.x.min(b.x);
|
||||
let top = a.y.min(b.y);
|
||||
let right = (a.x + a.width).max(b.x + b.width);
|
||||
let bottom = (a.y + a.height).max(b.y + b.height);
|
||||
|
||||
Rectangle::new(
|
||||
Point::new(left, top),
|
||||
Size::new(right - left, bottom - top),
|
||||
)
|
||||
}
|
||||
|
||||
fn clamped_position(cursor: mouse::Cursor, bounds: Rectangle) -> Option<Point> {
|
||||
cursor.position_from(bounds.position()).map(|position| {
|
||||
Point::new(
|
||||
position.x.clamp(0.0, bounds.width.max(1.0) - 1.0),
|
||||
position.y.clamp(0.0, bounds.height.max(1.0) - 1.0),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn point_distance(a: Point, b: Point) -> f32 {
|
||||
let dx = a.x - b.x;
|
||||
let dy = a.y - b.y;
|
||||
|
||||
(dx * dx + dy * dy).sqrt()
|
||||
}
|
||||
|
||||
impl<'a, Link, Message, Theme, Renderer>
|
||||
From<SelectableRichText<'a, Link, Message, Theme, Renderer>>
|
||||
for Element<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Message: 'a,
|
||||
Link: Clone + 'a,
|
||||
Theme: Catalog + 'a,
|
||||
Renderer: advanced_text::Renderer + 'a,
|
||||
{
|
||||
fn from(
|
||||
text: SelectableRichText<'a, Link, Message, Theme, Renderer>,
|
||||
) -> Element<'a, Message, Theme, Renderer> {
|
||||
Element::new(text)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn selected_substring_returns_middle_and_none_for_collapsed() {
|
||||
assert_eq!(selected_substring("abcdef", 2, 5), Some("cde".to_owned()));
|
||||
assert_eq!(selected_substring("abcdef", 3, 3), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_substring_handles_reversed_range() {
|
||||
assert_eq!(selected_substring("abcdef", 5, 2), Some("cde".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_all_uses_byte_length() {
|
||||
assert_eq!(select_all(""), (0, 0));
|
||||
assert_eq!(select_all("aé👍z"), (0, "aé👍z".len()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_selection_uses_byte_offsets_without_panicking() {
|
||||
let text = "aé👍z";
|
||||
|
||||
assert_eq!(selected_substring(text, 1, 7), Some("é👍".to_owned()));
|
||||
assert_eq!(selected_substring(text, 3, 7), Some("👍".to_owned()));
|
||||
assert_eq!(selected_substring(text, 2, 7), Some("é👍".to_owned()));
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,7 @@ fn state(name: &str, addr: EndpointAddr) -> PeerState {
|
||||
addr,
|
||||
sharing: None,
|
||||
avatar: Default::default(),
|
||||
game: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user