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
+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();