From 961705ffa9ff5fc93fa987a2817deab0f5015d6c Mon Sep 17 00:00:00 2001 From: Mollusk Date: Sun, 21 Jun 2026 15:31:09 -0400 Subject: [PATCH] feat(game): broadcast game presence (GOSSIP_PROTO 3) + per-game background MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steps 5-6 of game detection. BREAKING wire change — bump everyone. Wire (step 5): - PeerState.game: Option (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 --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/app/mod.rs | 32 +++++++++++++++ src/bin/test_net.rs | 2 + src/core/messages.rs | 17 ++++++++ src/core/mod.rs | 80 +++++++++++++++++++++++++++++++++++++ src/network/gossip.rs | 30 ++++++++++++++ src/network/mod.rs | 18 +++++++++ src/protocol.rs | 10 ++++- tests/gossip_rebootstrap.rs | 1 + 10 files changed, 190 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f473116..0dbff5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4871,7 +4871,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" [[package]] name = "peerspeak" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index fe13518..561ba8f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "peerspeak" -version = "0.3.0" +version = "0.4.0" edition = "2024" # Application crate, not a crates.io library — refuse `cargo publish` and let # cargo-deny's [licenses.private] skip the missing-license check. diff --git a/src/app/mod.rs b/src/app/mod.rs index 9849fd1..0c65cfc 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -381,6 +381,11 @@ pub struct AppState { /// doesn't read the file from disk on every redraw. Loaded on startup and /// refreshed when the background is changed/removed. `None` = no custom bg. background_image: Option, + /// 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, peers: HashMap, audio_levels: HashMap, /// Peers we've locally muted (their audio isn't mixed into our output). @@ -565,6 +570,7 @@ impl Default for AppState { selected_output, config, background_image, + current_game: None, peers: HashMap::new(), audio_levels: HashMap::new(), locally_muted: HashSet::new(), @@ -624,6 +630,23 @@ fn load_background_bytes(config: &AppConfig) -> Option { 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 { + 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 { // 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 @@ -1144,6 +1167,15 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { 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 => { if state.closing { return iced::exit(); diff --git a/src/bin/test_net.rs b/src/bin/test_net.rs index 3627c46..9eb7d77 100644 --- a/src/bin/test_net.rs +++ b/src/bin/test_net.rs @@ -64,6 +64,7 @@ async fn main() -> Result<(), Box> { addr: endpoint_a.addr(), sharing: None, avatar: Default::default(), + game: None, }; room_a.join(&ticket_str, state_a, vec![]).await?; println!("Node A joined topic."); @@ -83,6 +84,7 @@ async fn main() -> Result<(), Box> { addr: endpoint_b.addr(), sharing: None, avatar: Default::default(), + game: None, }; room_b.join(&ticket_str, state_b, vec![]).await?; println!("Node B joined topic."); diff --git a/src/core/messages.rs b/src/core/messages.rs index 94b68e9..5840f46 100644 --- a/src/core/messages.rs +++ b/src/core/messages.rs @@ -89,6 +89,17 @@ pub enum CoreCommand { /// invisible) and the outbound ping scheduler (invisible = fully dark). Sent at /// startup from config and whenever the user changes it. SetPresenceMode(PresenceMode), + /// Toggle broadcasting the detected game as presence (game detection). Opt-in, + /// default OFF. Enabling immediately publishes the current game; disabling + /// immediately publishes `game: None`. Detection for the local background runs + /// regardless. Sent at startup from config and on user toggle. + SetGamePresenceEnabled(bool), + /// Set the manual game-detection override (`Auto` / `None` / a forced game). + /// Forwarded to the detector and applied immediately (bypasses debounce). + SetGameOverride(crate::game::ManualOverride), + /// Replace the user process→display-name mappings used by the non-Steam + /// detection fallback. Sent at startup from config and after Settings edits. + SetGameProcessMap(std::collections::BTreeMap), } #[derive(Debug, Clone)] @@ -150,6 +161,12 @@ pub enum UiEvent { /// failure, this carries the previous truthful mode. The GUI must mirror + /// persist this so its presence picker matches the endpoint's discovery state. PresenceModeReverted { mode: PresenceMode }, + /// The locally-detected running game changed (game detection). Carries the + /// debounced `DetectedGame` (id + display name + source) or `None` when nothing + /// is detected. The GUI uses the stable `id` to switch the per-game background + /// (W18) and may show a local "Playing …" indicator. Emitted regardless of + /// whether game presence is being broadcast — the broadcast is core's own job. + GameChanged(Option), /// Core finished orderly app shutdown and the GUI can exit. ShutdownComplete, Error(String), diff --git a/src/core/mod.rs b/src/core/mod.rs index d929f98..58a1feb 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -80,6 +80,17 @@ fn audio_datagram_len_ok(len: usize) -> bool { (4..=4 + MAX_OPUS_PAYLOAD).contains(&len) } +/// The presence label to broadcast for a detected game: its display name, +/// sanitized + length-capped, or `None` when there's no game or no broadcastable +/// name (a Steam appid without a manifest name, or a label that sanitizes empty). +/// Sanitizing here as well as at the gossip ingest boundary keeps the outgoing +/// value clean even though every peer re-sanitizes on receipt. +fn game_presence_label(game: Option<&crate::game::DetectedGame>) -> Option { + game.and_then(|g| g.name.as_deref()) + .map(crate::sanitize::sanitize_game_label) + .filter(|s| !s.is_empty()) +} + fn arm_discovery_retry( discovery_deadline: &mut Option, now: tokio::time::Instant, @@ -924,7 +935,23 @@ async fn run_core_loop( 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 = None; let mut network_mode = NetworkMode::default(); // Pixelpass binary override (config), and the ticket of our own active screen // share (rides our presence so the room — incl. late joiners — can watch). @@ -1031,6 +1058,30 @@ async fn run_core_loop( Some(cmd) => cmd, 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() => { // 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 @@ -2182,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) => { recording_mode = mode; } diff --git a/src/network/gossip.rs b/src/network/gossip.rs index 07de35a..c2428d8 100644 --- a/src/network/gossip.rs +++ b/src/network/gossip.rs @@ -22,6 +22,15 @@ use crate::protocol::GOSSIP_SIG_DOMAIN; /// reasonable cross-peer clock skew without leaving a wide replay window. const GOSSIP_FRESHNESS_MS: u64 = 120_000; +/// Hard cap on an inbound gossip frame before it is deserialized. The largest +/// legitimate payload is an `Announce` carrying a full custom avatar (≤48 KB +/// base64, [`crate::avatar::CUSTOM_MAX_B64`]) plus the small presence/signature +/// fields — about 49 KB on the wire. This cap sits comfortably above that while +/// bounding the work/allocation a hostile peer can force: `serde_json::from_slice` +/// allocates while parsing, so post-deserialize string caps do NOT prevent abuse — +/// the size must be checked *before* parsing (security hardening, Codex find). +const MAX_GOSSIP_FRAME_BYTES: usize = 128 * 1024; + /// A gossip message plus the authentication envelope that proves who sent it. /// `author` is the claimed sender (an `EndpointId`, which *is* an ed25519 public /// key); `sig` is that key's signature over [`signable_bytes`], so a forged @@ -343,6 +352,17 @@ impl RoomState for IrohGossipState { match res { Ok(iroh_gossip::api::Event::Received(msg)) => { crate::log_msg(&format!("Gossip received Event::Received from delivery={:?}", msg.delivered_from)); + // Reject oversized frames BEFORE deserializing: parsing + // allocates, so a size check has to precede `from_slice` to + // bound the memory a hostile peer can make us hold. + if msg.content.len() > MAX_GOSSIP_FRAME_BYTES { + crate::log_msg(&format!( + "Gossip dropped oversized frame: {} bytes > {} cap", + msg.content.len(), + MAX_GOSSIP_FRAME_BYTES + )); + continue; + } match serde_json::from_slice::(&msg.content) { Ok(payload) => { // Authenticate before trusting `author` for ANY @@ -406,6 +426,15 @@ impl RoomState for IrohGossipState { // peer-supplied: cap/validate once at ingest // so invalid offers never render a Watch button. state.sharing = state.sharing.and_then(crate::screenshare::sanitize_ticket); + // The game-presence label is untrusted + // peer text like the name: sanitize + + // length-cap at ingest (strip bidi/control, + // 64-char/256-byte cap). An empty result + // means "no game" rather than a blank label. + state.game = state.game.and_then(|g| { + let cleaned = crate::sanitize::sanitize_game_label(&g); + (!cleaned.is_empty()).then_some(cleaned) + }); disconnected_peers.lock().unwrap().remove(&payload.author); let (is_new, state_changed) = { let mut peer_map = peers.lock().unwrap(); @@ -676,6 +705,7 @@ mod tests { addr, sharing: None, avatar: crate::avatar::Avatar::default(), + game: None, } } diff --git a/src/network/mod.rs b/src/network/mod.rs index 97b21a3..28aeb10 100644 --- a/src/network/mod.rs +++ b/src/network/mod.rs @@ -39,6 +39,15 @@ pub struct PeerState { /// peers/configs that predate the field still deserialize (→ monogram). #[serde(default)] pub avatar: crate::avatar::Avatar, + /// The game this peer is currently playing, as a display string only (shown as + /// `Playing ` 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, } /// The locally-owned, "sticky" pieces of our own presence: the identity fields @@ -56,6 +65,11 @@ pub struct PeerState { 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, } impl SelfPresence { @@ -74,6 +88,7 @@ impl SelfPresence { addr, sharing, avatar: self.avatar.clone(), + game: self.game.clone(), } } } @@ -287,6 +302,7 @@ mod tests { addr, sharing: None, avatar: crate::avatar::Avatar::default(), + game: None, } } @@ -401,6 +417,7 @@ mod tests { 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())); @@ -409,6 +426,7 @@ mod tests { 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); diff --git a/src/protocol.rs b/src/protocol.rs index 9ab4334..331ec6c 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -26,7 +26,13 @@ pub const FRIENDS_PROTO: u32 = 1; /// v2 (0.3.0): `GossipMessage::Chat` gained an optional file attachment /// (`ChatAttachment`), so a pre-v2 peer can't interpret/serve chat files — bumped /// to fail fast rather than half-work. -pub const GOSSIP_PROTO: u32 = 2; +/// +/// v3 (0.4.0): `PeerState` gained an optional `game` presence field (the +/// `Playing ` status). The field is `#[serde(default)]`, so the bump isn't +/// strictly required for decoding — but per the versioning discipline a wire-shape +/// change is isolated into its own topic + signature domain so v2 and v3 peers +/// never share a swarm. Resync everyone, exactly like the W4 avatar bump. +pub const GOSSIP_PROTO: u32 = 3; /// File-transfer plane version (chat attachment request/stream shape). Bump on /// any change. Mirrored in [`FILES_ALPN`]. pub const FILES_PROTO: u32 = 1; @@ -41,7 +47,7 @@ pub const FILES_ALPN: &[u8] = b"peerspeak/files/1"; /// ed25519 gossip signature domain: `peerspeak-gossip-v`. Carries /// the gossip protocol version into every signed payload — a version mismatch /// fails verification (cryptographic separation between gossip versions). -pub const GOSSIP_SIG_DOMAIN: &str = "peerspeak-gossip-v2"; +pub const GOSSIP_SIG_DOMAIN: &str = "peerspeak-gossip-v3"; /// Version-namespace a room topic so peers on different gossip protocol versions /// derive **different subscription topics from the same ticket** and therefore diff --git a/tests/gossip_rebootstrap.rs b/tests/gossip_rebootstrap.rs index 6478a13..1d59287 100644 --- a/tests/gossip_rebootstrap.rs +++ b/tests/gossip_rebootstrap.rs @@ -63,6 +63,7 @@ fn state(name: &str, addr: EndpointAddr) -> PeerState { addr, sharing: None, avatar: Default::default(), + game: None, } }