feat(game): Step 7 UI — opt-in toggle, roster Playing line, Settings

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 <game>' 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 15:43:24 -04:00
co-authored by Claude Opus 4.8
parent 961705ffa9
commit 3878e716dd
+335 -6
View File
@@ -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<Vec<u8>>),
/// 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<Item = UiEvent> {
@@ -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<crate::game::DetectedGame>,
/// 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<EndpointId, PeerState>,
audio_levels: HashMap<EndpointId, f32>,
/// 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::<EndpointId>() {
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<AppMessage> {
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 <game>" 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]