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>
This commit is contained in:
2026-06-21 15:31:09 -04:00
co-authored by Claude Opus 4.8
parent 7d44808a5e
commit 961705ffa9
10 changed files with 190 additions and 4 deletions
Generated
+1 -1
View File
@@ -4871,7 +4871,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "peerspeak"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"anyhow",
"async-trait",
+1 -1
View File
@@ -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.
+32
View File
@@ -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<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>,
peers: HashMap<EndpointId, PeerState>,
audio_levels: HashMap<EndpointId, f32>,
/// 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<bytes::Bytes> {
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 {
// 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<AppMessage> {
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();
+2
View File
@@ -64,6 +64,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
addr: endpoint_a.addr(),
sharing: None,
avatar: Default::default(),
game: None,
};
room_a.join(&ticket_str, state_a, vec![]).await?;
println!("Node A joined topic.");
@@ -83,6 +84,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
addr: endpoint_b.addr(),
sharing: None,
avatar: Default::default(),
game: None,
};
room_b.join(&ticket_str, state_b, vec![]).await?;
println!("Node B joined topic.");
+17
View File
@@ -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<String, String>),
}
#[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<crate::game::DetectedGame>),
/// Core finished orderly app shutdown and the GUI can exit.
ShutdownComplete,
Error(String),
+80
View File
@@ -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<String> {
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<tokio::time::Instant>,
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<crate::game::DetectedGame> = None;
let mut network_mode = NetworkMode::default();
// Pixelpass binary override (config), and the ticket of our own active screen
// share (rides our presence so the room — incl. late joiners — can watch).
@@ -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;
}
+30
View File
@@ -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::<GossipPayload>(&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,
}
}
+18
View File
@@ -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 <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
@@ -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<String>,
}
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);
+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
/// (`ChatAttachment`), so a pre-v2 peer can't interpret/serve chat files — bumped
/// to fail fast rather than half-work.
pub const GOSSIP_PROTO: u32 = 2;
///
/// v3 (0.4.0): `PeerState` gained an optional `game` presence field (the
/// `Playing <name>` status). The field is `#[serde(default)]`, so the bump isn't
/// strictly required for decoding — but per the versioning discipline a wire-shape
/// change is isolated into its own topic + signature domain so v2 and v3 peers
/// never share a swarm. Resync everyone, exactly like the W4 avatar bump.
pub const GOSSIP_PROTO: u32 = 3;
/// File-transfer plane version (chat attachment request/stream shape). Bump on
/// any change. Mirrored in [`FILES_ALPN`].
pub const FILES_PROTO: u32 = 1;
@@ -41,7 +47,7 @@ pub const FILES_ALPN: &[u8] = b"peerspeak/files/1";
/// ed25519 gossip signature domain: `peerspeak-gossip-v<GOSSIP_PROTO>`. Carries
/// the gossip protocol version into every signed payload — a version mismatch
/// fails verification (cryptographic separation between gossip versions).
pub const GOSSIP_SIG_DOMAIN: &str = "peerspeak-gossip-v2";
pub const GOSSIP_SIG_DOMAIN: &str = "peerspeak-gossip-v3";
/// Version-namespace a room topic so peers on different gossip protocol versions
/// derive **different subscription topics from the same ticket** and therefore
+1
View File
@@ -63,6 +63,7 @@ fn state(name: &str, addr: EndpointAddr) -> PeerState {
addr,
sharing: None,
avatar: Default::default(),
game: None,
}
}