Compare commits

...
7 Commits
Author SHA1 Message Date
mollusk fad65a4fcf fix(game): detect live Steam appid via /proc SteamAppId, not stale registry.vdf
windows-build / windows-build (push) Has been cancelled
cargo-deny / cargo-deny (push) Has been cancelled
Field test found Steam games were never detected on Linux. Root cause:
Steam rewrites ~/.steam/registry.vdf only on SHUTDOWN, so its RunningAppID
is stale (often absent) while a game is actually running — polling it can
never see the live game.

Fix: on Linux, read the live appid from the running game's environment
(SteamAppId in /proc/<pid>/environ, the var Steam exports to every game
process — the same signal MangoHud uses; readable for our own processes).
registry.vdf stays as a best-effort fallback. Windows still reads the real
registry's RunningAppID, which IS updated live there. Other Unix keeps the
registry.vdf fallback.

Pure parse_steam_app_id_from_environ() is unit-tested (nonzero filter,
absent, substring-not-fooled, garbage). Also fixes a latent bug in the
first draft where a single non-UTF8 SteamAppId value would abort the whole
scan via ? instead of skipping.

396 lib tests, clippy --all-targets clean.
2026-06-21 16:11:17 -04:00
molluskandClaude Opus 4.8 3878e716dd feat(game): Step 7 UI — opt-in toggle, roster Playing line, Settings
Final step of game detection. Functional, plain styling (to art-direct).

- Settings 'Games' category: opt-in 'show my game' toggle
  (SetGamePresenceEnabled, persisted), manual override picker (Auto /
  None / Pin current), per-game background picker+remove (reuses
  process_background + hashed game_background_path), and a non-Steam
  process->name mapping editor (add/remove, pushes SetGameProcessMap).
- Roster: each peer card shows 'Playing <game>' under their name when
  they broadcast one; our own self card shows it too, marked
  '(not shared)' when broadcasting is off.
- Startup: seeds SetGamePresenceEnabled + SetGameProcessMap from config.
- Updated the settings-category navigation test for the new category.

395 lib tests green, clippy --all-targets clean, binary builds, and an
8s smoke launch starts the core + detector thread with no panic (detector
logs nothing by design — privacy).

Feature complete on Linux end-to-end (pending a coordinated GOSSIP_PROTO
3 redeploy to field-test presence with peers). Windows FFI still needs
its cross-build pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 15:43:24 -04:00
molluskandClaude Opus 4.8 961705ffa9 feat(game): broadcast game presence (GOSSIP_PROTO 3) + per-game background
Steps 5-6 of game detection. BREAKING wire change — bump everyone.

Wire (step 5):
- PeerState.game: Option<String> (display label only — never appid/source).
- SelfPresence.game + to_state carry it (single self-state builder).
- GOSSIP_PROTO 2->3, GOSSIP_SIG_DOMAIN v3, version comment bumped together;
  Cargo MINOR 0.3.0 -> 0.4.0 per VERSIONING.md. v2/v3 isolate into
  different topics + signature domains, so a coordinated redeploy is
  required (same as the W4 avatar bump).
- Gossip ingest sanitizes incoming game via sanitize_game_label (bidi/
  control strip, 64-char/256-byte cap); empty -> None.
- Bonus security fix (Codex find): reject inbound gossip frames over a
  128KB cap BEFORE serde_json::from_slice — a legit Announce with a full
  48KB avatar is ~49KB, so this bounds allocation abuse with headroom.

Core wiring:
- Spawns the detector at startup; consumes its watch channel in the main
  select. Detection runs continuously (for the local background); the
  broadcast is gated by game_presence_enabled (opt-in, default OFF).
  New commands: SetGamePresenceEnabled (immediate publish/clear, D8),
  SetGameOverride, SetGameProcessMap. New event: GameChanged.
- game_presence_label sanitizes the outgoing label too.

Background switch (step 6):
- GUI handles GameChanged: stores current_game, swaps background to the
  per-game override (config.game_backgrounds[id]) or falls back to the
  W16 default; reuses the existing cached-handle path (no redraw flicker).

