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
+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,
}
}