From 3878e716dd0f495da098e94609504ad0786e6cf8 Mon Sep 17 00:00:00 2001 From: Mollusk Date: Sun, 21 Jun 2026 15:43:24 -0400 Subject: [PATCH] =?UTF-8?q?feat(game):=20Step=207=20UI=20=E2=80=94=20opt-i?= =?UTF-8?q?n=20toggle,=20roster=20Playing=20line,=20Settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ' 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 --- src/app/mod.rs | 341 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 335 insertions(+), 6 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 0c65cfc..bfdc657 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -45,10 +45,11 @@ pub enum SettingsCategory { Appearance, Network, Notifications, + Games, } impl SettingsCategory { - const ALL: [SettingsCategory; 7] = [ + const ALL: [SettingsCategory; 8] = [ SettingsCategory::Audio, SettingsCategory::Hotkeys, SettingsCategory::Recording, @@ -56,6 +57,7 @@ impl SettingsCategory { SettingsCategory::Appearance, SettingsCategory::Network, SettingsCategory::Notifications, + SettingsCategory::Games, ]; fn label(self) -> &'static str { @@ -67,6 +69,7 @@ impl SettingsCategory { SettingsCategory::Appearance => "Appearance", SettingsCategory::Network => "Network", SettingsCategory::Notifications => "Notifications", + SettingsCategory::Games => "Games", } } @@ -79,6 +82,7 @@ impl SettingsCategory { SettingsCategory::Appearance => "Layout and theme", SettingsCategory::Network => "Relay and privacy mode", SettingsCategory::Notifications => "Chimes and sounds", + SettingsCategory::Games => "Detection, presence, backgrounds", } } } @@ -342,6 +346,47 @@ pub enum AppMessage { ShutdownCommandSent(bool), /// Fallback close if the core does not acknowledge shutdown promptly. 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>), + /// 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 { @@ -386,6 +431,13 @@ pub struct AppState { /// `None` = nothing detected. Independent of whether we broadcast it to peers /// (that's `config.game_presence_enabled`). current_game: Option, + /// 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, audio_levels: HashMap, /// Peers we've locally muted (their audio isn't mixed into our output). @@ -519,6 +571,10 @@ impl Default for AppState { let _ = controller.send(CoreCommand::SetRecordingMode(config.recording_mode)); let _ = controller.send(CoreCommand::SetPixelpassPath(config.pixelpass_path.clone())); 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 { if let Ok(id) = peer.parse::() { let _ = controller.send(CoreCommand::SetPeerEq(id, *settings)); @@ -571,6 +627,9 @@ impl Default for AppState { config, background_image, current_game: None, + game_map_exe_input: String::new(), + game_map_name_input: String::new(), + game_override: GameOverrideChoice::Auto, peers: HashMap::new(), audio_levels: HashMap::new(), locally_muted: HashSet::new(), @@ -1611,6 +1670,126 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.config.background_dim = dim.clamp(0.0, 1.0); 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 => { state.drawer_chat_open = !state.drawer_chat_open; } @@ -3236,6 +3415,126 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .spacing(10) .width(iced::Length::Fill) .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> { @@ -3555,6 +3854,22 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { ] .spacing(10) .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. { let el: Element<'_, AppMessage> = if state.self_sharing { @@ -3702,10 +4017,20 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { let mut card_content = column![ row![ avatar_view(&peer.avatar, &peer.name, &peer_id.to_string(), 38.0), - column![ - text(&peer.name).size(16).color(color_text), - text(format!("ID: {}", short_id(&peer_id.to_string()))).size(11).color(color_subtext) - ], + { + // Name + id, plus a "Playing " 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(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, horizontal_space(), share_el, @@ -5544,10 +5869,14 @@ mod tests { let labels: Vec<_> = SettingsCategory::ALL.iter().map(|c| c.label()).collect(); assert_eq!( 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::Profile.hint(), "Avatar and identity"); + assert_eq!(SettingsCategory::Games.hint(), "Detection, presence, backgrounds"); } #[test]