397 lib tests (all green), clippy --all-targets clean, full binary builds.
Remaining: step 7 UI (opt-in toggle, roster 'Playing' text, manual
override control, Settings game-backgrounds + process-map editors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 15:31:09 -04:00
molluskandClaude Opus 4.8 7d44808a5e feat(game): cancellable detector service
Step 4 of game detection. One std-thread worker owns the SteamProbe cache
+ Debouncer across ticks, polls the OS adapters every 3s off the async
runtime, and publishes the stable detected game on a tokio watch channel
only when it changes. Manual override + process map are live-updatable via
shared handles; a cancellable sleep honors stop promptly; drop stops it.

The per-tick decision (match + resolve + debounce) is the pure poll_once,
unit-tested with synthetic Steam/process inputs (debounce, process-only
match, immediate manual override). +4 tests (397 lib).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 15:21:52 -04:00
molluskandClaude Opus 4.8 e31d3db986 feat(game): Steam + process-scan OS adapters
Step 2-3 of game detection (game-presence-plan.md). The OS edges feeding
the pure seams from the previous commit.

- src/game/steam.rs: SteamProbe — reads the live RunningAppID and resolves
  it to a name via appmanifest_<id>.acf (no binary appinfo.vdf). Pure parse
  fns (parse_running_app_id / parse_library_paths / parse_app_name) over
  file contents are unit-tested incl. current+legacy libraryfolders shapes,
  escaped Windows paths, empty/missing names, and garbage. Roots discovered
  across native/Flatpak/Snap (Linux) and the registry (Windows); libraries
  and resolved names cached + mtime-invalidated so the 3s poll doesn't
  rescan. File reads byte-capped.
- src/game/scan.rs: native running-process enumeration — /proc (exe symlink,
  comm fallback) on Linux, Toolhelp on Windows — feeding the pure
  match_processes. No sysinfo dep (D7).
- Cargo.toml: windows-sys as a direct Windows-only dep for the registry +
  Toolhelp FFI. No NEW crate — it was already in the lockfile transitively
  via cpal/rfd, so the audit surface is unchanged.

391 lib tests (+5). Linux: build + clippy --all-targets clean. Windows FFI
signatures verified against windows-sys 0.61 source (one *const vs *mut
lpReserved fixed) but NOT yet cross-compiled — defer to the post-UI Windows
build cycle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 15:19:53 -04:00
molluskandClaude Opus 4.8 87a2209a85 feat(game): pure detection seams — matcher, debouncer, VDF parser, config
Step 1 of the game-detection feature (game-presence-plan.md): all the
pure, I/O-free logic, tested first.

- src/game/mod.rs: DetectedGame + stable namespaced ids (steam:730 /
  exe:hl2_linux, never the mutable name); ManualOverride; the priority
  resolve() matcher (override -> Steam -> mapped process -> none); the
  Debouncer (2-on/3-off, immediate bypass for manual override) that
  stops a flapping detector re-announcing the ~48KB-avatar PeerState;
  match_processes() over explicit user mappings with a launcher denylist
  (never guesses a game from an arbitrary process).
- src/game/vdf.rs: a real recursive-descent KeyValues/VDF parser (not a
  name-regex) for appmanifest/.acf, libraryfolders.vdf, registry.vdf —
  depth-capped, escape-aware, never panics on malformed/truncated input.
- src/sanitize.rs: sanitize_game_label (64-char/256-byte cap, wider than
  the 48-char name cap) sharing the bidi/zero-width cleaning.
- src/config.rs: additive game_presence_enabled (opt-in, default OFF),
  game_backgrounds + game_process_map (BTreeMap, deterministic);
  background_path generalized to hashed per-game files; explicit
  legacy-config migration test (load() wipes on any deserialize error).
- src/background.rs: game_background_filename (FNV-1a hashed, fs-safe).

No wire/protocol change yet; no OS reads yet. 386 lib tests (+28).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 15:12:58 -04:00
molluskandClaude Opus 4.8 9e8c8b4ace refactor(core): single SelfPresence self-state builder
Core reconstructed PeerState in five command branches (join, mute
toggle, avatar change, screen-share start/stop), each repeating the full
field list. Factor a SelfPresence struct holding the sticky identity
fields (name + avatar) with a to_state(is_muted, addr, sharing) builder
that folds in the volatile per-announce fields, so the PeerState literal
lives in one place. This is the precondition for adding a broadcast
game-presence field without editing every call site.

No behavior change. +1 unit test (359 lib total path).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 15:05:32 -04:00
19 changed files with 2506 additions and 66 deletions
Generated
+2 -1
View File
@@ -4871,7 +4871,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]] [[package]]
name = "peerspeak" name = "peerspeak"
version = "0.3.0" version = "0.4.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -4894,6 +4894,7 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"windows-sys 0.61.2",
] ]
[[package]] [[package]]
+10 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "peerspeak" name = "peerspeak"
version = "0.3.0" version = "0.4.0"
edition = "2024" edition = "2024"
# Application crate, not a crates.io library — refuse `cargo publish` and let # Application crate, not a crates.io library — refuse `cargo publish` and let
# cargo-deny's [licenses.private] skip the missing-license check. # cargo-deny's [licenses.private] skip the missing-license check.
@@ -65,3 +65,12 @@ rfd = { version = "0.17", default-features = false }
# Windows audio backend: cpal drives WASAPI for capture/playback behind the # Windows audio backend: cpal drives WASAPI for capture/playback behind the
# AudioBackend trait (src/audio/cpal_impl.rs). The Linux counterpart is pipewire. # AudioBackend trait (src/audio/cpal_impl.rs). The Linux counterpart is pipewire.
cpal = "0.15" 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",
] }
+366 -5
View File
@@ -45,10 +45,11 @@ pub enum SettingsCategory {
Appearance, Appearance,
Network, Network,
Notifications, Notifications,
Games,
} }
impl SettingsCategory { impl SettingsCategory {
const ALL: [SettingsCategory; 7] = [ const ALL: [SettingsCategory; 8] = [
SettingsCategory::Audio, SettingsCategory::Audio,
SettingsCategory::Hotkeys, SettingsCategory::Hotkeys,
SettingsCategory::Recording, SettingsCategory::Recording,
@@ -56,6 +57,7 @@ impl SettingsCategory {
SettingsCategory::Appearance, SettingsCategory::Appearance,
SettingsCategory::Network, SettingsCategory::Network,
SettingsCategory::Notifications, SettingsCategory::Notifications,
SettingsCategory::Games,
]; ];
fn label(self) -> &'static str { fn label(self) -> &'static str {
@@ -67,6 +69,7 @@ impl SettingsCategory {
SettingsCategory::Appearance => "Appearance", SettingsCategory::Appearance => "Appearance",
SettingsCategory::Network => "Network", SettingsCategory::Network => "Network",
SettingsCategory::Notifications => "Notifications", SettingsCategory::Notifications => "Notifications",
SettingsCategory::Games => "Games",
} }
} }
@@ -79,6 +82,7 @@ impl SettingsCategory {
SettingsCategory::Appearance => "Layout and theme", SettingsCategory::Appearance => "Layout and theme",
SettingsCategory::Network => "Relay and privacy mode", SettingsCategory::Network => "Relay and privacy mode",
SettingsCategory::Notifications => "Chimes and sounds", SettingsCategory::Notifications => "Chimes and sounds",
SettingsCategory::Games => "Detection, presence, backgrounds",
} }
} }
} }
@@ -342,6 +346,47 @@ pub enum AppMessage {
ShutdownCommandSent(bool), ShutdownCommandSent(bool),
/// Fallback close if the core does not acknowledge shutdown promptly. /// Fallback close if the core does not acknowledge shutdown promptly.
ShutdownTimeout, ShutdownTimeout,
// --- Game detection (W17/W18) ---
/// Toggle broadcasting the detected game to peers (opt-in, default off).
ToggleGamePresence(bool),
/// Choose the manual detection override (Auto / None / the current game).
GameOverrideSelected(GameOverrideChoice),
/// Add-mapping form edits (executable basename → display name).
GameMapExeChanged(String),
GameMapNameChanged(String),
/// Commit the add-mapping form into the process map.
AddGameMapping,
/// Remove a process→name mapping by its executable key.
RemoveGameMapping(String),
/// Open the native picker to set a per-game background for the given game id.
PickGameBackground(String),
/// Result of the per-game background picker: (game id, chosen bytes or None).
GameBackgroundPicked(String, Option<Vec<u8>>),
/// Clear a per-game background mapping by game id.
RemoveGameBackground(String),
}
/// The manual game-detection override as shown in the Settings picker. Maps to a
/// [`crate::game::ManualOverride`] using the app's currently-detected game for the
/// `Current` choice (so "force this game" carries the live id + name).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GameOverrideChoice {
/// Trust auto-detection (default).
Auto,
/// Force "not playing" — never broadcast a game.
None,
/// Pin the game currently detected (only offered while something is detected).
Current,
}
impl std::fmt::Display for GameOverrideChoice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
GameOverrideChoice::Auto => "Auto-detect",
GameOverrideChoice::None => "None (don't show a game)",
GameOverrideChoice::Current => "Pin current game",
})
}
} }
fn core_subscription() -> impl iced::futures::Stream<Item = UiEvent> { fn core_subscription() -> impl iced::futures::Stream<Item = UiEvent> {
@@ -381,6 +426,18 @@ pub struct AppState {
/// doesn't read the file from disk on every redraw. Loaded on startup and /// doesn't read the file from disk on every redraw. Loaded on startup and
/// refreshed when the background is changed/removed. `None` = no custom bg. /// refreshed when the background is changed/removed. `None` = no custom bg.
background_image: Option<bytes::Bytes>, background_image: Option<bytes::Bytes>,
/// The locally-detected running game (game detection), as reported by core.
/// Drives the per-game background (W18) and a local "Playing …" indicator.
/// `None` = nothing detected. Independent of whether we broadcast it to peers
/// (that's `config.game_presence_enabled`).
current_game: Option<crate::game::DetectedGame>,
/// Add-mapping form state in the Games settings: the executable basename and
/// display name being entered for a new process→name mapping.
game_map_exe_input: String,
game_map_name_input: String,
/// Current manual-override selection shown in the Games settings picker.
/// Session-only (not persisted); defaults to Auto each launch.
game_override: GameOverrideChoice,
peers: HashMap<EndpointId, PeerState>, peers: HashMap<EndpointId, PeerState>,
audio_levels: HashMap<EndpointId, f32>, audio_levels: HashMap<EndpointId, f32>,
/// Peers we've locally muted (their audio isn't mixed into our output). /// Peers we've locally muted (their audio isn't mixed into our output).
@@ -514,6 +571,10 @@ impl Default for AppState {
let _ = controller.send(CoreCommand::SetRecordingMode(config.recording_mode)); let _ = controller.send(CoreCommand::SetRecordingMode(config.recording_mode));
let _ = controller.send(CoreCommand::SetPixelpassPath(config.pixelpass_path.clone())); let _ = controller.send(CoreCommand::SetPixelpassPath(config.pixelpass_path.clone()));
let _ = controller.send(CoreCommand::SetPresenceMode(config.presence_mode)); let _ = controller.send(CoreCommand::SetPresenceMode(config.presence_mode));
// Game detection (W17/W18): seed the opt-in broadcast flag + the user's
// process→name mappings from config. The manual override starts at Auto.
let _ = controller.send(CoreCommand::SetGamePresenceEnabled(config.game_presence_enabled));
let _ = controller.send(CoreCommand::SetGameProcessMap(config.game_process_map.clone()));
for (peer, settings) in &config.peer_eq { for (peer, settings) in &config.peer_eq {
if let Ok(id) = peer.parse::<EndpointId>() { if let Ok(id) = peer.parse::<EndpointId>() {
let _ = controller.send(CoreCommand::SetPeerEq(id, *settings)); let _ = controller.send(CoreCommand::SetPeerEq(id, *settings));
@@ -565,6 +626,10 @@ impl Default for AppState {
selected_output, selected_output,
config, config,
background_image, background_image,
current_game: None,
game_map_exe_input: String::new(),
game_map_name_input: String::new(),
game_override: GameOverrideChoice::Auto,
peers: HashMap::new(), peers: HashMap::new(),
audio_levels: HashMap::new(), audio_levels: HashMap::new(),
locally_muted: HashSet::new(), locally_muted: HashSet::new(),
@@ -624,6 +689,23 @@ fn load_background_bytes(config: &AppConfig) -> Option<bytes::Bytes> {
std::fs::read(path).ok().map(bytes::Bytes::from) std::fs::read(path).ok().map(bytes::Bytes::from)
} }
/// The effective background for the current state: the per-game override (W18) when
/// the running `game` has a mapping in `config.game_backgrounds`, otherwise the
/// single custom background (W16). A mapped-but-missing/unreadable per-game file
/// falls back to the default WITHOUT forgetting the mapping (the file may return).
fn effective_background_bytes(
config: &AppConfig,
game: Option<&crate::game::DetectedGame>,
) -> Option<bytes::Bytes> {
if let Some(g) = game
&& let Some(path) = config.game_backgrounds.get(&g.id)
&& let Ok(bytes) = std::fs::read(path)
{
return Some(bytes::Bytes::from(bytes));
}
load_background_bytes(config)
}
pub fn run_gui() -> iced::Result { pub fn run_gui() -> iced::Result {
// Restore the last window size (saved on close). Position is restored too, // Restore the last window size (saved on close). Position is restored too,
// but only on X11 — Wayland's xdg-shell gives clients no way to set their own // but only on X11 — Wayland's xdg-shell gives clients no way to set their own
@@ -1144,6 +1226,15 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
format!("Presence mode stayed {mode}") format!("Presence mode stayed {mode}")
}; };
} }
UiEvent::GameChanged(detected) => {
// The locally-detected game changed: switch the per-game
// background (W18) if one is mapped, else fall back to the
// default. Presence broadcasting is handled in core, gated by
// the opt-in toggle; this is purely local presentation.
state.current_game = detected;
state.background_image =
effective_background_bytes(&state.config, state.current_game.as_ref());
}
UiEvent::ShutdownComplete => { UiEvent::ShutdownComplete => {
if state.closing { if state.closing {
return iced::exit(); return iced::exit();
@@ -1579,6 +1670,126 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.config.background_dim = dim.clamp(0.0, 1.0); state.config.background_dim = dim.clamp(0.0, 1.0);
state.config.save(); state.config.save();
} }
AppMessage::ToggleGamePresence(enabled) => {
state.config.game_presence_enabled = enabled;
state.config.save();
// Core publishes/clears our game immediately (D8).
let _ = state.controller.send(CoreCommand::SetGamePresenceEnabled(enabled));
}
AppMessage::GameOverrideSelected(choice) => {
state.game_override = choice;
let override_ = match choice {
GameOverrideChoice::Auto => crate::game::ManualOverride::Auto,
GameOverrideChoice::None => crate::game::ManualOverride::ForceNone,
// "Pin current" carries the live detection; nothing detected = Auto.
GameOverrideChoice::Current => match &state.current_game {
Some(g) => crate::game::ManualOverride::Force(g.clone()),
None => crate::game::ManualOverride::Auto,
},
};
let _ = state.controller.send(CoreCommand::SetGameOverride(override_));
}
AppMessage::GameMapExeChanged(val) => {
state.game_map_exe_input = val;
}
AppMessage::GameMapNameChanged(val) => {
state.game_map_name_input = val;
}
AppMessage::AddGameMapping => {
let exe = crate::game::normalize_exe(&state.game_map_exe_input);
let name = state.game_map_name_input.trim().to_string();
if !exe.is_empty() && !name.is_empty() {
state.config.game_process_map.insert(exe, name);
state.config.save();
state.game_map_exe_input.clear();
state.game_map_name_input.clear();
let _ = state
.controller
.send(CoreCommand::SetGameProcessMap(state.config.game_process_map.clone()));
}
}
AppMessage::RemoveGameMapping(exe) => {
if state.config.game_process_map.remove(&exe).is_some() {
state.config.save();
let _ = state
.controller
.send(CoreCommand::SetGameProcessMap(state.config.game_process_map.clone()));
}
}
AppMessage::PickGameBackground(game_id) => {
// Native picker off the UI thread; result tagged with the game id.
return Task::perform(
async {
let handle = rfd::AsyncFileDialog::new()
.add_filter("Images", &["png", "jpg", "jpeg", "webp", "bmp"])
.set_title("Choose a per-game background")
.pick_file()
.await;
match handle {
Some(h) => Some(h.read().await),
None => None,
}
},
move |bytes| AppMessage::GameBackgroundPicked(game_id, bytes),
);
}
AppMessage::GameBackgroundPicked(game_id, picked) => {
if let Some(bytes) = picked {
match crate::background::process_background(&bytes) {
Ok(png) => match AppConfig::game_background_path(&game_id) {
Some(path) => {
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
match std::fs::write(&path, &png) {
Ok(()) => {
state
.config
.game_backgrounds
.insert(game_id.clone(), path.to_string_lossy().into_owned());
state.config.save();
// Apply at once if it's the running game.
if state.current_game.as_ref().map(|g| g.id.as_str())
== Some(game_id.as_str())
{
state.background_image = effective_background_bytes(
&state.config,
state.current_game.as_ref(),
);
}
state.status_message = "Game background updated.".to_string();
}
Err(e) => {
state.status_message =
format!("Couldn't save game background: {e}");
}
}
}
None => {
state.status_message =
"Couldn't find a config directory to save the background."
.to_string();
}
},
Err(e) => {
state.status_message = e;
}
}
}
}
AppMessage::RemoveGameBackground(game_id) => {
if let Some(path) = AppConfig::game_background_path(&game_id) {
let _ = std::fs::remove_file(path);
}
if state.config.game_backgrounds.remove(&game_id).is_some() {
state.config.save();
if state.current_game.as_ref().map(|g| g.id.as_str()) == Some(game_id.as_str()) {
state.background_image =
effective_background_bytes(&state.config, state.current_game.as_ref());
}
state.status_message = "Game background removed.".to_string();
}
}
AppMessage::ToggleDrawerChat => { AppMessage::ToggleDrawerChat => {
state.drawer_chat_open = !state.drawer_chat_open; state.drawer_chat_open = !state.drawer_chat_open;
} }
@@ -3204,6 +3415,126 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.spacing(10) .spacing(10)
.width(iced::Length::Fill) .width(iced::Length::Fill)
.into(), .into(),
SettingsCategory::Games => {
// What's running right now (local detection), for context.
let detected_label = match &state.current_game {
Some(g) => match &g.name {
Some(name) => format!("Currently detected: {name}"),
None => "Currently detected: (a game, no name available)".to_string(),
},
None => "Currently detected: nothing".to_string(),
};
// Manual override picker: Auto / None, plus "Pin current" when a
// game is detected.
let mut override_opts = vec![GameOverrideChoice::Auto, GameOverrideChoice::None];
if state.current_game.is_some() {
override_opts.push(GameOverrideChoice::Current);
}
// Per-game background row for the running game (if any).
let game_bg_section: Element<'_, AppMessage> = match &state.current_game {
Some(g) => {
let has_bg = state.config.game_backgrounds.contains_key(&g.id);
let id_for_pick = g.id.clone();
let mut controls = row![
button(text("Set background for this game").size(13))
.on_press(AppMessage::PickGameBackground(id_for_pick)),
]
.spacing(8);
if has_bg {
let id_for_remove = g.id.clone();
controls = controls.push(
button(text("Remove").size(13))
.on_press(AppMessage::RemoveGameBackground(id_for_remove)),
);
}
controls.into()
}
None => text("Start a game to set its background.")
.size(12)
.color(color_subtext)
.into(),
};
// The list of configured per-game backgrounds (by stable id).
let mut bg_list = column![].spacing(4).width(iced::Length::Fill);
for id in state.config.game_backgrounds.keys() {
let id_owned = id.clone();
bg_list = bg_list.push(
row![
text(id.clone()).size(12).width(iced::Length::Fill),
button(text("Remove").size(12))
.on_press(AppMessage::RemoveGameBackground(id_owned)),
]
.spacing(8)
.width(iced::Length::Fill),
);
}
// The process→name mappings (non-Steam games), with an add form.
let mut map_list = column![].spacing(4).width(iced::Length::Fill);
for (exe, name) in &state.config.game_process_map {
let exe_owned = exe.clone();
map_list = map_list.push(
row![
text(format!("{exe}{name}")).size(12).width(iced::Length::Fill),
button(text("Remove").size(12))
.on_press(AppMessage::RemoveGameMapping(exe_owned)),
]
.spacing(8)
.width(iced::Length::Fill),
);
}
column![
section_header("Game presence"),
column![
checkbox(state.config.game_presence_enabled)
.label("Show the game I'm playing to people in the call")
.on_toggle(AppMessage::ToggleGamePresence),
text("Off by default. When on, your detected game appears next to your avatar for everyone in the room.")
.size(11).color(color_subtext),
vertical_space(6.0),
text(detected_label).size(12).color(color_subtext),
row![
text("Override:").size(13),
pick_list(
override_opts,
Some(state.game_override),
AppMessage::GameOverrideSelected,
),
].spacing(8),
].spacing(6).width(iced::Length::Fill),
vertical_space(section_gap),
section_header("Per-game background"),
column![
text("Give a game its own UI background; it switches automatically while you play. Falls back to your custom background (Appearance) otherwise.")
.size(11).color(color_subtext),
game_bg_section,
bg_list,
].spacing(8).width(iced::Length::Fill),
vertical_space(section_gap),
section_header("Non-Steam games"),
column![
text("Steam games are detected automatically. For other launchers, map an executable name to a display name.")
.size(11).color(color_subtext),
row![
text_input("executable (e.g. hl2_linux)", &state.game_map_exe_input)
.on_input(AppMessage::GameMapExeChanged)
.width(iced::Length::Fill),
text_input("shown name (e.g. Half-Life 2)", &state.game_map_name_input)
.on_input(AppMessage::GameMapNameChanged)
.width(iced::Length::Fill),
button(text("Add").size(13)).on_press(AppMessage::AddGameMapping),
].spacing(8).width(iced::Length::Fill),
map_list,
].spacing(8).width(iced::Length::Fill),
]
.spacing(10)
.width(iced::Length::Fill)
.into()
}
}; };
let category_button = |category: SettingsCategory| -> Element<'_, AppMessage> { let category_button = |category: SettingsCategory| -> Element<'_, AppMessage> {
@@ -3523,6 +3854,22 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
] ]
.spacing(10) .spacing(10)
.align_y(iced::alignment::Vertical::Center), .align_y(iced::alignment::Vertical::Center),
// Our own detected game (W17/W18). When we're broadcasting it, it
// matches what peers see; otherwise it's marked "not shared".
{
let el: Element<'_, AppMessage> =
match state.current_game.as_ref().and_then(|g| g.name.as_deref()) {
Some(name) if state.config.game_presence_enabled => {
text(format!("Playing {name}")).size(11).color(color_blue).into()
}
Some(name) => text(format!("Playing {name} (not shared)"))
.size(11)
.color(color_subtext)
.into(),
None => text("").into(),
};
el
},
// Live "you're sharing" badge — only present while sharing. // Live "you're sharing" badge — only present while sharing.
{ {
let el: Element<'_, AppMessage> = if state.self_sharing { let el: Element<'_, AppMessage> = if state.self_sharing {
@@ -3670,10 +4017,20 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
let mut card_content = column![ let mut card_content = column![
row![ row![
avatar_view(&peer.avatar, &peer.name, &peer_id.to_string(), 38.0), avatar_view(&peer.avatar, &peer.name, &peer_id.to_string(), 38.0),
column![ {
// Name + id, plus a "Playing <game>" line when the peer is
// broadcasting a game (game presence, W17).
let mut name_col = column![
text(&peer.name).size(16).color(color_text), text(&peer.name).size(16).color(color_text),
text(format!("ID: {}", short_id(&peer_id.to_string()))).size(11).color(color_subtext) text(format!("ID: {}", short_id(&peer_id.to_string()))).size(11).color(color_subtext),
], ];
if let Some(game) = &peer.game {
name_col = name_col.push(
text(format!("Playing {game}")).size(11).color(color_blue),
);
}
name_col
},
add_friend_el, add_friend_el,
horizontal_space(), horizontal_space(),
share_el, share_el,
@@ -5512,10 +5869,14 @@ mod tests {
let labels: Vec<_> = SettingsCategory::ALL.iter().map(|c| c.label()).collect(); let labels: Vec<_> = SettingsCategory::ALL.iter().map(|c| c.label()).collect();
assert_eq!( assert_eq!(
labels, labels,
vec!["Audio", "Hotkeys", "Recording", "Profile", "Appearance", "Network", "Notifications"] vec![
"Audio", "Hotkeys", "Recording", "Profile", "Appearance", "Network",
"Notifications", "Games"
]
); );
assert_eq!(SettingsCategory::Audio.hint(), "Devices, mic gate, echo"); assert_eq!(SettingsCategory::Audio.hint(), "Devices, mic gate, echo");
assert_eq!(SettingsCategory::Profile.hint(), "Avatar and identity"); assert_eq!(SettingsCategory::Profile.hint(), "Avatar and identity");
assert_eq!(SettingsCategory::Games.hint(), "Detection, presence, backgrounds");
} }
#[test] #[test]
+27
View File
@@ -45,6 +45,22 @@ pub fn process_background(raw: &[u8]) -> Result<Vec<u8>, String> {
Ok(png.into_inner()) 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 /// 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` /// 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 /// 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()); 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] #[test]
fn scrim_color_sets_alpha_and_keeps_rgb() { fn scrim_color_sets_alpha_and_keeps_rgb() {
let base = Color::from_rgb(0.1, 0.2, 0.3); let base = Color::from_rgb(0.1, 0.2, 0.3);
+2
View File
@@ -64,6 +64,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
addr: endpoint_a.addr(), addr: endpoint_a.addr(),
sharing: None, sharing: None,
avatar: Default::default(), avatar: Default::default(),
game: None,
}; };
room_a.join(&ticket_str, state_a, vec![]).await?; room_a.join(&ticket_str, state_a, vec![]).await?;
println!("Node A joined topic."); println!("Node A joined topic.");
@@ -83,6 +84,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
addr: endpoint_b.addr(), addr: endpoint_b.addr(),
sharing: None, sharing: None,
avatar: Default::default(), avatar: Default::default(),
game: None,
}; };
room_b.join(&ticket_str, state_b, vec![]).await?; room_b.join(&ticket_str, state_b, vec![]).await?;
println!("Node B joined topic."); println!("Node B joined topic.");
+88 -6
View File
@@ -1,7 +1,7 @@
use crate::notify::Sound; use crate::notify::Sound;
use crate::theme::AppTheme; use crate::theme::AppTheme;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::{BTreeMap, HashMap};
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
@@ -199,6 +199,26 @@ pub struct AppConfig {
/// `crate::background::scrim_color`. /// `crate::background::scrim_color`.
#[serde(default = "default_background_dim")] #[serde(default = "default_background_dim")]
pub background_dim: f32, 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). /// What a call recording captures (mixed / per-peer stems / both).
#[serde(default)] #[serde(default)]
pub recording_mode: RecordingMode, pub recording_mode: RecordingMode,
@@ -305,6 +325,9 @@ impl Default for AppConfig {
avatar: crate::avatar::Avatar::default(), avatar: crate::avatar::Avatar::default(),
background: None, background: None,
background_dim: default_background_dim(), background_dim: default_background_dim(),
game_presence_enabled: false,
game_backgrounds: BTreeMap::new(),
game_process_map: BTreeMap::new(),
recording_mode: RecordingMode::default(), recording_mode: RecordingMode::default(),
custom_sound_self_join: None, custom_sound_self_join: None,
custom_sound_peer_join: None, custom_sound_peer_join: None,
@@ -375,17 +398,31 @@ impl AppConfig {
}) })
} }
/// Path the processed custom-background PNG (W16) is written to, alongside /// Path to a processed-background PNG of the given filename, alongside
/// `config.json` in the app config dir. We store our own downscaled copy here /// `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. /// (rather than base64 in the config) so the JSON stays small. Used for both
pub fn background_path() -> Option<PathBuf> { /// the single custom background and the per-game backgrounds.
fn background_dir_path(filename: &str) -> Option<PathBuf> {
dirs::config_dir().map(|mut p| { dirs::config_dir().map(|mut p| {
p.push("peerspeak"); p.push("peerspeak");
p.push("background.png"); p.push(filename);
p 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 { pub fn load() -> Self {
if let Some(path) = Self::config_path() if let Some(path) = Self::config_path()
&& let Ok(contents) = fs::read_to_string(&path) && let Ok(contents) = fs::read_to_string(&path)
@@ -480,6 +517,51 @@ mod tests {
); );
} }
#[test]
fn test_backward_compat_game_detection_fields() {
// A config that predates the game-detection feature (W18) — and crucially
// still carries the W16 single `background` as a plain string — must
// deserialize without error. `AppConfig::load()` silently replaces ANY
// deserialize failure with full defaults, so a broken migration here would
// wipe everyone's settings; this guards that the additive fields kept the
// old shape loadable and that `background` was NOT retyped.
let legacy_json = r#"{
"input_device": "",
"output_device": "",
"noise_gate_threshold": 0.01,
"username": "Eric",
"background": "/home/eric/.config/peerspeak/background.png",
"background_dim": 0.4
}"#;
let cfg: AppConfig = serde_json::from_str(legacy_json).unwrap();
// The pre-existing single background survives untouched (still Option<String>).
assert_eq!(cfg.background.as_deref(), Some("/home/eric/.config/peerspeak/background.png"));
assert!((cfg.background_dim - 0.4).abs() < f32::EPSILON);
// The new game-detection fields default to off/empty → silent, opt-in upgrade.
assert!(!cfg.game_presence_enabled);
assert!(cfg.game_backgrounds.is_empty());
assert!(cfg.game_process_map.is_empty());
}
#[test]
fn test_game_maps_serialize_deterministically() {
// BTreeMap ordering makes the serialized config stable across runs.
let mut cfg = AppConfig::default();
cfg.game_backgrounds.insert("steam:730".into(), "/a.png".into());
cfg.game_backgrounds.insert("exe:hl2_linux".into(), "/b.png".into());
cfg.game_process_map.insert("hl2_linux".into(), "Half-Life 2".into());
let json = serde_json::to_string(&cfg).unwrap();
// Keys appear in sorted order (exe: before steam:).
let bg = json.find("game_backgrounds").unwrap();
let exe_at = json[bg..].find("exe:hl2_linux").unwrap();
let steam_at = json[bg..].find("steam:730").unwrap();
assert!(exe_at < steam_at, "BTreeMap keys must serialize sorted");
// Full round-trip preserves the maps.
let back: AppConfig = serde_json::from_str(&json).unwrap();
assert_eq!(back.game_backgrounds, cfg.game_backgrounds);
assert_eq!(back.game_process_map, cfg.game_process_map);
}
#[test] #[test]
fn test_window_size_fields() { fn test_window_size_fields() {
// Default impl is the standard launch size. // Default impl is the standard launch size.
+17
View File
@@ -89,6 +89,17 @@ pub enum CoreCommand {
/// invisible) and the outbound ping scheduler (invisible = fully dark). Sent at /// invisible) and the outbound ping scheduler (invisible = fully dark). Sent at
/// startup from config and whenever the user changes it. /// startup from config and whenever the user changes it.
SetPresenceMode(PresenceMode), 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)] #[derive(Debug, Clone)]
@@ -150,6 +161,12 @@ pub enum UiEvent {
/// failure, this carries the previous truthful mode. The GUI must mirror + /// failure, this carries the previous truthful mode. The GUI must mirror +
/// persist this so its presence picker matches the endpoint's discovery state. /// persist this so its presence picker matches the endpoint's discovery state.
PresenceModeReverted { mode: PresenceMode }, 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. /// Core finished orderly app shutdown and the GUI can exit.
ShutdownComplete, ShutdownComplete,
Error(String), Error(String),
+117 -43
View File
@@ -7,7 +7,7 @@ use crate::audio::eq::{Eq, EqSettings};
use crate::codec::{AudioEncoder, opus_impl::OpusEncoder}; use crate::codec::{AudioEncoder, opus_impl::OpusEncoder};
use crate::core::jitter::{JitterBuffer, FRAME_SAMPLES}; use crate::core::jitter::{JitterBuffer, FRAME_SAMPLES};
use crate::network::{ use crate::network::{
NetworkTransport, RoomState, PeerState, RoomEvent, ConnEvent, PeerSpeakTicket, NetworkTransport, RoomState, SelfPresence, RoomEvent, ConnEvent, PeerSpeakTicket,
iroh_impl::{IrohTransport, AudioRouter, FileRouter}, iroh_impl::{IrohTransport, AudioRouter, FileRouter},
gossip::IrohGossipState, gossip::IrohGossipState,
}; };
@@ -80,6 +80,17 @@ fn audio_datagram_len_ok(len: usize) -> bool {
(4..=4 + MAX_OPUS_PAYLOAD).contains(&len) (4..=4 + MAX_OPUS_PAYLOAD).contains(&len)
} }
/// The presence label to broadcast for a detected game: its display name,
/// sanitized + length-capped, or `None` when there's no game or no broadcastable
/// name (a Steam appid without a manifest name, or a label that sanitizes empty).
/// Sanitizing here as well as at the gossip ingest boundary keeps the outgoing
/// value clean even though every peer re-sanitizes on receipt.
fn game_presence_label(game: Option<&crate::game::DetectedGame>) -> Option<String> {
game.and_then(|g| g.name.as_deref())
.map(crate::sanitize::sanitize_game_label)
.filter(|s| !s.is_empty())
}
fn arm_discovery_retry( fn arm_discovery_retry(
discovery_deadline: &mut Option<tokio::time::Instant>, discovery_deadline: &mut Option<tokio::time::Instant>,
now: tokio::time::Instant, now: tokio::time::Instant,
@@ -917,10 +928,30 @@ async fn run_core_loop(
let peer_gate = Arc::new(Mutex::new(HashMap::<EndpointId, f32>::new())); let peer_gate = Arc::new(Mutex::new(HashMap::<EndpointId, f32>::new()));
// Peers locally muted by us: decoded for level metering but not mixed. // Peers locally muted by us: decoded for level metering but not mixed.
let locally_muted = Arc::new(Mutex::new(HashSet::<EndpointId>::new())); let locally_muted = Arc::new(Mutex::new(HashSet::<EndpointId>::new()));
let mut current_name = "Anonymous".to_string(); // Sticky identity fields of our own presence (display name + W4 avatar), set on
// Our chosen avatar (W4), set on Join and changeable via SetAvatar; included // Join and changed via SetName/SetAvatar. Combined with the volatile per-announce
// in every self-state we announce over presence. // fields (mute/addr/share ticket) by `SelfPresence::to_state` — the single place
let mut current_avatar = crate::avatar::Avatar::default(); // our `PeerState` is built. Defaults match the prior `current_name`/`current_avatar`.
let mut presence = SelfPresence {
name: "Anonymous".to_string(),
avatar: crate::avatar::Avatar::default(),
game: None,
};
// Game detection (W17/W18): a background worker polls Steam state + the process
// list and publishes the debounced running game on a watch channel. Detection
// runs continuously (the GUI uses it for the local per-game background); whether
// the game is *broadcast* as presence is gated by `game_presence_enabled` (opt-in,
// seeded false; the GUI sends `SetGamePresenceEnabled` from config at startup).
// The override + process map start at their defaults and are set via commands.
let game_detector = crate::game::detector::GameDetector::spawn(
crate::game::ManualOverride::Auto,
std::collections::BTreeMap::new(),
);
let mut game_rx = game_detector.subscribe();
let mut game_presence_enabled = false;
// The latest debounced detection, kept regardless of the broadcast toggle so a
// later opt-in can immediately publish whatever is currently running.
let mut current_game: Option<crate::game::DetectedGame> = None;
let mut network_mode = NetworkMode::default(); let mut network_mode = NetworkMode::default();
// Pixelpass binary override (config), and the ticket of our own active screen // Pixelpass binary override (config), and the ticket of our own active screen
// share (rides our presence so the room — incl. late joiners — can watch). // share (rides our presence so the room — incl. late joiners — can watch).
@@ -1027,6 +1058,30 @@ async fn run_core_loop(
Some(cmd) => cmd, Some(cmd) => cmd,
None => break, None => break,
}, },
changed = game_rx.changed() => {
// The detector worker published a new debounced game (or `None`).
if changed.is_err() {
// Worker gone (shouldn't happen before shutdown); stop watching.
continue;
}
let detected = game_rx.borrow_and_update().clone();
current_game = detected.clone();
// Always tell the GUI for the local per-game background + indicator.
let _ = ui_tx.send(UiEvent::GameChanged(detected.clone())).await;
// Broadcast as presence only when opted in; re-announce if in a room.
if game_presence_enabled {
presence.game = game_presence_label(detected.as_ref());
if let Some(session) = &active_session {
let self_state = presence.to_state(
is_muted.load(Ordering::Relaxed),
net.endpoint.addr(),
current_sharing.clone(),
);
let _ = session.room_state.update_self_state(self_state).await;
}
}
continue;
}
_ = ping_interval.tick() => { _ = ping_interval.tick() => {
// Fully dark while Invisible (the user's choice): don't even probe, // Fully dark while Invisible (the user's choice): don't even probe,
// so nothing we do touches a friend's machine. Otherwise refresh in a // so nothing we do touches a friend's machine. Otherwise refresh in a
@@ -1125,8 +1180,8 @@ async fn run_core_loop(
} }
CoreCommand::Join { name, ticket, room_name, input_device, output_device, echo_cancellation, avatar } => { CoreCommand::Join { name, ticket, room_name, input_device, output_device, echo_cancellation, avatar } => {
current_name = name.clone(); presence.name = name.clone();
current_avatar = avatar; presence.avatar = avatar;
// Finalize any recording before tearing down the old session — its // Finalize any recording before tearing down the old session — its
// capture/mixer feeders are about to stop. // capture/mixer feeders are about to stop.
@@ -1206,13 +1261,11 @@ async fn run_core_loop(
// Fresh join starts not sharing; clear any stale share ticket. // Fresh join starts not sharing; clear any stale share ticket.
current_sharing = None; current_sharing = None;
let self_state = PeerState { let self_state = presence.to_state(
name: current_name.clone(), is_muted.load(Ordering::Relaxed),
is_muted: is_muted.load(Ordering::Relaxed), endpoint.addr(),
addr: endpoint.addr(), None,
sharing: None, );
avatar: current_avatar.clone(),
};
// Snapshot THIS room's retained peers (by ticket) as extra bootstrap // Snapshot THIS room's retained peers (by ticket) as extra bootstrap
// targets so a rejoin can dial them (A8) — including after a detour // targets so a rejoin can dial them (A8) — including after a detour
@@ -1917,29 +1970,25 @@ async fn run_core_loop(
is_muted.store(new_state, Ordering::Relaxed); is_muted.store(new_state, Ordering::Relaxed);
if let Some(session) = &active_session { if let Some(session) = &active_session {
let self_state = PeerState { let self_state = presence.to_state(
name: current_name.clone(), new_state,
is_muted: new_state, net.endpoint.addr(),
addr: net.endpoint.addr(), current_sharing.clone(),
sharing: current_sharing.clone(), );
avatar: current_avatar.clone(),
};
let _ = session.room_state.update_self_state(self_state).await; let _ = session.room_state.update_self_state(self_state).await;
} }
} }
CoreCommand::SetAvatar(avatar) => { CoreCommand::SetAvatar(avatar) => {
current_avatar = avatar; presence.avatar = avatar;
// Re-announce presence so the room (incl. late joiners, via the // Re-announce presence so the room (incl. late joiners, via the
// retained presence) picks up the new avatar (W4). // retained presence) picks up the new avatar (W4).
if let Some(session) = &active_session { if let Some(session) = &active_session {
let self_state = PeerState { let self_state = presence.to_state(
name: current_name.clone(), is_muted.load(Ordering::Relaxed),
is_muted: is_muted.load(Ordering::Relaxed), net.endpoint.addr(),
addr: net.endpoint.addr(), current_sharing.clone(),
sharing: current_sharing.clone(), );
avatar: current_avatar.clone(),
};
let _ = session.room_state.update_self_state(self_state).await; let _ = session.room_state.update_self_state(self_state).await;
} }
} }
@@ -2184,6 +2233,35 @@ async fn run_core_loop(
} }
} }
CoreCommand::SetGamePresenceEnabled(enabled) => {
game_presence_enabled = enabled;
// Recompute our broadcast label: the current game when enabling,
// cleared when disabling. Publish immediately (D8) so peers see the
// game appear/disappear at once, not on the next detector tick.
presence.game = if enabled {
game_presence_label(current_game.as_ref())
} else {
None
};
if let Some(session) = &active_session {
let self_state = presence.to_state(
is_muted.load(Ordering::Relaxed),
net.endpoint.addr(),
current_sharing.clone(),
);
let _ = session.room_state.update_self_state(self_state).await;
}
}
CoreCommand::SetGameOverride(override_) => {
// Applied on the detector's next poll, immediately (bypasses debounce).
game_detector.set_override(override_);
}
CoreCommand::SetGameProcessMap(map) => {
game_detector.set_process_map(map);
}
CoreCommand::SetRecordingMode(mode) => { CoreCommand::SetRecordingMode(mode) => {
recording_mode = mode; recording_mode = mode;
} }
@@ -2337,13 +2415,11 @@ async fn run_core_loop(
crate::log_msg("Screen share host started"); crate::log_msg("Screen share host started");
session.screenshare_host = Some(child); session.screenshare_host = Some(child);
current_sharing = Some(ticket.clone()); current_sharing = Some(ticket.clone());
let self_state = PeerState { let self_state = presence.to_state(
name: current_name.clone(), is_muted.load(Ordering::Relaxed),
is_muted: is_muted.load(Ordering::Relaxed), net.endpoint.addr(),
addr: net.endpoint.addr(), Some(ticket),
sharing: Some(ticket), );
avatar: current_avatar.clone(),
};
let _ = session.room_state.update_self_state(self_state).await; let _ = session.room_state.update_self_state(self_state).await;
let _ = ui_tx.send(UiEvent::ScreenShareStarted).await; let _ = ui_tx.send(UiEvent::ScreenShareStarted).await;
} }
@@ -2362,13 +2438,11 @@ async fn run_core_loop(
let _ = child.kill().await; let _ = child.kill().await;
crate::log_msg("Screen share host stopped"); crate::log_msg("Screen share host stopped");
} }
let self_state = PeerState { let self_state = presence.to_state(
name: current_name.clone(), is_muted.load(Ordering::Relaxed),
is_muted: is_muted.load(Ordering::Relaxed), net.endpoint.addr(),
addr: net.endpoint.addr(), None,
sharing: None, );
avatar: current_avatar.clone(),
};
let _ = session.room_state.update_self_state(self_state).await; let _ = session.room_state.update_self_state(self_state).await;
} }
let _ = ui_tx.send(UiEvent::ScreenShareStopped).await; let _ = ui_tx.send(UiEvent::ScreenShareStopped).await;
+234
View File
@@ -0,0 +1,234 @@
//! 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::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
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>,
}
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>) -> 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();
std::thread::Builder::new()
.name("game-detector".to_string())
.spawn(move || worker_loop(worker_inputs, tx, worker_stop))
.ok();
Self { inputs, rx, stop }
}
/// 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();
}
}
/// 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());
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
View File
@@ -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"));
}
}
+117
View File
@@ -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:?}"
);
}
}
}
+508
View File
@@ -0,0 +1,508 @@
//! 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;
/// 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 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 || len == 0 {
// SAFETY: valid handle.
unsafe { RegCloseKey(hkey) };
return None;
}
let mut buf = vec![0u16; (len as usize).div_ceil(2)];
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 {
return None;
}
// Trim the trailing NUL(s).
while buf.last() == Some(&0) {
buf.pop();
}
Some(PathBuf::from(String::from_utf16_lossy(&buf)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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
View File
@@ -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);
}
}
+1
View File
@@ -20,6 +20,7 @@ pub mod recents;
pub mod discovery; pub mod discovery;
pub mod hotkeys; pub mod hotkeys;
pub mod files; pub mod files;
pub mod game;
use std::fs::File; use std::fs::File;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
+30
View File
@@ -22,6 +22,15 @@ use crate::protocol::GOSSIP_SIG_DOMAIN;
/// reasonable cross-peer clock skew without leaving a wide replay window. /// reasonable cross-peer clock skew without leaving a wide replay window.
const GOSSIP_FRESHNESS_MS: u64 = 120_000; 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. /// A gossip message plus the authentication envelope that proves who sent it.
/// `author` is the claimed sender (an `EndpointId`, which *is* an ed25519 public /// `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 /// key); `sig` is that key's signature over [`signable_bytes`], so a forged
@@ -343,6 +352,17 @@ impl RoomState for IrohGossipState {
match res { match res {
Ok(iroh_gossip::api::Event::Received(msg)) => { Ok(iroh_gossip::api::Event::Received(msg)) => {
crate::log_msg(&format!("Gossip received Event::Received from delivery={:?}", msg.delivered_from)); 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) { match serde_json::from_slice::<GossipPayload>(&msg.content) {
Ok(payload) => { Ok(payload) => {
// Authenticate before trusting `author` for ANY // Authenticate before trusting `author` for ANY
@@ -406,6 +426,15 @@ impl RoomState for IrohGossipState {
// peer-supplied: cap/validate once at ingest // peer-supplied: cap/validate once at ingest
// so invalid offers never render a Watch button. // so invalid offers never render a Watch button.
state.sharing = state.sharing.and_then(crate::screenshare::sanitize_ticket); state.sharing = state.sharing.and_then(crate::screenshare::sanitize_ticket);
// The game-presence label is untrusted
// peer text like the name: sanitize +
// length-cap at ingest (strip bidi/control,
// 64-char/256-byte cap). An empty result
// means "no game" rather than a blank label.
state.game = state.game.and_then(|g| {
let cleaned = crate::sanitize::sanitize_game_label(&g);
(!cleaned.is_empty()).then_some(cleaned)
});
disconnected_peers.lock().unwrap().remove(&payload.author); disconnected_peers.lock().unwrap().remove(&payload.author);
let (is_new, state_changed) = { let (is_new, state_changed) = {
let mut peer_map = peers.lock().unwrap(); let mut peer_map = peers.lock().unwrap();
@@ -676,6 +705,7 @@ mod tests {
addr, addr,
sharing: None, sharing: None,
avatar: crate::avatar::Avatar::default(), avatar: crate::avatar::Avatar::default(),
game: None,
} }
} }
+76
View File
@@ -39,6 +39,58 @@ pub struct PeerState {
/// peers/configs that predate the field still deserialize (→ monogram). /// peers/configs that predate the field still deserialize (→ monogram).
#[serde(default)] #[serde(default)]
pub avatar: crate::avatar::Avatar, 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)] #[derive(Debug, Clone)]
@@ -250,6 +302,7 @@ mod tests {
addr, addr,
sharing: None, sharing: None,
avatar: crate::avatar::Avatar::default(), avatar: crate::avatar::Avatar::default(),
game: None,
} }
} }
@@ -358,6 +411,29 @@ mod tests {
assert_eq!(PeerSpeakTicket::restamp("not-a-ticket", EndpointAddr::from(me)), "not-a-ticket"); 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] #[test]
fn test_peer_state_serde_round_trip() { fn test_peer_state_serde_round_trip() {
let original = sample_peer_state(); let original = sample_peer_state();
+8 -2
View File
@@ -26,7 +26,13 @@ pub const FRIENDS_PROTO: u32 = 1;
/// v2 (0.3.0): `GossipMessage::Chat` gained an optional file attachment /// v2 (0.3.0): `GossipMessage::Chat` gained an optional file attachment
/// (`ChatAttachment`), so a pre-v2 peer can't interpret/serve chat files — bumped /// (`ChatAttachment`), so a pre-v2 peer can't interpret/serve chat files — bumped
/// to fail fast rather than half-work. /// 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 /// File-transfer plane version (chat attachment request/stream shape). Bump on
/// any change. Mirrored in [`FILES_ALPN`]. /// any change. Mirrored in [`FILES_ALPN`].
pub const FILES_PROTO: u32 = 1; 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 /// ed25519 gossip signature domain: `peerspeak-gossip-v<GOSSIP_PROTO>`. Carries
/// the gossip protocol version into every signed payload — a version mismatch /// the gossip protocol version into every signed payload — a version mismatch
/// fails verification (cryptographic separation between gossip versions). /// 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 /// Version-namespace a room topic so peers on different gossip protocol versions
/// derive **different subscription topics from the same ticket** and therefore /// derive **different subscription topics from the same ticket** and therefore
+78 -7
View File
@@ -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 / /// Sanitize an untrusted peer display name for safe rendering. Strips bidi /
/// zero-width format characters, turns control characters into spaces, collapses /// 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 /// 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 /// [`NAME_MAX_CHARS`]. Returns `""` if nothing usable remains (callers may
/// substitute a placeholder such as a short id). /// substitute a placeholder such as a short id).
pub fn sanitize_name(input: &str) -> String { pub fn sanitize_name(input: &str) -> String {
let cleaned: String = input clean_label(input).chars().take(NAME_MAX_CHARS).collect()
.chars() }
.filter(|c| !is_spoofing_format_char(*c))
.map(|c| if c.is_control() { ' ' } else { c }) /// Sanitize an untrusted game-presence label (the `Playing <name>` status that
.collect(); /// rides the gossip presence plane). Same spoof/control cleaning as
let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join(" "); /// [`sanitize_name`], but capped at [`GAME_LABEL_MAX_CHARS`] scalars AND
collapsed.chars().take(NAME_MAX_CHARS).collect() /// [`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. /// 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); 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 ----------------------------------------------------------- // --- linkify -----------------------------------------------------------
/// Concatenating every segment's inner text must reproduce the input exactly. /// Concatenating every segment's inner text must reproduce the input exactly.
+1
View File
@@ -63,6 +63,7 @@ fn state(name: &str, addr: EndpointAddr) -> PeerState {
addr, addr,
sharing: None, sharing: None,
avatar: Default::default(), avatar: Default::default(),
game: None,
} }
} }