Compare commits
7
Commits
7e4f2f2127
...
fad65a4fcf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fad65a4fcf | ||
|
|
3878e716dd | ||
|
|
961705ffa9 | ||
|
|
7d44808a5e | ||
|
|
e31d3db986 | ||
|
|
87a2209a85 | ||
|
|
9e8c8b4ace |
Generated
+2
-1
@@ -4871,7 +4871,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "peerspeak"
|
name = "peerspeak"
|
||||||
version = "0.3.0"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -4894,6 +4894,7 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+10
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "peerspeak"
|
name = "peerspeak"
|
||||||
version = "0.3.0"
|
version = "0.4.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
# Application crate, not a crates.io library — refuse `cargo publish` and let
|
# Application crate, not a crates.io library — refuse `cargo publish` and let
|
||||||
# cargo-deny's [licenses.private] skip the missing-license check.
|
# cargo-deny's [licenses.private] skip the missing-license check.
|
||||||
@@ -65,3 +65,12 @@ rfd = { version = "0.17", default-features = false }
|
|||||||
# Windows audio backend: cpal drives WASAPI for capture/playback behind the
|
# Windows audio backend: cpal drives WASAPI for capture/playback behind the
|
||||||
# AudioBackend trait (src/audio/cpal_impl.rs). The Linux counterpart is pipewire.
|
# AudioBackend trait (src/audio/cpal_impl.rs). The Linux counterpart is pipewire.
|
||||||
cpal = "0.15"
|
cpal = "0.15"
|
||||||
|
# Win32 FFI for game detection (no new crate: windows-sys is already pulled in
|
||||||
|
# transitively by cpal/rfd). Registry reads the Steam RunningAppID + install path;
|
||||||
|
# Toolhelp enumerates running processes for the non-Steam process-scan fallback.
|
||||||
|
windows-sys = { version = "0.61", features = [
|
||||||
|
"Win32_Foundation",
|
||||||
|
"Win32_System_Registry",
|
||||||
|
"Win32_System_Diagnostics_ToolHelp",
|
||||||
|
"Win32_System_Threading",
|
||||||
|
] }
|
||||||
|
|||||||
+366
-5
@@ -45,10 +45,11 @@ pub enum SettingsCategory {
|
|||||||
Appearance,
|
Appearance,
|
||||||
Network,
|
Network,
|
||||||
Notifications,
|
Notifications,
|
||||||
|
Games,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SettingsCategory {
|
impl SettingsCategory {
|
||||||
const ALL: [SettingsCategory; 7] = [
|
const ALL: [SettingsCategory; 8] = [
|
||||||
SettingsCategory::Audio,
|
SettingsCategory::Audio,
|
||||||
SettingsCategory::Hotkeys,
|
SettingsCategory::Hotkeys,
|
||||||
SettingsCategory::Recording,
|
SettingsCategory::Recording,
|
||||||
@@ -56,6 +57,7 @@ impl SettingsCategory {
|
|||||||
SettingsCategory::Appearance,
|
SettingsCategory::Appearance,
|
||||||
SettingsCategory::Network,
|
SettingsCategory::Network,
|
||||||
SettingsCategory::Notifications,
|
SettingsCategory::Notifications,
|
||||||
|
SettingsCategory::Games,
|
||||||
];
|
];
|
||||||
|
|
||||||
fn label(self) -> &'static str {
|
fn label(self) -> &'static str {
|
||||||
@@ -67,6 +69,7 @@ impl SettingsCategory {
|
|||||||
SettingsCategory::Appearance => "Appearance",
|
SettingsCategory::Appearance => "Appearance",
|
||||||
SettingsCategory::Network => "Network",
|
SettingsCategory::Network => "Network",
|
||||||
SettingsCategory::Notifications => "Notifications",
|
SettingsCategory::Notifications => "Notifications",
|
||||||
|
SettingsCategory::Games => "Games",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +82,7 @@ impl SettingsCategory {
|
|||||||
SettingsCategory::Appearance => "Layout and theme",
|
SettingsCategory::Appearance => "Layout and theme",
|
||||||
SettingsCategory::Network => "Relay and privacy mode",
|
SettingsCategory::Network => "Relay and privacy mode",
|
||||||
SettingsCategory::Notifications => "Chimes and sounds",
|
SettingsCategory::Notifications => "Chimes and sounds",
|
||||||
|
SettingsCategory::Games => "Detection, presence, backgrounds",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -342,6 +346,47 @@ pub enum AppMessage {
|
|||||||
ShutdownCommandSent(bool),
|
ShutdownCommandSent(bool),
|
||||||
/// Fallback close if the core does not acknowledge shutdown promptly.
|
/// Fallback close if the core does not acknowledge shutdown promptly.
|
||||||
ShutdownTimeout,
|
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> {
|
fn core_subscription() -> impl iced::futures::Stream<Item = UiEvent> {
|
||||||
@@ -381,6 +426,18 @@ pub struct AppState {
|
|||||||
/// doesn't read the file from disk on every redraw. Loaded on startup and
|
/// 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.
|
/// refreshed when the background is changed/removed. `None` = no custom bg.
|
||||||
background_image: Option<bytes::Bytes>,
|
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>,
|
||||||
|
/// 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>,
|
peers: HashMap<EndpointId, PeerState>,
|
||||||
audio_levels: HashMap<EndpointId, f32>,
|
audio_levels: HashMap<EndpointId, f32>,
|
||||||
/// Peers we've locally muted (their audio isn't mixed into our output).
|
/// Peers we've locally muted (their audio isn't mixed into our output).
|
||||||
@@ -514,6 +571,10 @@ impl Default for AppState {
|
|||||||
let _ = controller.send(CoreCommand::SetRecordingMode(config.recording_mode));
|
let _ = controller.send(CoreCommand::SetRecordingMode(config.recording_mode));
|
||||||
let _ = controller.send(CoreCommand::SetPixelpassPath(config.pixelpass_path.clone()));
|
let _ = controller.send(CoreCommand::SetPixelpassPath(config.pixelpass_path.clone()));
|
||||||
let _ = controller.send(CoreCommand::SetPresenceMode(config.presence_mode));
|
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 {
|
for (peer, settings) in &config.peer_eq {
|
||||||
if let Ok(id) = peer.parse::<EndpointId>() {
|
if let Ok(id) = peer.parse::<EndpointId>() {
|
||||||
let _ = controller.send(CoreCommand::SetPeerEq(id, *settings));
|
let _ = controller.send(CoreCommand::SetPeerEq(id, *settings));
|
||||||
@@ -565,6 +626,10 @@ impl Default for AppState {
|
|||||||
selected_output,
|
selected_output,
|
||||||
config,
|
config,
|
||||||
background_image,
|
background_image,
|
||||||
|
current_game: None,
|
||||||
|
game_map_exe_input: String::new(),
|
||||||
|
game_map_name_input: String::new(),
|
||||||
|
game_override: GameOverrideChoice::Auto,
|
||||||
peers: HashMap::new(),
|
peers: HashMap::new(),
|
||||||
audio_levels: HashMap::new(),
|
audio_levels: HashMap::new(),
|
||||||
locally_muted: HashSet::new(),
|
locally_muted: HashSet::new(),
|
||||||
@@ -624,6 +689,23 @@ fn load_background_bytes(config: &AppConfig) -> Option<bytes::Bytes> {
|
|||||||
std::fs::read(path).ok().map(bytes::Bytes::from)
|
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 {
|
pub fn run_gui() -> iced::Result {
|
||||||
// Restore the last window size (saved on close). Position is restored too,
|
// 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
|
// but only on X11 — Wayland's xdg-shell gives clients no way to set their own
|
||||||
@@ -1144,6 +1226,15 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
format!("Presence mode stayed {mode}")
|
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 => {
|
UiEvent::ShutdownComplete => {
|
||||||
if state.closing {
|
if state.closing {
|
||||||
return iced::exit();
|
return iced::exit();
|
||||||
@@ -1579,6 +1670,126 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
state.config.background_dim = dim.clamp(0.0, 1.0);
|
state.config.background_dim = dim.clamp(0.0, 1.0);
|
||||||
state.config.save();
|
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 => {
|
AppMessage::ToggleDrawerChat => {
|
||||||
state.drawer_chat_open = !state.drawer_chat_open;
|
state.drawer_chat_open = !state.drawer_chat_open;
|
||||||
}
|
}
|
||||||
@@ -3204,6 +3415,126 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
.spacing(10)
|
.spacing(10)
|
||||||
.width(iced::Length::Fill)
|
.width(iced::Length::Fill)
|
||||||
.into(),
|
.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> {
|
let category_button = |category: SettingsCategory| -> Element<'_, AppMessage> {
|
||||||
@@ -3523,6 +3854,22 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
]
|
]
|
||||||
.spacing(10)
|
.spacing(10)
|
||||||
.align_y(iced::alignment::Vertical::Center),
|
.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.
|
// Live "you're sharing" badge — only present while sharing.
|
||||||
{
|
{
|
||||||
let el: Element<'_, AppMessage> = if state.self_sharing {
|
let el: Element<'_, AppMessage> = if state.self_sharing {
|
||||||
@@ -3670,10 +4017,20 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
let mut card_content = column![
|
let mut card_content = column![
|
||||||
row![
|
row![
|
||||||
avatar_view(&peer.avatar, &peer.name, &peer_id.to_string(), 38.0),
|
avatar_view(&peer.avatar, &peer.name, &peer_id.to_string(), 38.0),
|
||||||
column![
|
{
|
||||||
|
// 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(&peer.name).size(16).color(color_text),
|
||||||
text(format!("ID: {}", short_id(&peer_id.to_string()))).size(11).color(color_subtext)
|
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,
|
add_friend_el,
|
||||||
horizontal_space(),
|
horizontal_space(),
|
||||||
share_el,
|
share_el,
|
||||||
@@ -5512,10 +5869,14 @@ mod tests {
|
|||||||
let labels: Vec<_> = SettingsCategory::ALL.iter().map(|c| c.label()).collect();
|
let labels: Vec<_> = SettingsCategory::ALL.iter().map(|c| c.label()).collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
labels,
|
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::Audio.hint(), "Devices, mic gate, echo");
|
||||||
assert_eq!(SettingsCategory::Profile.hint(), "Avatar and identity");
|
assert_eq!(SettingsCategory::Profile.hint(), "Avatar and identity");
|
||||||
|
assert_eq!(SettingsCategory::Games.hint(), "Detection, presence, backgrounds");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -45,6 +45,22 @@ pub fn process_background(raw: &[u8]) -> Result<Vec<u8>, String> {
|
|||||||
Ok(png.into_inner())
|
Ok(png.into_inner())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A filesystem-safe, app-owned filename for the processed PNG of a per-game
|
||||||
|
/// background (W18), derived from the game's stable id by hashing rather than
|
||||||
|
/// embedding the raw id: keeps the name short and safe (ids contain `:` and
|
||||||
|
/// arbitrary executable basenames) and avoids leaking the id into the filesystem.
|
||||||
|
/// Deterministic and dependency-free (FNV-1a 64-bit), so the same game id always
|
||||||
|
/// maps to the same file.
|
||||||
|
pub fn game_background_filename(game_id: &str) -> String {
|
||||||
|
// FNV-1a, 64-bit.
|
||||||
|
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
|
||||||
|
for b in game_id.as_bytes() {
|
||||||
|
hash ^= *b as u64;
|
||||||
|
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
||||||
|
}
|
||||||
|
format!("game-bg-{hash:016x}.png")
|
||||||
|
}
|
||||||
|
|
||||||
/// The legibility scrim drawn between the background image and the UI: the active
|
/// The legibility scrim drawn between the background image and the UI: the active
|
||||||
/// theme's base colour at `dim` alpha (clamped to `0.0..=1.0`). A higher `dim`
|
/// theme's base colour at `dim` alpha (clamped to `0.0..=1.0`). A higher `dim`
|
||||||
/// recedes the image so body text and panel chrome stay readable, and it re-tints
|
/// recedes the image so body text and panel chrome stay readable, and it re-tints
|
||||||
@@ -90,6 +106,17 @@ mod tests {
|
|||||||
assert!(process_background(b"definitely not an image").is_err());
|
assert!(process_background(b"definitely not an image").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn game_background_filename_is_stable_safe_and_distinct() {
|
||||||
|
let a = game_background_filename("steam:730");
|
||||||
|
// Stable for the same id.
|
||||||
|
assert_eq!(a, game_background_filename("steam:730"));
|
||||||
|
// Distinct ids → distinct files (no `:` or path chars leak through).
|
||||||
|
assert_ne!(a, game_background_filename("exe:hl2_linux"));
|
||||||
|
assert!(a.starts_with("game-bg-") && a.ends_with(".png"));
|
||||||
|
assert!(!a.contains(':') && !a.contains('/') && !a.contains('\\'));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scrim_color_sets_alpha_and_keeps_rgb() {
|
fn scrim_color_sets_alpha_and_keeps_rgb() {
|
||||||
let base = Color::from_rgb(0.1, 0.2, 0.3);
|
let base = Color::from_rgb(0.1, 0.2, 0.3);
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
addr: endpoint_a.addr(),
|
addr: endpoint_a.addr(),
|
||||||
sharing: None,
|
sharing: None,
|
||||||
avatar: Default::default(),
|
avatar: Default::default(),
|
||||||
|
game: None,
|
||||||
};
|
};
|
||||||
room_a.join(&ticket_str, state_a, vec![]).await?;
|
room_a.join(&ticket_str, state_a, vec![]).await?;
|
||||||
println!("Node A joined topic.");
|
println!("Node A joined topic.");
|
||||||
@@ -83,6 +84,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
addr: endpoint_b.addr(),
|
addr: endpoint_b.addr(),
|
||||||
sharing: None,
|
sharing: None,
|
||||||
avatar: Default::default(),
|
avatar: Default::default(),
|
||||||
|
game: None,
|
||||||
};
|
};
|
||||||
room_b.join(&ticket_str, state_b, vec![]).await?;
|
room_b.join(&ticket_str, state_b, vec![]).await?;
|
||||||
println!("Node B joined topic.");
|
println!("Node B joined topic.");
|
||||||
|
|||||||
+88
-6
@@ -1,7 +1,7 @@
|
|||||||
use crate::notify::Sound;
|
use crate::notify::Sound;
|
||||||
use crate::theme::AppTheme;
|
use crate::theme::AppTheme;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::{BTreeMap, HashMap};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
@@ -199,6 +199,26 @@ pub struct AppConfig {
|
|||||||
/// `crate::background::scrim_color`.
|
/// `crate::background::scrim_color`.
|
||||||
#[serde(default = "default_background_dim")]
|
#[serde(default = "default_background_dim")]
|
||||||
pub background_dim: f32,
|
pub background_dim: f32,
|
||||||
|
/// Broadcast the detected game as presence next to our avatar (game-detection
|
||||||
|
/// feature). **Opt-in, default OFF.** Enabling immediately publishes the
|
||||||
|
/// current game; disabling immediately publishes `game: None`. Toggling this
|
||||||
|
/// is the only thing that puts our game on the wire — detection itself (for the
|
||||||
|
/// local background) runs regardless.
|
||||||
|
#[serde(default)]
|
||||||
|
pub game_presence_enabled: bool,
|
||||||
|
/// Per-game UI background overrides (W18), keyed by stable game id
|
||||||
|
/// (`steam:730`, `exe:hl2_linux`) → path to the processed PNG we wrote in the
|
||||||
|
/// config dir (see `game_background_path`). The running game's entry wins; with
|
||||||
|
/// no entry we fall back to the single custom `background`. Local-only; never
|
||||||
|
/// sent to peers. `BTreeMap` for deterministic serialization.
|
||||||
|
#[serde(default)]
|
||||||
|
pub game_backgrounds: BTreeMap<String, String>,
|
||||||
|
/// User process→display-name mappings for non-Steam game detection, keyed by
|
||||||
|
/// normalized executable basename (`hl2_linux`) → the name to show/broadcast
|
||||||
|
/// (`Half-Life 2`). Only exact mappings here are ever matched (we never guess a
|
||||||
|
/// game from an arbitrary process). Local-only.
|
||||||
|
#[serde(default)]
|
||||||
|
pub game_process_map: BTreeMap<String, String>,
|
||||||
/// What a call recording captures (mixed / per-peer stems / both).
|
/// What a call recording captures (mixed / per-peer stems / both).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub recording_mode: RecordingMode,
|
pub recording_mode: RecordingMode,
|
||||||
@@ -305,6 +325,9 @@ impl Default for AppConfig {
|
|||||||
avatar: crate::avatar::Avatar::default(),
|
avatar: crate::avatar::Avatar::default(),
|
||||||
background: None,
|
background: None,
|
||||||
background_dim: default_background_dim(),
|
background_dim: default_background_dim(),
|
||||||
|
game_presence_enabled: false,
|
||||||
|
game_backgrounds: BTreeMap::new(),
|
||||||
|
game_process_map: BTreeMap::new(),
|
||||||
recording_mode: RecordingMode::default(),
|
recording_mode: RecordingMode::default(),
|
||||||
custom_sound_self_join: None,
|
custom_sound_self_join: None,
|
||||||
custom_sound_peer_join: None,
|
custom_sound_peer_join: None,
|
||||||
@@ -375,17 +398,31 @@ impl AppConfig {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Path the processed custom-background PNG (W16) is written to, alongside
|
/// Path to a processed-background PNG of the given filename, alongside
|
||||||
/// `config.json` in the app config dir. We store our own downscaled copy here
|
/// `config.json` in the app config dir. We store our own downscaled copies here
|
||||||
/// (rather than base64 in the config) so the JSON stays small.
|
/// (rather than base64 in the config) so the JSON stays small. Used for both
|
||||||
pub fn background_path() -> Option<PathBuf> {
|
/// the single custom background and the per-game backgrounds.
|
||||||
|
fn background_dir_path(filename: &str) -> Option<PathBuf> {
|
||||||
dirs::config_dir().map(|mut p| {
|
dirs::config_dir().map(|mut p| {
|
||||||
p.push("peerspeak");
|
p.push("peerspeak");
|
||||||
p.push("background.png");
|
p.push(filename);
|
||||||
p
|
p
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Path the single custom-background PNG (W16) is written to.
|
||||||
|
pub fn background_path() -> Option<PathBuf> {
|
||||||
|
Self::background_dir_path("background.png")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path the processed per-game background PNG (W18) for `game_id` is written
|
||||||
|
/// to. The filename is an app-owned hash of the id (see
|
||||||
|
/// `crate::background::game_background_filename`), so raw game ids never appear
|
||||||
|
/// on disk and the name is always filesystem-safe.
|
||||||
|
pub fn game_background_path(game_id: &str) -> Option<PathBuf> {
|
||||||
|
Self::background_dir_path(&crate::background::game_background_filename(game_id))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn load() -> Self {
|
pub fn load() -> Self {
|
||||||
if let Some(path) = Self::config_path()
|
if let Some(path) = Self::config_path()
|
||||||
&& let Ok(contents) = fs::read_to_string(&path)
|
&& let Ok(contents) = fs::read_to_string(&path)
|
||||||
@@ -480,6 +517,51 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_backward_compat_game_detection_fields() {
|
||||||
|
// A config that predates the game-detection feature (W18) — and crucially
|
||||||
|
// still carries the W16 single `background` as a plain string — must
|
||||||
|
// deserialize without error. `AppConfig::load()` silently replaces ANY
|
||||||
|
// deserialize failure with full defaults, so a broken migration here would
|
||||||
|
// wipe everyone's settings; this guards that the additive fields kept the
|
||||||
|
// old shape loadable and that `background` was NOT retyped.
|
||||||
|
let legacy_json = r#"{
|
||||||
|
"input_device": "",
|
||||||
|
"output_device": "",
|
||||||
|
"noise_gate_threshold": 0.01,
|
||||||
|
"username": "Eric",
|
||||||
|
"background": "/home/eric/.config/peerspeak/background.png",
|
||||||
|
"background_dim": 0.4
|
||||||
|
}"#;
|
||||||
|
let cfg: AppConfig = serde_json::from_str(legacy_json).unwrap();
|
||||||
|
// The pre-existing single background survives untouched (still Option<String>).
|
||||||
|
assert_eq!(cfg.background.as_deref(), Some("/home/eric/.config/peerspeak/background.png"));
|
||||||
|
assert!((cfg.background_dim - 0.4).abs() < f32::EPSILON);
|
||||||
|
// The new game-detection fields default to off/empty → silent, opt-in upgrade.
|
||||||
|
assert!(!cfg.game_presence_enabled);
|
||||||
|
assert!(cfg.game_backgrounds.is_empty());
|
||||||
|
assert!(cfg.game_process_map.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_game_maps_serialize_deterministically() {
|
||||||
|
// BTreeMap ordering makes the serialized config stable across runs.
|
||||||
|
let mut cfg = AppConfig::default();
|
||||||
|
cfg.game_backgrounds.insert("steam:730".into(), "/a.png".into());
|
||||||
|
cfg.game_backgrounds.insert("exe:hl2_linux".into(), "/b.png".into());
|
||||||
|
cfg.game_process_map.insert("hl2_linux".into(), "Half-Life 2".into());
|
||||||
|
let json = serde_json::to_string(&cfg).unwrap();
|
||||||
|
// Keys appear in sorted order (exe: before steam:).
|
||||||
|
let bg = json.find("game_backgrounds").unwrap();
|
||||||
|
let exe_at = json[bg..].find("exe:hl2_linux").unwrap();
|
||||||
|
let steam_at = json[bg..].find("steam:730").unwrap();
|
||||||
|
assert!(exe_at < steam_at, "BTreeMap keys must serialize sorted");
|
||||||
|
// Full round-trip preserves the maps.
|
||||||
|
let back: AppConfig = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(back.game_backgrounds, cfg.game_backgrounds);
|
||||||
|
assert_eq!(back.game_process_map, cfg.game_process_map);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_window_size_fields() {
|
fn test_window_size_fields() {
|
||||||
// Default impl is the standard launch size.
|
// Default impl is the standard launch size.
|
||||||
|
|||||||
@@ -89,6 +89,17 @@ pub enum CoreCommand {
|
|||||||
/// invisible) and the outbound ping scheduler (invisible = fully dark). Sent at
|
/// invisible) and the outbound ping scheduler (invisible = fully dark). Sent at
|
||||||
/// startup from config and whenever the user changes it.
|
/// startup from config and whenever the user changes it.
|
||||||
SetPresenceMode(PresenceMode),
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -150,6 +161,12 @@ pub enum UiEvent {
|
|||||||
/// failure, this carries the previous truthful mode. The GUI must mirror +
|
/// failure, this carries the previous truthful mode. The GUI must mirror +
|
||||||
/// persist this so its presence picker matches the endpoint's discovery state.
|
/// persist this so its presence picker matches the endpoint's discovery state.
|
||||||
PresenceModeReverted { mode: PresenceMode },
|
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.
|
/// Core finished orderly app shutdown and the GUI can exit.
|
||||||
ShutdownComplete,
|
ShutdownComplete,
|
||||||
Error(String),
|
Error(String),
|
||||||
|
|||||||
+117
-43
@@ -7,7 +7,7 @@ use crate::audio::eq::{Eq, EqSettings};
|
|||||||
use crate::codec::{AudioEncoder, opus_impl::OpusEncoder};
|
use crate::codec::{AudioEncoder, opus_impl::OpusEncoder};
|
||||||
use crate::core::jitter::{JitterBuffer, FRAME_SAMPLES};
|
use crate::core::jitter::{JitterBuffer, FRAME_SAMPLES};
|
||||||
use crate::network::{
|
use crate::network::{
|
||||||
NetworkTransport, RoomState, PeerState, RoomEvent, ConnEvent, PeerSpeakTicket,
|
NetworkTransport, RoomState, SelfPresence, RoomEvent, ConnEvent, PeerSpeakTicket,
|
||||||
iroh_impl::{IrohTransport, AudioRouter, FileRouter},
|
iroh_impl::{IrohTransport, AudioRouter, FileRouter},
|
||||||
gossip::IrohGossipState,
|
gossip::IrohGossipState,
|
||||||
};
|
};
|
||||||
@@ -80,6 +80,17 @@ fn audio_datagram_len_ok(len: usize) -> bool {
|
|||||||
(4..=4 + MAX_OPUS_PAYLOAD).contains(&len)
|
(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(
|
fn arm_discovery_retry(
|
||||||
discovery_deadline: &mut Option<tokio::time::Instant>,
|
discovery_deadline: &mut Option<tokio::time::Instant>,
|
||||||
now: tokio::time::Instant,
|
now: tokio::time::Instant,
|
||||||
@@ -917,10 +928,30 @@ async fn run_core_loop(
|
|||||||
let peer_gate = Arc::new(Mutex::new(HashMap::<EndpointId, f32>::new()));
|
let peer_gate = Arc::new(Mutex::new(HashMap::<EndpointId, f32>::new()));
|
||||||
// Peers locally muted by us: decoded for level metering but not mixed.
|
// Peers locally muted by us: decoded for level metering but not mixed.
|
||||||
let locally_muted = Arc::new(Mutex::new(HashSet::<EndpointId>::new()));
|
let locally_muted = Arc::new(Mutex::new(HashSet::<EndpointId>::new()));
|
||||||
let mut current_name = "Anonymous".to_string();
|
// Sticky identity fields of our own presence (display name + W4 avatar), set on
|
||||||
// Our chosen avatar (W4), set on Join and changeable via SetAvatar; included
|
// Join and changed via SetName/SetAvatar. Combined with the volatile per-announce
|
||||||
// in every self-state we announce over presence.
|
// fields (mute/addr/share ticket) by `SelfPresence::to_state` — the single place
|
||||||
let mut current_avatar = crate::avatar::Avatar::default();
|
// our `PeerState` is built. Defaults match the prior `current_name`/`current_avatar`.
|
||||||
|
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();
|
let mut network_mode = NetworkMode::default();
|
||||||
// Pixelpass binary override (config), and the ticket of our own active screen
|
// Pixelpass binary override (config), and the ticket of our own active screen
|
||||||
// share (rides our presence so the room — incl. late joiners — can watch).
|
// share (rides our presence so the room — incl. late joiners — can watch).
|
||||||
@@ -1027,6 +1058,30 @@ async fn run_core_loop(
|
|||||||
Some(cmd) => cmd,
|
Some(cmd) => cmd,
|
||||||
None => break,
|
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() => {
|
_ = ping_interval.tick() => {
|
||||||
// Fully dark while Invisible (the user's choice): don't even probe,
|
// 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
|
// so nothing we do touches a friend's machine. Otherwise refresh in a
|
||||||
@@ -1125,8 +1180,8 @@ async fn run_core_loop(
|
|||||||
}
|
}
|
||||||
|
|
||||||
CoreCommand::Join { name, ticket, room_name, input_device, output_device, echo_cancellation, avatar } => {
|
CoreCommand::Join { name, ticket, room_name, input_device, output_device, echo_cancellation, avatar } => {
|
||||||
current_name = name.clone();
|
presence.name = name.clone();
|
||||||
current_avatar = avatar;
|
presence.avatar = avatar;
|
||||||
|
|
||||||
// Finalize any recording before tearing down the old session — its
|
// Finalize any recording before tearing down the old session — its
|
||||||
// capture/mixer feeders are about to stop.
|
// capture/mixer feeders are about to stop.
|
||||||
@@ -1206,13 +1261,11 @@ async fn run_core_loop(
|
|||||||
|
|
||||||
// Fresh join starts not sharing; clear any stale share ticket.
|
// Fresh join starts not sharing; clear any stale share ticket.
|
||||||
current_sharing = None;
|
current_sharing = None;
|
||||||
let self_state = PeerState {
|
let self_state = presence.to_state(
|
||||||
name: current_name.clone(),
|
is_muted.load(Ordering::Relaxed),
|
||||||
is_muted: is_muted.load(Ordering::Relaxed),
|
endpoint.addr(),
|
||||||
addr: endpoint.addr(),
|
None,
|
||||||
sharing: None,
|
);
|
||||||
avatar: current_avatar.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Snapshot THIS room's retained peers (by ticket) as extra bootstrap
|
// Snapshot THIS room's retained peers (by ticket) as extra bootstrap
|
||||||
// targets so a rejoin can dial them (A8) — including after a detour
|
// targets so a rejoin can dial them (A8) — including after a detour
|
||||||
@@ -1917,29 +1970,25 @@ async fn run_core_loop(
|
|||||||
is_muted.store(new_state, Ordering::Relaxed);
|
is_muted.store(new_state, Ordering::Relaxed);
|
||||||
|
|
||||||
if let Some(session) = &active_session {
|
if let Some(session) = &active_session {
|
||||||
let self_state = PeerState {
|
let self_state = presence.to_state(
|
||||||
name: current_name.clone(),
|
new_state,
|
||||||
is_muted: new_state,
|
net.endpoint.addr(),
|
||||||
addr: net.endpoint.addr(),
|
current_sharing.clone(),
|
||||||
sharing: current_sharing.clone(),
|
);
|
||||||
avatar: current_avatar.clone(),
|
|
||||||
};
|
|
||||||
let _ = session.room_state.update_self_state(self_state).await;
|
let _ = session.room_state.update_self_state(self_state).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CoreCommand::SetAvatar(avatar) => {
|
CoreCommand::SetAvatar(avatar) => {
|
||||||
current_avatar = avatar;
|
presence.avatar = avatar;
|
||||||
// Re-announce presence so the room (incl. late joiners, via the
|
// Re-announce presence so the room (incl. late joiners, via the
|
||||||
// retained presence) picks up the new avatar (W4).
|
// retained presence) picks up the new avatar (W4).
|
||||||
if let Some(session) = &active_session {
|
if let Some(session) = &active_session {
|
||||||
let self_state = PeerState {
|
let self_state = presence.to_state(
|
||||||
name: current_name.clone(),
|
is_muted.load(Ordering::Relaxed),
|
||||||
is_muted: is_muted.load(Ordering::Relaxed),
|
net.endpoint.addr(),
|
||||||
addr: net.endpoint.addr(),
|
current_sharing.clone(),
|
||||||
sharing: current_sharing.clone(),
|
);
|
||||||
avatar: current_avatar.clone(),
|
|
||||||
};
|
|
||||||
let _ = session.room_state.update_self_state(self_state).await;
|
let _ = session.room_state.update_self_state(self_state).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2184,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) => {
|
CoreCommand::SetRecordingMode(mode) => {
|
||||||
recording_mode = mode;
|
recording_mode = mode;
|
||||||
}
|
}
|
||||||
@@ -2337,13 +2415,11 @@ async fn run_core_loop(
|
|||||||
crate::log_msg("Screen share host started");
|
crate::log_msg("Screen share host started");
|
||||||
session.screenshare_host = Some(child);
|
session.screenshare_host = Some(child);
|
||||||
current_sharing = Some(ticket.clone());
|
current_sharing = Some(ticket.clone());
|
||||||
let self_state = PeerState {
|
let self_state = presence.to_state(
|
||||||
name: current_name.clone(),
|
is_muted.load(Ordering::Relaxed),
|
||||||
is_muted: is_muted.load(Ordering::Relaxed),
|
net.endpoint.addr(),
|
||||||
addr: net.endpoint.addr(),
|
Some(ticket),
|
||||||
sharing: Some(ticket),
|
);
|
||||||
avatar: current_avatar.clone(),
|
|
||||||
};
|
|
||||||
let _ = session.room_state.update_self_state(self_state).await;
|
let _ = session.room_state.update_self_state(self_state).await;
|
||||||
let _ = ui_tx.send(UiEvent::ScreenShareStarted).await;
|
let _ = ui_tx.send(UiEvent::ScreenShareStarted).await;
|
||||||
}
|
}
|
||||||
@@ -2362,13 +2438,11 @@ async fn run_core_loop(
|
|||||||
let _ = child.kill().await;
|
let _ = child.kill().await;
|
||||||
crate::log_msg("Screen share host stopped");
|
crate::log_msg("Screen share host stopped");
|
||||||
}
|
}
|
||||||
let self_state = PeerState {
|
let self_state = presence.to_state(
|
||||||
name: current_name.clone(),
|
is_muted.load(Ordering::Relaxed),
|
||||||
is_muted: is_muted.load(Ordering::Relaxed),
|
net.endpoint.addr(),
|
||||||
addr: net.endpoint.addr(),
|
None,
|
||||||
sharing: None,
|
);
|
||||||
avatar: current_avatar.clone(),
|
|
||||||
};
|
|
||||||
let _ = session.room_state.update_self_state(self_state).await;
|
let _ = session.room_state.update_self_state(self_state).await;
|
||||||
}
|
}
|
||||||
let _ = ui_tx.send(UiEvent::ScreenShareStopped).await;
|
let _ = ui_tx.send(UiEvent::ScreenShareStopped).await;
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
//! The detector service (§5): one cancellable background worker that polls the OS
|
||||||
|
//! adapters, runs the pure matcher + debouncer, and publishes the stable detected
|
||||||
|
//! game on a watch channel — only when it changes, so a flapping detector can't
|
||||||
|
//! spam `PeerState` re-announces.
|
||||||
|
//!
|
||||||
|
//! All the OS reads (Steam files / registry, the process scan) are blocking, so
|
||||||
|
//! the worker is a dedicated `std::thread`, not a tokio task; it owns the
|
||||||
|
//! [`SteamProbe`] cache and the [`Debouncer`] across ticks. The per-tick decision
|
||||||
|
//! is factored into the pure [`poll_once`] so the wiring of resolve + match +
|
||||||
|
//! debounce is unit-tested without any I/O.
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
builtin_denylist, match_processes, resolve, Debouncer, DetectedGame, ManualOverride,
|
||||||
|
};
|
||||||
|
use super::scan;
|
||||||
|
use super::steam::SteamProbe;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::watch;
|
||||||
|
|
||||||
|
/// How often the detector samples Steam state + the process list.
|
||||||
|
pub const POLL_INTERVAL: Duration = Duration::from_secs(3);
|
||||||
|
/// Granularity of the cancellable sleep between polls, so a stop request is
|
||||||
|
/// honored promptly instead of after a full [`POLL_INTERVAL`].
|
||||||
|
const SLEEP_TICK: Duration = Duration::from_millis(200);
|
||||||
|
|
||||||
|
/// Apply one poll's worth of inputs to the debouncer, returning the new published
|
||||||
|
/// value **iff it changed** (the signal to re-announce presence / switch the
|
||||||
|
/// background). Pure: the caller supplies the already-fetched Steam detection and
|
||||||
|
/// process list, so resolve + match + debounce are testable with zero I/O.
|
||||||
|
pub fn poll_once(
|
||||||
|
debouncer: &mut Debouncer,
|
||||||
|
override_: &ManualOverride,
|
||||||
|
steam: Option<DetectedGame>,
|
||||||
|
processes: &[String],
|
||||||
|
process_map: &BTreeMap<String, String>,
|
||||||
|
denylist: &std::collections::BTreeSet<&str>,
|
||||||
|
) -> Option<Option<DetectedGame>> {
|
||||||
|
let matched = match_processes(processes, process_map, denylist);
|
||||||
|
let res = resolve(override_, steam, &matched);
|
||||||
|
if debouncer.observe(res.game, res.immediate) {
|
||||||
|
Some(debouncer.current().cloned())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared, live-updatable inputs to the detector, written by core (manual override
|
||||||
|
/// changes, config edits to the process map) and read each poll by the worker.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct DetectorInputs {
|
||||||
|
pub override_: Mutex<ManualOverride>,
|
||||||
|
pub process_map: Mutex<BTreeMap<String, String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A running detector service. Holds the watch receiver for detected-game changes
|
||||||
|
/// and the shared inputs; dropping it (or calling [`stop`](Self::stop)) ends the
|
||||||
|
/// worker thread.
|
||||||
|
pub struct GameDetector {
|
||||||
|
inputs: Arc<DetectorInputs>,
|
||||||
|
rx: watch::Receiver<Option<DetectedGame>>,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GameDetector {
|
||||||
|
/// Spawn the detector worker. `process_map` seeds the non-Steam mappings;
|
||||||
|
/// `override_` seeds the manual override (usually `Auto`). The worker runs
|
||||||
|
/// until [`stop`](Self::stop) or the returned `GameDetector` is dropped.
|
||||||
|
pub fn spawn(override_: ManualOverride, process_map: BTreeMap<String, String>) -> Self {
|
||||||
|
let inputs = Arc::new(DetectorInputs {
|
||||||
|
override_: Mutex::new(override_),
|
||||||
|
process_map: Mutex::new(process_map),
|
||||||
|
});
|
||||||
|
let (tx, rx) = watch::channel(None);
|
||||||
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
|
|
||||||
|
let worker_inputs = inputs.clone();
|
||||||
|
let worker_stop = stop.clone();
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("game-detector".to_string())
|
||||||
|
.spawn(move || worker_loop(worker_inputs, tx, worker_stop))
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
Self { inputs, rx, stop }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A clone of the watch receiver for detected-game changes. The current value
|
||||||
|
/// is `None` until the first non-empty detection is debounced in.
|
||||||
|
pub fn subscribe(&self) -> watch::Receiver<Option<DetectedGame>> {
|
||||||
|
self.rx.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the manual override (applied on the next poll, immediately,
|
||||||
|
/// bypassing debounce).
|
||||||
|
pub fn set_override(&self, override_: ManualOverride) {
|
||||||
|
*self.inputs.override_.lock().unwrap() = override_;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the user process→name mappings (e.g. after a Settings edit).
|
||||||
|
pub fn set_process_map(&self, map: BTreeMap<String, String>) {
|
||||||
|
*self.inputs.process_map.lock().unwrap() = map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signal the worker to exit. Idempotent; also happens on drop.
|
||||||
|
pub fn stop(&self) {
|
||||||
|
self.stop.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for GameDetector {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The blocking worker loop: probe, decide, publish on change, sleep (cancellably).
|
||||||
|
fn worker_loop(
|
||||||
|
inputs: Arc<DetectorInputs>,
|
||||||
|
tx: watch::Sender<Option<DetectedGame>>,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
) {
|
||||||
|
let denylist = builtin_denylist();
|
||||||
|
let mut steam = SteamProbe::new();
|
||||||
|
let mut debouncer = Debouncer::default();
|
||||||
|
|
||||||
|
while !stop.load(Ordering::Relaxed) {
|
||||||
|
let override_ = inputs.override_.lock().unwrap().clone();
|
||||||
|
let process_map = inputs.process_map.lock().unwrap().clone();
|
||||||
|
|
||||||
|
let steam_game = steam.detect();
|
||||||
|
let processes = scan::running_executables();
|
||||||
|
|
||||||
|
if let Some(new_current) =
|
||||||
|
poll_once(&mut debouncer, &override_, steam_game, &processes, &process_map, &denylist)
|
||||||
|
{
|
||||||
|
// A closed receiver means core shut down; stop quietly.
|
||||||
|
if tx.send(new_current).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancellable sleep: wake promptly on a stop request.
|
||||||
|
let mut slept = Duration::ZERO;
|
||||||
|
while slept < POLL_INTERVAL && !stop.load(Ordering::Relaxed) {
|
||||||
|
std::thread::sleep(SLEEP_TICK);
|
||||||
|
slept += SLEEP_TICK;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::super::GameSource;
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn game(id: &str, name: &str, source: GameSource) -> DetectedGame {
|
||||||
|
DetectedGame { id: id.into(), name: Some(name.into()), source }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
|
||||||
|
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn poll_once_debounces_steam_detection() {
|
||||||
|
let deny = builtin_denylist();
|
||||||
|
let mut d = Debouncer::default();
|
||||||
|
let steam = game("steam:730", "CS2", GameSource::Steam);
|
||||||
|
let empty = BTreeMap::new();
|
||||||
|
|
||||||
|
// First poll: detected but not yet published (needs two hits).
|
||||||
|
assert_eq!(
|
||||||
|
poll_once(&mut d, &ManualOverride::Auto, Some(steam.clone()), &[], &empty, &deny),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
// Second poll: published.
|
||||||
|
assert_eq!(
|
||||||
|
poll_once(&mut d, &ManualOverride::Auto, Some(steam.clone()), &[], &empty, &deny),
|
||||||
|
Some(Some(steam))
|
||||||
|
);
|
||||||
|
// Third identical poll: no change event.
|
||||||
|
assert_eq!(
|
||||||
|
poll_once(&mut d, &ManualOverride::Auto, Some(game("steam:730", "CS2", GameSource::Steam)), &[], &empty, &deny),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn poll_once_matches_process_when_no_steam() {
|
||||||
|
let deny = builtin_denylist();
|
||||||
|
let mut d = Debouncer::default();
|
||||||
|
let procs = vec!["/games/hl2_linux".to_string()];
|
||||||
|
let user = map(&[("hl2_linux", "Half-Life 2")]);
|
||||||
|
|
||||||
|
poll_once(&mut d, &ManualOverride::Auto, None, &procs, &user, &deny);
|
||||||
|
let change = poll_once(&mut d, &ManualOverride::Auto, None, &procs, &user, &deny);
|
||||||
|
let published = change.expect("should publish on second hit").expect("a game");
|
||||||
|
assert_eq!(published.id, "exe:hl2_linux");
|
||||||
|
assert_eq!(published.name.as_deref(), Some("Half-Life 2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn poll_once_manual_override_is_immediate() {
|
||||||
|
let deny = builtin_denylist();
|
||||||
|
let mut d = Debouncer::default();
|
||||||
|
let forced = game("steam:220", "HL2", GameSource::Steam);
|
||||||
|
// Even with a live Steam detection of something else, the override wins now.
|
||||||
|
let other = game("steam:730", "CS2", GameSource::Steam);
|
||||||
|
let change = poll_once(
|
||||||
|
&mut d,
|
||||||
|
&ManualOverride::Force(forced.clone()),
|
||||||
|
Some(other),
|
||||||
|
&[],
|
||||||
|
&BTreeMap::new(),
|
||||||
|
&deny,
|
||||||
|
);
|
||||||
|
assert_eq!(change, Some(Some(forced)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spawn_and_stop_is_clean() {
|
||||||
|
// Smoke test the lifecycle: spawning and stopping must not panic, and the
|
||||||
|
// initial published value is None.
|
||||||
|
let det = GameDetector::spawn(ManualOverride::Auto, BTreeMap::new());
|
||||||
|
assert_eq!(*det.subscribe().borrow(), None);
|
||||||
|
det.set_override(ManualOverride::ForceNone);
|
||||||
|
det.set_process_map(map(&[("x", "X")]));
|
||||||
|
det.stop();
|
||||||
|
// Dropping also stops; no hang/panic.
|
||||||
|
drop(det);
|
||||||
|
}
|
||||||
|
}
|
||||||
+483
@@ -0,0 +1,483 @@
|
|||||||
|
//! Game detection, game-presence, and game-reactive backgrounds.
|
||||||
|
//!
|
||||||
|
//! A single local "what game is running" detector feeds two consumers:
|
||||||
|
//! 1. **Local** — a per-game UI background that auto-switches (extends W16).
|
||||||
|
//! 2. **Broadcast** — a `Playing <name>` status next to our avatar in every peer's
|
||||||
|
//! roster, riding the gossip presence plane like nickname + avatar.
|
||||||
|
//!
|
||||||
|
//! This module is structured testable-seams-first: the *pure* logic lives here
|
||||||
|
//! (the stable-id scheme, the priority [`resolve`] matcher, the [`Debouncer`], and
|
||||||
|
//! the process-name [`match_processes`] mapping), unit-tested with zero I/O. The OS
|
||||||
|
//! edges — Steam state/file reads ([`steam`]) and the running-process scan
|
||||||
|
//! ([`scan`]) — feed already-parsed values into these pure functions, and the
|
||||||
|
//! cancellable poll service ([`detector`]) wires them together.
|
||||||
|
|
||||||
|
pub mod detector;
|
||||||
|
pub mod scan;
|
||||||
|
pub mod steam;
|
||||||
|
pub mod vdf;
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
|
/// Where a detected game came from. Encodes the trust/priority tier directly:
|
||||||
|
/// a manual override beats live Steam state, which beats a matched process. Used
|
||||||
|
/// only for prioritization and as a presentation hint — never trusted as identity.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum GameSource {
|
||||||
|
/// The user forced a specific game (or "none") via the manual override.
|
||||||
|
Manual,
|
||||||
|
/// Steam's live `RunningAppID` resolved against an `appmanifest`.
|
||||||
|
Steam,
|
||||||
|
/// A running process matched against the user's process→name mappings.
|
||||||
|
Process,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A game the local detector currently believes is running.
|
||||||
|
///
|
||||||
|
/// `id` is the stable, namespaced identity used as the config key for backgrounds
|
||||||
|
/// (`steam:730`, `exe:hl2_linux`) — **never** the mutable display name. `name` is
|
||||||
|
/// the human label shown locally and broadcast as presence; it is `None` only for
|
||||||
|
/// the Steam appid-without-manifest case, where the background can still switch by
|
||||||
|
/// `id` but nothing is broadcast (per the "don't invent `Steam App 123`" rule).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct DetectedGame {
|
||||||
|
/// Stable namespaced identity. Config-key safe; survives renames.
|
||||||
|
pub id: String,
|
||||||
|
/// Trustworthy human name; `None` = id-only (Steam manifest unavailable).
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// Provenance / priority tier.
|
||||||
|
pub source: GameSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DetectedGame {
|
||||||
|
/// The Steam namespaced id for an appid: `steam:<appid>`.
|
||||||
|
pub fn steam_id(app_id: u32) -> String {
|
||||||
|
format!("steam:{app_id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The process namespaced id for an executable identity: `exe:<normalized>`.
|
||||||
|
pub fn exe_id(exe: &str) -> String {
|
||||||
|
format!("exe:{}", normalize_exe(exe))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The user's manual override sitting above both detectors (D2). Small by design.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub enum ManualOverride {
|
||||||
|
/// Trust the auto-detector (default).
|
||||||
|
#[default]
|
||||||
|
Auto,
|
||||||
|
/// Force "not playing anything" regardless of what is detected.
|
||||||
|
ForceNone,
|
||||||
|
/// Force a specific game (the user picked it from the known-games list).
|
||||||
|
Force(DetectedGame),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The outcome of [`resolve`]: the chosen game (if any) plus whether the choice is
|
||||||
|
/// a manual override and so should **bypass the [`Debouncer`]** (apply immediately).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Resolution {
|
||||||
|
pub game: Option<DetectedGame>,
|
||||||
|
/// `true` when a manual override (`ForceNone`/`Force`) decided the value.
|
||||||
|
pub immediate: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the detector priority (D2 / §5): **manual override → Steam → mapped
|
||||||
|
/// process → none**. Pure; the adapters resolve `steam`/`processes` into
|
||||||
|
/// `DetectedGame`s and this only picks the winner. `processes` is in the adapter's
|
||||||
|
/// deterministic priority order (see [`match_processes`]); its first entry wins.
|
||||||
|
pub fn resolve(
|
||||||
|
override_: &ManualOverride,
|
||||||
|
steam: Option<DetectedGame>,
|
||||||
|
processes: &[DetectedGame],
|
||||||
|
) -> Resolution {
|
||||||
|
match override_ {
|
||||||
|
ManualOverride::ForceNone => Resolution { game: None, immediate: true },
|
||||||
|
ManualOverride::Force(g) => Resolution { game: Some(g.clone()), immediate: true },
|
||||||
|
ManualOverride::Auto => {
|
||||||
|
let game = steam.or_else(|| processes.first().cloned());
|
||||||
|
Resolution { game, immediate: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Samples required before a *new* game is accepted/switched to.
|
||||||
|
pub const ACCEPT_HITS: u32 = 2;
|
||||||
|
/// Consecutive "no game" samples before a currently-shown game is cleared. At the
|
||||||
|
/// ~3 s poll cadence this is ~9 s, absorbing a brief Steam stale/crash blip.
|
||||||
|
pub const CLEAR_MISSES: u32 = 3;
|
||||||
|
|
||||||
|
/// Debounces a stream of raw per-poll detections into a stable published value, so
|
||||||
|
/// a flapping detector can't repeatedly re-announce the entire `PeerState` (which
|
||||||
|
/// can carry the ~48 KB avatar). Pure state machine — the service feeds it samples
|
||||||
|
/// and re-announces only when [`observe`](Debouncer::observe) reports a change.
|
||||||
|
///
|
||||||
|
/// A switch to a different game needs [`ACCEPT_HITS`] matching samples; clearing a
|
||||||
|
/// game needs [`CLEAR_MISSES`] consecutive misses. A manual override
|
||||||
|
/// (`immediate = true`) applies at once, bypassing both counters.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct Debouncer {
|
||||||
|
current: Option<DetectedGame>,
|
||||||
|
pending: Option<DetectedGame>,
|
||||||
|
pending_hits: u32,
|
||||||
|
misses: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Debouncer {
|
||||||
|
/// The currently published, debounced value.
|
||||||
|
pub fn current(&self) -> Option<&DetectedGame> {
|
||||||
|
self.current.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed one poll result. `immediate` (a manual override is active) bypasses the
|
||||||
|
/// debounce. Returns `true` iff the published [`current`](Self::current) value
|
||||||
|
/// changed — the signal for the service to re-announce presence / switch the
|
||||||
|
/// background.
|
||||||
|
pub fn observe(&mut self, sample: Option<DetectedGame>, immediate: bool) -> bool {
|
||||||
|
if immediate {
|
||||||
|
let changed = self.current != sample;
|
||||||
|
self.current = sample;
|
||||||
|
self.pending = None;
|
||||||
|
self.pending_hits = 0;
|
||||||
|
self.misses = 0;
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
match sample {
|
||||||
|
Some(game) => {
|
||||||
|
self.misses = 0;
|
||||||
|
if self.current.as_ref() == Some(&game) {
|
||||||
|
// Already publishing this game; drop any half-counted switch.
|
||||||
|
self.pending = None;
|
||||||
|
self.pending_hits = 0;
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
if self.pending.as_ref() == Some(&game) {
|
||||||
|
self.pending_hits += 1;
|
||||||
|
} else {
|
||||||
|
self.pending = Some(game);
|
||||||
|
self.pending_hits = 1;
|
||||||
|
}
|
||||||
|
if self.pending_hits >= ACCEPT_HITS {
|
||||||
|
self.current = self.pending.take();
|
||||||
|
self.pending_hits = 0;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// A miss never counts toward a *switch*; drop any pending candidate.
|
||||||
|
self.pending = None;
|
||||||
|
self.pending_hits = 0;
|
||||||
|
if self.current.is_some() {
|
||||||
|
self.misses += 1;
|
||||||
|
if self.misses >= CLEAR_MISSES {
|
||||||
|
self.current = None;
|
||||||
|
self.misses = 0;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize a raw executable name/path to a stable identity for matching and ids:
|
||||||
|
/// take the final path component (handling both `/` and `\\` separators) and
|
||||||
|
/// lowercase it. Keeps any extension (`minecraft.exe` stays distinct from a
|
||||||
|
/// hypothetical `minecraft`), trims surrounding whitespace.
|
||||||
|
pub fn normalize_exe(raw: &str) -> String {
|
||||||
|
raw.rsplit(['/', '\\']).next().unwrap_or(raw).trim().to_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Launcher/helper executables that must NEVER be reported as a game even if a
|
||||||
|
/// mapping names them — defense against a mis-entered mapping turning the launcher
|
||||||
|
/// itself into "the game". Normalized (lowercase basename) for comparison.
|
||||||
|
const BUILTIN_DENYLIST: &[&str] = &[
|
||||||
|
"steam",
|
||||||
|
"steam.exe",
|
||||||
|
"steamwebhelper",
|
||||||
|
"steamwebhelper.exe",
|
||||||
|
"steamerrorreporter",
|
||||||
|
"gameoverlayui",
|
||||||
|
"reaper",
|
||||||
|
"lutris",
|
||||||
|
"heroic",
|
||||||
|
"heroic.exe",
|
||||||
|
"legendary",
|
||||||
|
"gogdl",
|
||||||
|
"wine",
|
||||||
|
"wine64",
|
||||||
|
"wineserver",
|
||||||
|
"wine-preloader",
|
||||||
|
"proton",
|
||||||
|
"pressure-vessel-wrap",
|
||||||
|
"explorer.exe",
|
||||||
|
"services.exe",
|
||||||
|
"svchost.exe",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The built-in launcher/helper denylist as a set, for membership checks.
|
||||||
|
pub fn builtin_denylist() -> BTreeSet<&'static str> {
|
||||||
|
BUILTIN_DENYLIST.iter().copied().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Match the currently-running executables against the user's explicit
|
||||||
|
/// process→display-name mappings, returning detected games in **deterministic
|
||||||
|
/// priority order** (sorted by stable id) with duplicates removed.
|
||||||
|
///
|
||||||
|
/// Conservative by construction (§3): only exact normalized-basename matches to a
|
||||||
|
/// user mapping count — we never guess that an arbitrary long-running process is a
|
||||||
|
/// game. Any executable on `denylist` is rejected even if mapped, so a launcher or
|
||||||
|
/// helper can't be promoted to "the game".
|
||||||
|
///
|
||||||
|
/// `user_map` keys are matched against the normalized basename of each running
|
||||||
|
/// entry; the key itself is normalized too, so the caller may store either
|
||||||
|
/// `Half-Life 2` style display values keyed by `hl2_linux` or `HL2_Linux`.
|
||||||
|
pub fn match_processes(
|
||||||
|
running: &[String],
|
||||||
|
user_map: &BTreeMap<String, String>,
|
||||||
|
denylist: &BTreeSet<&str>,
|
||||||
|
) -> Vec<DetectedGame> {
|
||||||
|
// Normalize the user map once so lookups are basename/case-insensitive.
|
||||||
|
let normalized_map: BTreeMap<String, &String> =
|
||||||
|
user_map.iter().map(|(k, v)| (normalize_exe(k), v)).collect();
|
||||||
|
|
||||||
|
let mut seen: BTreeSet<String> = BTreeSet::new();
|
||||||
|
let mut out: Vec<DetectedGame> = Vec::new();
|
||||||
|
for raw in running {
|
||||||
|
let norm = normalize_exe(raw);
|
||||||
|
if norm.is_empty() || denylist.contains(norm.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(name) = normalized_map.get(&norm) {
|
||||||
|
let id = format!("exe:{norm}");
|
||||||
|
if seen.insert(id.clone()) {
|
||||||
|
out.push(DetectedGame {
|
||||||
|
id,
|
||||||
|
name: Some((*name).clone()),
|
||||||
|
source: GameSource::Process,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Deterministic priority: stable order independent of process-scan order.
|
||||||
|
out.sort_by(|a, b| a.id.cmp(&b.id));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn steam_game(app_id: u32, name: &str) -> DetectedGame {
|
||||||
|
DetectedGame {
|
||||||
|
id: DetectedGame::steam_id(app_id),
|
||||||
|
name: Some(name.to_string()),
|
||||||
|
source: GameSource::Steam,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ids / normalization ----------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stable_ids_are_namespaced() {
|
||||||
|
assert_eq!(DetectedGame::steam_id(730), "steam:730");
|
||||||
|
assert_eq!(DetectedGame::exe_id("/usr/games/hl2_linux"), "exe:hl2_linux");
|
||||||
|
assert_eq!(DetectedGame::exe_id("C:\\Games\\Minecraft.exe"), "exe:minecraft.exe");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_handles_both_separators_and_case() {
|
||||||
|
assert_eq!(normalize_exe("/opt/Foo/Bar.x86_64"), "bar.x86_64");
|
||||||
|
assert_eq!(normalize_exe("D:\\a\\b\\GAME.EXE"), "game.exe");
|
||||||
|
assert_eq!(normalize_exe(" spaced.bin "), "spaced.bin");
|
||||||
|
assert_eq!(normalize_exe("bare"), "bare");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- resolve priority --------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_prefers_steam_over_process_in_auto() {
|
||||||
|
let steam = steam_game(730, "CS2");
|
||||||
|
let procs = vec![DetectedGame {
|
||||||
|
id: "exe:foo".into(),
|
||||||
|
name: Some("Foo".into()),
|
||||||
|
source: GameSource::Process,
|
||||||
|
}];
|
||||||
|
let r = resolve(&ManualOverride::Auto, Some(steam.clone()), &procs);
|
||||||
|
assert_eq!(r.game, Some(steam));
|
||||||
|
assert!(!r.immediate);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_falls_back_to_first_process_then_none() {
|
||||||
|
let procs = vec![
|
||||||
|
DetectedGame { id: "exe:a".into(), name: Some("A".into()), source: GameSource::Process },
|
||||||
|
DetectedGame { id: "exe:b".into(), name: Some("B".into()), source: GameSource::Process },
|
||||||
|
];
|
||||||
|
let r = resolve(&ManualOverride::Auto, None, &procs);
|
||||||
|
assert_eq!(r.game.as_ref().unwrap().id, "exe:a");
|
||||||
|
let none = resolve(&ManualOverride::Auto, None, &[]);
|
||||||
|
assert_eq!(none.game, None);
|
||||||
|
assert!(!none.immediate);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_manual_override_wins_and_is_immediate() {
|
||||||
|
let steam = steam_game(730, "CS2");
|
||||||
|
// ForceNone overrides a live Steam detection, immediately.
|
||||||
|
let r = resolve(&ManualOverride::ForceNone, Some(steam.clone()), &[]);
|
||||||
|
assert_eq!(r.game, None);
|
||||||
|
assert!(r.immediate);
|
||||||
|
// Force(x) overrides too.
|
||||||
|
let forced = steam_game(220, "HL2");
|
||||||
|
let r = resolve(&ManualOverride::Force(forced.clone()), Some(steam), &[]);
|
||||||
|
assert_eq!(r.game, Some(forced));
|
||||||
|
assert!(r.immediate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- debounce ----------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn debounce_requires_two_hits_to_switch() {
|
||||||
|
let mut d = Debouncer::default();
|
||||||
|
let g = steam_game(730, "CS2");
|
||||||
|
// First sighting: not yet published.
|
||||||
|
assert!(!d.observe(Some(g.clone()), false));
|
||||||
|
assert_eq!(d.current(), None);
|
||||||
|
// Second consecutive sighting: now published.
|
||||||
|
assert!(d.observe(Some(g.clone()), false));
|
||||||
|
assert_eq!(d.current(), Some(&g));
|
||||||
|
// Steady state: same game, no further change events.
|
||||||
|
assert!(!d.observe(Some(g.clone()), false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn debounce_requires_three_misses_to_clear() {
|
||||||
|
let mut d = Debouncer::default();
|
||||||
|
let g = steam_game(730, "CS2");
|
||||||
|
d.observe(Some(g.clone()), false);
|
||||||
|
d.observe(Some(g.clone()), false);
|
||||||
|
assert_eq!(d.current(), Some(&g));
|
||||||
|
// Two misses: still shown (absorbs a transient blip).
|
||||||
|
assert!(!d.observe(None, false));
|
||||||
|
assert!(!d.observe(None, false));
|
||||||
|
assert_eq!(d.current(), Some(&g));
|
||||||
|
// Third miss: cleared.
|
||||||
|
assert!(d.observe(None, false));
|
||||||
|
assert_eq!(d.current(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn debounce_blip_during_clear_resets_miss_count() {
|
||||||
|
let mut d = Debouncer::default();
|
||||||
|
let g = steam_game(730, "CS2");
|
||||||
|
d.observe(Some(g.clone()), false);
|
||||||
|
d.observe(Some(g.clone()), false);
|
||||||
|
// Miss, miss, then the game reappears: miss count resets, stays published.
|
||||||
|
d.observe(None, false);
|
||||||
|
d.observe(None, false);
|
||||||
|
assert!(!d.observe(Some(g.clone()), false));
|
||||||
|
assert_eq!(d.current(), Some(&g));
|
||||||
|
// It now takes a fresh run of three misses to clear.
|
||||||
|
d.observe(None, false);
|
||||||
|
d.observe(None, false);
|
||||||
|
assert!(d.observe(None, false));
|
||||||
|
assert_eq!(d.current(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn debounce_immediate_bypasses_counters() {
|
||||||
|
let mut d = Debouncer::default();
|
||||||
|
let g = steam_game(730, "CS2");
|
||||||
|
// A manual override publishes on the first sample.
|
||||||
|
assert!(d.observe(Some(g.clone()), true));
|
||||||
|
assert_eq!(d.current(), Some(&g));
|
||||||
|
// ForceNone clears immediately.
|
||||||
|
assert!(d.observe(None, true));
|
||||||
|
assert_eq!(d.current(), None);
|
||||||
|
// Re-issuing the same immediate value is not a change.
|
||||||
|
d.observe(Some(g.clone()), true);
|
||||||
|
assert!(!d.observe(Some(g.clone()), true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn debounce_switching_games_needs_two_hits_of_the_new_one() {
|
||||||
|
let mut d = Debouncer::default();
|
||||||
|
let a = steam_game(1, "A");
|
||||||
|
let b = steam_game(2, "B");
|
||||||
|
d.observe(Some(a.clone()), false);
|
||||||
|
d.observe(Some(a.clone()), false);
|
||||||
|
assert_eq!(d.current(), Some(&a));
|
||||||
|
// One sample of B does not switch.
|
||||||
|
assert!(!d.observe(Some(b.clone()), false));
|
||||||
|
assert_eq!(d.current(), Some(&a));
|
||||||
|
// Second consecutive B switches.
|
||||||
|
assert!(d.observe(Some(b.clone()), false));
|
||||||
|
assert_eq!(d.current(), Some(&b));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- process matching --------------------------------------------------
|
||||||
|
|
||||||
|
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
|
||||||
|
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn match_processes_matches_only_explicit_mappings() {
|
||||||
|
let user = map(&[("hl2_linux", "Half-Life 2")]);
|
||||||
|
let deny = builtin_denylist();
|
||||||
|
let running = vec![
|
||||||
|
"/usr/bin/firefox".to_string(),
|
||||||
|
"/games/Half-Life 2/hl2_linux".to_string(),
|
||||||
|
"/usr/bin/htop".to_string(),
|
||||||
|
];
|
||||||
|
let got = match_processes(&running, &user, &deny);
|
||||||
|
assert_eq!(got.len(), 1);
|
||||||
|
assert_eq!(got[0].id, "exe:hl2_linux");
|
||||||
|
assert_eq!(got[0].name.as_deref(), Some("Half-Life 2"));
|
||||||
|
assert_eq!(got[0].source, GameSource::Process);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn match_processes_rejects_denylisted_even_if_mapped() {
|
||||||
|
// A mis-entered mapping naming the Steam client must not win.
|
||||||
|
let user = map(&[("steam", "Steam (oops)"), ("mygame", "My Game")]);
|
||||||
|
let deny = builtin_denylist();
|
||||||
|
let running = vec!["/usr/bin/steam".into(), "/opt/mygame".into()];
|
||||||
|
let got = match_processes(&running, &user, &deny);
|
||||||
|
assert_eq!(got.len(), 1);
|
||||||
|
assert_eq!(got[0].id, "exe:mygame");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn match_processes_is_deterministic_and_deduped() {
|
||||||
|
let user = map(&[("zed", "Zed"), ("alpha", "Alpha")]);
|
||||||
|
let deny = builtin_denylist();
|
||||||
|
// Same game twice (two processes) + reverse discovery order.
|
||||||
|
let running = vec![
|
||||||
|
"/b/zed".into(),
|
||||||
|
"/a/alpha".into(),
|
||||||
|
"/c/alpha".into(),
|
||||||
|
];
|
||||||
|
let got = match_processes(&running, &user, &deny);
|
||||||
|
// Deduped to two, sorted by id (alpha before zed) regardless of scan order.
|
||||||
|
assert_eq!(got.iter().map(|g| g.id.as_str()).collect::<Vec<_>>(), vec!["exe:alpha", "exe:zed"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn match_processes_ignores_unmapped_and_case_folds() {
|
||||||
|
let user = map(&[("Game.x86_64", "The Game")]);
|
||||||
|
let deny = builtin_denylist();
|
||||||
|
let running = vec!["/x/GAME.X86_64".into(), "/y/random".into()];
|
||||||
|
let got = match_processes(&running, &user, &deny);
|
||||||
|
assert_eq!(got.len(), 1);
|
||||||
|
assert_eq!(got[0].name.as_deref(), Some("The Game"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
//! Running-process enumeration for the non-Steam detection fallback (D6/D7):
|
||||||
|
//! native adapters only — `/proc` on Linux, Toolhelp on Windows — so there is no
|
||||||
|
//! `sysinfo` dependency and the audit surface stays small.
|
||||||
|
//!
|
||||||
|
//! This module is *just the OS edge*: it returns the list of running executable
|
||||||
|
//! paths/names. The trustworthy part — turning that list into a game via the
|
||||||
|
//! user's explicit mappings and the launcher denylist — is the pure
|
||||||
|
//! [`match_processes`](super::match_processes), unit-tested in the parent module.
|
||||||
|
|
||||||
|
/// Enumerate the executables of currently-running processes as paths/basenames.
|
||||||
|
/// Best-effort: processes we can't introspect (other users') are skipped rather
|
||||||
|
/// than erroring. The result is fed to [`match_processes`](super::match_processes),
|
||||||
|
/// which normalizes each entry to a basename before matching.
|
||||||
|
pub fn running_executables() -> Vec<String> {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
linux_proc_executables()
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
windows_toolhelp_executables()
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "linux", windows)))]
|
||||||
|
{
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn linux_proc_executables() -> Vec<String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let Ok(entries) = std::fs::read_dir("/proc") else {
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let name = entry.file_name();
|
||||||
|
let Some(name) = name.to_str() else { continue };
|
||||||
|
// Only numeric entries are processes.
|
||||||
|
if !name.bytes().all(|b| b.is_ascii_digit()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let proc_dir = entry.path();
|
||||||
|
// Prefer the real exe path (full, untruncated); fall back to `comm`, which
|
||||||
|
// is readable for all processes but truncated to 15 bytes.
|
||||||
|
if let Ok(exe) = std::fs::read_link(proc_dir.join("exe"))
|
||||||
|
&& let Some(s) = exe.to_str()
|
||||||
|
{
|
||||||
|
out.push(s.to_string());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(comm) = std::fs::read_to_string(proc_dir.join("comm")) {
|
||||||
|
let trimmed = comm.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
out.push(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn windows_toolhelp_executables() -> Vec<String> {
|
||||||
|
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
|
||||||
|
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
|
||||||
|
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
|
||||||
|
TH32CS_SNAPPROCESS,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
// SAFETY: standard Toolhelp snapshot of all processes; handle checked below.
|
||||||
|
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
|
||||||
|
if snapshot == INVALID_HANDLE_VALUE {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() };
|
||||||
|
entry.dwSize = std::mem::size_of::<PROCESSENTRY32W>() as u32;
|
||||||
|
// SAFETY: entry is zeroed with dwSize set, as Process32FirstW requires.
|
||||||
|
let mut ok = unsafe { Process32FirstW(snapshot, &mut entry) };
|
||||||
|
while ok != 0 {
|
||||||
|
// szExeFile is a NUL-terminated UTF-16 array (the basename, e.g. game.exe).
|
||||||
|
let end = entry.szExeFile.iter().position(|&c| c == 0).unwrap_or(entry.szExeFile.len());
|
||||||
|
let name = String::from_utf16_lossy(&entry.szExeFile[..end]);
|
||||||
|
if !name.is_empty() {
|
||||||
|
out.push(name);
|
||||||
|
}
|
||||||
|
// SAFETY: same valid snapshot + entry struct.
|
||||||
|
ok = unsafe { Process32NextW(snapshot, &mut entry) };
|
||||||
|
}
|
||||||
|
// SAFETY: snapshot handle came from CreateToolhelp32Snapshot above.
|
||||||
|
unsafe { CloseHandle(snapshot) };
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn enumerates_at_least_this_process() {
|
||||||
|
// The test runner itself is a process, so /proc enumeration must be
|
||||||
|
// non-empty and include something that normalizes to our own exe basename.
|
||||||
|
let exes = running_executables();
|
||||||
|
assert!(!exes.is_empty(), "expected to see running processes via /proc");
|
||||||
|
// Our own /proc/self/exe basename should appear among them.
|
||||||
|
let me = std::fs::read_link("/proc/self/exe")
|
||||||
|
.ok()
|
||||||
|
.and_then(|p| p.file_name().map(|f| f.to_string_lossy().into_owned()));
|
||||||
|
if let Some(me) = me {
|
||||||
|
let me_norm = super::super::normalize_exe(&me);
|
||||||
|
assert!(
|
||||||
|
exes.iter().any(|e| super::super::normalize_exe(e) == me_norm),
|
||||||
|
"running list should include our own executable {me_norm:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,508 @@
|
|||||||
|
//! Steam detection adapter: the primary signal (D1). Reads Steam's live
|
||||||
|
//! `RunningAppID` and resolves it to a display name via the plain-text
|
||||||
|
//! `appmanifest_<appid>.acf`, with no dependency on the binary `appinfo.vdf`.
|
||||||
|
//!
|
||||||
|
//! The *parsing* is pure and unit-tested ([`parse_running_app_id`],
|
||||||
|
//! [`parse_library_paths`], [`parse_app_name`], all over file contents). The fs /
|
||||||
|
//! Windows-registry reads are the thin edge, and [`SteamProbe`] caches roots,
|
||||||
|
//! library list, and resolved names — invalidating by mtime — so the 3 s detector
|
||||||
|
//! poll does not rescan every library each tick (Codex hardening).
|
||||||
|
|
||||||
|
use super::vdf::{self, Value};
|
||||||
|
use super::{DetectedGame, GameSource};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
|
/// Max bytes read from any single Steam state file. These are small text files
|
||||||
|
/// (a manifest is a few KB); the cap stops a corrupt/hostile giant file from being
|
||||||
|
/// slurped into memory before the parser's own depth guard kicks in.
|
||||||
|
const MAX_STEAM_FILE_BYTES: u64 = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Parse the live `RunningAppID` out of a Steam `registry.vdf` (the Linux/macOS
|
||||||
|
/// client's emulated-registry text file). Returns the appid only when present and
|
||||||
|
/// nonzero — `0`/absent is the "no game" state. Pure.
|
||||||
|
pub fn parse_running_app_id(registry_vdf: &str) -> Option<u32> {
|
||||||
|
let root = vdf::parse(registry_vdf).ok()?;
|
||||||
|
let raw = root
|
||||||
|
.get_path(&["Registry", "HKCU", "Software", "Valve", "Steam", "RunningAppID"])
|
||||||
|
.and_then(Value::as_str)?;
|
||||||
|
let id: u32 = raw.trim().parse().ok()?;
|
||||||
|
(id != 0).then_some(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the library folder paths out of a `libraryfolders.vdf`, handling **both**
|
||||||
|
/// the current shape (`"0" { "path" "..." }`) and the legacy shape
|
||||||
|
/// (`"1" "/path"`, the path as a direct string value). Non-numeric keys
|
||||||
|
/// (`contentstatsid`, …) are skipped. Pure; paths are returned as-is (escapes
|
||||||
|
/// already decoded by the VDF parser), including ones on offline drives — the
|
||||||
|
/// caller checks existence.
|
||||||
|
pub fn parse_library_paths(libraryfolders_vdf: &str) -> Vec<PathBuf> {
|
||||||
|
let Ok(root) = vdf::parse(libraryfolders_vdf) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
// The root may or may not wrap entries in a "libraryfolders" object.
|
||||||
|
let container = root.get("libraryfolders").unwrap_or(&root);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for (key, val) in container.entries() {
|
||||||
|
// Only numeric-keyed entries are library folders.
|
||||||
|
if key.parse::<u32>().is_err() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = match val {
|
||||||
|
Value::Str(s) => Some(s.as_str()),
|
||||||
|
Value::Obj(_) => val.get("path").and_then(Value::as_str),
|
||||||
|
};
|
||||||
|
if let Some(p) = path
|
||||||
|
&& !p.is_empty()
|
||||||
|
{
|
||||||
|
out.push(PathBuf::from(p));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the human `name` out of an `appmanifest_<appid>.acf`. Pure.
|
||||||
|
pub fn parse_app_name(appmanifest_acf: &str) -> Option<String> {
|
||||||
|
let root = vdf::parse(appmanifest_acf).ok()?;
|
||||||
|
root.get_path(&["AppState", "name"])
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read at most [`MAX_STEAM_FILE_BYTES`] of a file as UTF-8 (lossy), or `None` if
|
||||||
|
/// it is missing/unreadable. The thin fs edge under the pure parsers above.
|
||||||
|
fn read_capped(path: &Path) -> Option<String> {
|
||||||
|
use std::io::Read;
|
||||||
|
let file = std::fs::File::open(path).ok()?;
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
file.take(MAX_STEAM_FILE_BYTES).read_to_end(&mut buf).ok()?;
|
||||||
|
Some(String::from_utf8_lossy(&buf).into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mtime_of(path: &Path) -> Option<SystemTime> {
|
||||||
|
std::fs::metadata(path).ok()?.modified().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A library list cached against its source file's mtime.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct CachedLibraries {
|
||||||
|
source: Option<PathBuf>,
|
||||||
|
mtime: Option<SystemTime>,
|
||||||
|
paths: Vec<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A per-appid resolved name cached against the manifest's mtime. `name` is `None`
|
||||||
|
/// when the manifest exists but carries no usable name, or wasn't found.
|
||||||
|
struct CachedManifest {
|
||||||
|
mtime: Option<SystemTime>,
|
||||||
|
name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stateful Steam probe with mtime-invalidated caches. Construct once and call
|
||||||
|
/// [`detect`](Self::detect) each poll; all reads are blocking, so the detector
|
||||||
|
/// service runs it off the async worker.
|
||||||
|
pub struct SteamProbe {
|
||||||
|
roots: Vec<PathBuf>,
|
||||||
|
libraries: CachedLibraries,
|
||||||
|
manifests: HashMap<u32, CachedManifest>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SteamProbe {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SteamProbe {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
roots: discover_roots(),
|
||||||
|
libraries: CachedLibraries::default(),
|
||||||
|
manifests: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One detection pass: read the live `RunningAppID`, and if a game is running,
|
||||||
|
/// resolve its name from the appmanifest (cached). Returns a `DetectedGame`
|
||||||
|
/// with `name: None` when the appid is known but no manifest name is available
|
||||||
|
/// — the background can still switch by id, but presence must not invent a name.
|
||||||
|
pub fn detect(&mut self) -> Option<DetectedGame> {
|
||||||
|
let app_id = self.running_app_id()?;
|
||||||
|
let name = self.app_name(app_id);
|
||||||
|
Some(DetectedGame {
|
||||||
|
id: DetectedGame::steam_id(app_id),
|
||||||
|
name,
|
||||||
|
source: GameSource::Steam,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The live RunningAppID (nonzero), or `None`.
|
||||||
|
///
|
||||||
|
/// Platform notes: on **Windows** the real registry's `RunningAppID` is updated
|
||||||
|
/// live, so we read it. On **Linux** the client's `registry.vdf` is only
|
||||||
|
/// rewritten on Steam *shutdown* — it's stale while a game runs — so the live
|
||||||
|
/// signal is the running game process's `SteamAppId` environment variable
|
||||||
|
/// (`/proc/<pid>/environ`, readable for our own processes; the same approach
|
||||||
|
/// MangoHud uses); `registry.vdf` stays as a best-effort fallback. Other Unix
|
||||||
|
/// (macOS) only has the `registry.vdf` fallback for now.
|
||||||
|
fn running_app_id(&self) -> Option<u32> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
win::running_app_id()
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
running_app_id_from_environ().or_else(registry_running_app_id)
|
||||||
|
}
|
||||||
|
#[cfg(not(any(windows, target_os = "linux")))]
|
||||||
|
{
|
||||||
|
registry_running_app_id()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve (and cache) the display name for an appid by locating its
|
||||||
|
/// `appmanifest_<appid>.acf` across the known libraries.
|
||||||
|
fn app_name(&mut self, app_id: u32) -> Option<String> {
|
||||||
|
let manifest = self.find_manifest(app_id)?;
|
||||||
|
let mtime = mtime_of(&manifest);
|
||||||
|
if let Some(cached) = self.manifests.get(&app_id)
|
||||||
|
&& cached.mtime == mtime
|
||||||
|
{
|
||||||
|
return cached.name.clone();
|
||||||
|
}
|
||||||
|
let name = read_capped(&manifest).and_then(|c| parse_app_name(&c));
|
||||||
|
self.manifests.insert(app_id, CachedManifest { mtime, name: name.clone() });
|
||||||
|
name
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The path to an appid's manifest, if it exists in any library.
|
||||||
|
fn find_manifest(&mut self, app_id: u32) -> Option<PathBuf> {
|
||||||
|
let filename = format!("appmanifest_{app_id}.acf");
|
||||||
|
for lib in self.library_paths() {
|
||||||
|
let candidate = lib.join("steamapps").join(&filename);
|
||||||
|
if candidate.exists() {
|
||||||
|
return Some(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All Steam library folder paths, cached and refreshed only when the source
|
||||||
|
/// `libraryfolders.vdf` changes (mtime). Discovered from the known roots.
|
||||||
|
fn library_paths(&mut self) -> Vec<PathBuf> {
|
||||||
|
// Locate the libraryfolders.vdf to watch (first existing across roots).
|
||||||
|
let source = self
|
||||||
|
.roots
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.join("steamapps").join("libraryfolders.vdf"))
|
||||||
|
.find(|p| p.exists());
|
||||||
|
|
||||||
|
let mtime = source.as_deref().and_then(mtime_of);
|
||||||
|
if self.libraries.source == source && self.libraries.mtime == mtime && source.is_some() {
|
||||||
|
return self.libraries.paths.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut paths = Vec::new();
|
||||||
|
if let Some(ref src) = source
|
||||||
|
&& let Some(contents) = read_capped(src)
|
||||||
|
{
|
||||||
|
paths = parse_library_paths(&contents);
|
||||||
|
}
|
||||||
|
// Always include the roots themselves: the install dir is an implicit
|
||||||
|
// library even if libraryfolders.vdf is missing or lists only extras.
|
||||||
|
for root in &self.roots {
|
||||||
|
if !paths.contains(root) {
|
||||||
|
paths.push(root.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.libraries = CachedLibraries { source, mtime, paths: paths.clone() };
|
||||||
|
paths
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Candidate Steam install roots that actually exist on this machine (each is a
|
||||||
|
/// directory containing a `steamapps` folder). Covers native, Flatpak, and Snap
|
||||||
|
/// layouts on Linux; on Windows the install path comes from the registry.
|
||||||
|
fn discover_roots() -> Vec<PathBuf> {
|
||||||
|
let mut roots = Vec::new();
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
if let Some(p) = win::install_path() {
|
||||||
|
roots.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
if let Some(home) = dirs::home_dir() {
|
||||||
|
for rel in [
|
||||||
|
".steam/steam",
|
||||||
|
".steam/root",
|
||||||
|
".local/share/Steam",
|
||||||
|
".var/app/com.valvesoftware.Steam/.local/share/Steam",
|
||||||
|
"snap/steam/common/.local/share/Steam",
|
||||||
|
] {
|
||||||
|
roots.push(home.join(rel));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep only roots that exist and look like a Steam install.
|
||||||
|
roots.retain(|p| p.join("steamapps").is_dir());
|
||||||
|
roots.sort();
|
||||||
|
roots.dedup();
|
||||||
|
roots
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Candidate `registry.vdf` locations (Linux/macOS emulated registry).
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn registry_vdf_candidates() -> Vec<PathBuf> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
if let Some(home) = dirs::home_dir() {
|
||||||
|
out.push(home.join(".steam/registry.vdf"));
|
||||||
|
out.push(home.join(".steam/steam/registry.vdf"));
|
||||||
|
out.push(home.join(".var/app/com.valvesoftware.Steam/.steam/registry.vdf"));
|
||||||
|
out.push(home.join("snap/steam/common/.steam/registry.vdf"));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort `RunningAppID` from the on-disk `registry.vdf`. ⚠️ Stale while a
|
||||||
|
/// game runs (Steam rewrites the file only on shutdown), so this is a *fallback*
|
||||||
|
/// behind the live `/proc` `SteamAppId` scan on Linux — not the primary signal.
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn registry_running_app_id() -> Option<u32> {
|
||||||
|
for path in registry_vdf_candidates() {
|
||||||
|
if let Some(contents) = read_capped(&path)
|
||||||
|
&& let Some(id) = parse_running_app_id(&contents)
|
||||||
|
{
|
||||||
|
return Some(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a Steam appid out of a process's raw `environ` blob (NUL-separated
|
||||||
|
/// `KEY=VALUE` pairs), reading the `SteamAppId` variable Steam exports to every
|
||||||
|
/// game process. Returns the appid only when present and nonzero. Pure +
|
||||||
|
/// unit-tested; the `/proc` iteration is the thin edge in
|
||||||
|
/// [`running_app_id_from_environ`].
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn parse_steam_app_id_from_environ(environ: &[u8]) -> Option<u32> {
|
||||||
|
for kv in environ.split(|&b| b == 0) {
|
||||||
|
if let Some(val) = kv.strip_prefix(b"SteamAppId=")
|
||||||
|
&& let Ok(s) = std::str::from_utf8(val)
|
||||||
|
&& let Ok(id) = s.trim().parse::<u32>()
|
||||||
|
&& id != 0
|
||||||
|
{
|
||||||
|
return Some(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The live Steam appid of a running game, found by scanning `/proc/<pid>/environ`
|
||||||
|
/// for the `SteamAppId` Steam exports to the game's process tree. `environ` is
|
||||||
|
/// readable only for our own processes — exactly the ones a Steam game we launched
|
||||||
|
/// runs as — and we skip the rest. The live signal that replaces the stale
|
||||||
|
/// on-disk `registry.vdf` on Linux.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn running_app_id_from_environ() -> Option<u32> {
|
||||||
|
let entries = std::fs::read_dir("/proc").ok()?;
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let name = entry.file_name();
|
||||||
|
let Some(name) = name.to_str() else { continue };
|
||||||
|
if !name.bytes().all(|b| b.is_ascii_digit()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Cap the read: an environ is small; this bounds a pathological case.
|
||||||
|
if let Some(environ) = read_capped(&entry.path().join("environ"))
|
||||||
|
&& let Some(id) = parse_steam_app_id_from_environ(environ.as_bytes())
|
||||||
|
{
|
||||||
|
return Some(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod win {
|
||||||
|
//! Windows registry reads via direct Win32 FFI (windows-sys), no `winreg`
|
||||||
|
//! crate. Steam stores both the live `RunningAppID` and its install path under
|
||||||
|
//! `HKCU\Software\Valve\Steam`.
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
|
||||||
|
use windows_sys::Win32::System::Registry::{
|
||||||
|
RegCloseKey, RegOpenKeyExW, RegQueryValueExW, HKEY, HKEY_CURRENT_USER, KEY_READ,
|
||||||
|
REG_DWORD, REG_SZ,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// UTF-16, NUL-terminated, for a Win32 wide-string argument.
|
||||||
|
fn wide(s: &str) -> Vec<u16> {
|
||||||
|
s.encode_utf16().chain(std::iter::once(0)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open `HKCU\Software\Valve\Steam` for reading; `None` if absent.
|
||||||
|
fn open_steam_key() -> Option<HKEY> {
|
||||||
|
let subkey = wide("Software\\Valve\\Steam");
|
||||||
|
let mut hkey: HKEY = std::ptr::null_mut();
|
||||||
|
// SAFETY: valid HKEY constant, NUL-terminated subkey, out-param for the handle.
|
||||||
|
let rc = unsafe {
|
||||||
|
RegOpenKeyExW(HKEY_CURRENT_USER, subkey.as_ptr(), 0, KEY_READ, &mut hkey)
|
||||||
|
};
|
||||||
|
(rc == ERROR_SUCCESS).then_some(hkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The live `RunningAppID` REG_DWORD, nonzero, or `None`.
|
||||||
|
pub fn running_app_id() -> Option<u32> {
|
||||||
|
let hkey = open_steam_key()?;
|
||||||
|
let name = wide("RunningAppID");
|
||||||
|
let mut kind: u32 = 0;
|
||||||
|
let mut data: u32 = 0;
|
||||||
|
let mut len = std::mem::size_of::<u32>() as u32;
|
||||||
|
// SAFETY: out-params sized for a DWORD; data buffer is a u32 we own.
|
||||||
|
let rc = unsafe {
|
||||||
|
RegQueryValueExW(
|
||||||
|
hkey,
|
||||||
|
name.as_ptr(),
|
||||||
|
std::ptr::null(),
|
||||||
|
&mut kind,
|
||||||
|
&mut data as *mut u32 as *mut u8,
|
||||||
|
&mut len,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
// SAFETY: handle came from RegOpenKeyExW above.
|
||||||
|
unsafe { RegCloseKey(hkey) };
|
||||||
|
if rc == ERROR_SUCCESS && kind == REG_DWORD && data != 0 {
|
||||||
|
Some(data)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Steam install directory from `HKCU\...\Steam\SteamPath`, if it exists.
|
||||||
|
pub fn install_path() -> Option<PathBuf> {
|
||||||
|
let hkey = open_steam_key()?;
|
||||||
|
let name = wide("SteamPath");
|
||||||
|
let mut kind: u32 = 0;
|
||||||
|
let mut len: u32 = 0;
|
||||||
|
// First query the size.
|
||||||
|
// SAFETY: null data ptr with a zeroed len asks for the required size.
|
||||||
|
let rc = unsafe {
|
||||||
|
RegQueryValueExW(
|
||||||
|
hkey,
|
||||||
|
name.as_ptr(),
|
||||||
|
std::ptr::null(),
|
||||||
|
&mut kind,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
&mut len,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if rc != ERROR_SUCCESS || kind != REG_SZ || len == 0 {
|
||||||
|
// SAFETY: valid handle.
|
||||||
|
unsafe { RegCloseKey(hkey) };
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut buf = vec![0u16; (len as usize).div_ceil(2)];
|
||||||
|
let mut len2 = len;
|
||||||
|
// SAFETY: buffer sized to the queried byte length.
|
||||||
|
let rc = unsafe {
|
||||||
|
RegQueryValueExW(
|
||||||
|
hkey,
|
||||||
|
name.as_ptr(),
|
||||||
|
std::ptr::null(),
|
||||||
|
&mut kind,
|
||||||
|
buf.as_mut_ptr() as *mut u8,
|
||||||
|
&mut len2,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
// SAFETY: valid handle.
|
||||||
|
unsafe { RegCloseKey(hkey) };
|
||||||
|
if rc != ERROR_SUCCESS {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Trim the trailing NUL(s).
|
||||||
|
while buf.last() == Some(&0) {
|
||||||
|
buf.pop();
|
||||||
|
}
|
||||||
|
Some(PathBuf::from(String::from_utf16_lossy(&buf)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn running_app_id_reads_nonzero_and_rejects_zero() {
|
||||||
|
let running = r#""Registry" { "HKCU" { "Software" { "Valve" { "Steam" {
|
||||||
|
"RunningAppID" "440"
|
||||||
|
} } } } }"#;
|
||||||
|
assert_eq!(parse_running_app_id(running), Some(440));
|
||||||
|
let idle = r#""Registry" { "HKCU" { "Software" { "Valve" { "Steam" {
|
||||||
|
"RunningAppID" "0"
|
||||||
|
} } } } }"#;
|
||||||
|
assert_eq!(parse_running_app_id(idle), None);
|
||||||
|
// Missing key / garbage → None, no panic.
|
||||||
|
assert_eq!(parse_running_app_id(r#""Registry" { }"#), None);
|
||||||
|
assert_eq!(parse_running_app_id("not vdf at all {{{"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn library_paths_handles_current_and_legacy_shapes() {
|
||||||
|
let current = r#""libraryfolders" {
|
||||||
|
"0" { "path" "/home/eric/.local/share/Steam" "label" "" }
|
||||||
|
"1" { "path" "/mnt/games/SteamLibrary" }
|
||||||
|
"contentstatsid" "12345"
|
||||||
|
}"#;
|
||||||
|
let got = parse_library_paths(current);
|
||||||
|
assert_eq!(got, vec![
|
||||||
|
PathBuf::from("/home/eric/.local/share/Steam"),
|
||||||
|
PathBuf::from("/mnt/games/SteamLibrary"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Legacy shape: numeric keys map straight to path strings.
|
||||||
|
let legacy = r#""LibraryFolders" {
|
||||||
|
"TimeNextStatsReport" "9999"
|
||||||
|
"ContentStatsID" "42"
|
||||||
|
"1" "/mnt/old/SteamLibrary"
|
||||||
|
}"#;
|
||||||
|
let got = parse_library_paths(legacy);
|
||||||
|
assert_eq!(got, vec![PathBuf::from("/mnt/old/SteamLibrary")]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn library_paths_empty_on_garbage() {
|
||||||
|
assert!(parse_library_paths("totally broken {{{").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn steam_app_id_parsed_from_environ_blob() {
|
||||||
|
// A realistic NUL-separated environ with SteamAppId among other vars.
|
||||||
|
let environ = b"PATH=/usr/bin\0SteamAppId=440\0HOME=/home/x\0SteamGameId=440\0";
|
||||||
|
assert_eq!(parse_steam_app_id_from_environ(environ), Some(440));
|
||||||
|
// Nonzero requirement: SteamAppId=0 (the launcher itself) is ignored.
|
||||||
|
assert_eq!(parse_steam_app_id_from_environ(b"SteamAppId=0\0FOO=bar\0"), None);
|
||||||
|
// Absent → None (a non-Steam process).
|
||||||
|
assert_eq!(parse_steam_app_id_from_environ(b"PATH=/usr/bin\0HOME=/home/x\0"), None);
|
||||||
|
// Not fooled by a different var that merely contains the substring.
|
||||||
|
assert_eq!(parse_steam_app_id_from_environ(b"MY_SteamAppId=999\0"), None);
|
||||||
|
// Garbage value → None, no panic.
|
||||||
|
assert_eq!(parse_steam_app_id_from_environ(b"SteamAppId=notanumber\0"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_name_extracts_and_filters_empty() {
|
||||||
|
let acf = r#""AppState" { "appid" "440" "name" "Team Fortress 2" }"#;
|
||||||
|
assert_eq!(parse_app_name(acf), Some("Team Fortress 2".to_string()));
|
||||||
|
// Empty name → None (don't broadcast a blank).
|
||||||
|
let blank = r#""AppState" { "appid" "440" "name" "" }"#;
|
||||||
|
assert_eq!(parse_app_name(blank), None);
|
||||||
|
// Missing name → None.
|
||||||
|
assert_eq!(parse_app_name(r#""AppState" { "appid" "440" }"#), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
+340
@@ -0,0 +1,340 @@
|
|||||||
|
//! A small, defensive parser for Valve's KeyValues / VDF text format, used by
|
||||||
|
//! `appmanifest_<appid>.acf`, `libraryfolders.vdf`, and `~/.steam/registry.vdf`.
|
||||||
|
//!
|
||||||
|
//! Pure (operates on already-read file *contents*) and unit-tested, per the
|
||||||
|
//! testable-seams-first workflow — the file I/O and size caps live in the Steam
|
||||||
|
//! adapter. Deliberately a real recursive-descent KeyValues parser rather than a
|
||||||
|
//! `"name"`-line regex: escapes, nesting, and truncation will eventually break a
|
||||||
|
//! regex (Codex's "use a real VDF parser" hardening). Hardened against hostile
|
||||||
|
//! input with a recursion-depth cap, so a deeply nested file errors instead of
|
||||||
|
//! overflowing the stack, and never panics on malformed/truncated input.
|
||||||
|
|
||||||
|
/// Max object nesting depth accepted before bailing out. Real Steam files nest a
|
||||||
|
/// handful of levels (`registry.vdf` is the deepest at ~6); this is generous while
|
||||||
|
/// still bounding a malicious file.
|
||||||
|
const MAX_DEPTH: usize = 32;
|
||||||
|
|
||||||
|
/// A parsed KeyValues value: either a leaf string or a nested object. Child order
|
||||||
|
/// is preserved and duplicate keys are kept (KeyValues permits them); lookups
|
||||||
|
/// return the first match.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Value {
|
||||||
|
Str(String),
|
||||||
|
Obj(Vec<(String, Value)>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Value {
|
||||||
|
/// The leaf string at this node, if it is a string (not an object).
|
||||||
|
pub fn as_str(&self) -> Option<&str> {
|
||||||
|
match self {
|
||||||
|
Value::Str(s) => Some(s),
|
||||||
|
Value::Obj(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first child value under `key`, if this is an object containing it.
|
||||||
|
/// Case-insensitive on the key (KeyValues keys are conventionally
|
||||||
|
/// case-insensitive, and Steam is inconsistent, e.g. `AppState`/`appid`).
|
||||||
|
pub fn get(&self, key: &str) -> Option<&Value> {
|
||||||
|
match self {
|
||||||
|
Value::Obj(pairs) => pairs
|
||||||
|
.iter()
|
||||||
|
.find(|(k, _)| k.eq_ignore_ascii_case(key))
|
||||||
|
.map(|(_, v)| v),
|
||||||
|
Value::Str(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Follow a chain of object keys, returning the value at the end of the path.
|
||||||
|
/// `root.get_path(&["AppState", "name"])`.
|
||||||
|
pub fn get_path<'a>(&'a self, path: &[&str]) -> Option<&'a Value> {
|
||||||
|
let mut cur = self;
|
||||||
|
for key in path {
|
||||||
|
cur = cur.get(key)?;
|
||||||
|
}
|
||||||
|
Some(cur)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterate the (key, value) child pairs if this is an object.
|
||||||
|
pub fn entries(&self) -> &[(String, Value)] {
|
||||||
|
match self {
|
||||||
|
Value::Obj(pairs) => pairs,
|
||||||
|
Value::Str(_) => &[],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse KeyValues/VDF text into a top-level object (the sequence of root
|
||||||
|
/// key→value pairs). Returns `Err` on unbalanced braces, a key with no value, or
|
||||||
|
/// nesting past [`MAX_DEPTH`]. Never panics.
|
||||||
|
pub fn parse(input: &str) -> Result<Value, String> {
|
||||||
|
let mut lexer = Lexer { rest: input };
|
||||||
|
let obj = parse_object(&mut lexer, 0, true)?;
|
||||||
|
Ok(Value::Obj(obj))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a run of `key value` pairs. `top_level` parses until EOF; otherwise it
|
||||||
|
/// parses until a closing `}` (which it consumes).
|
||||||
|
fn parse_object(
|
||||||
|
lexer: &mut Lexer,
|
||||||
|
depth: usize,
|
||||||
|
top_level: bool,
|
||||||
|
) -> Result<Vec<(String, Value)>, String> {
|
||||||
|
if depth > MAX_DEPTH {
|
||||||
|
return Err("VDF nesting too deep".to_string());
|
||||||
|
}
|
||||||
|
let mut pairs = Vec::new();
|
||||||
|
loop {
|
||||||
|
match lexer.next_token()? {
|
||||||
|
None => {
|
||||||
|
if top_level {
|
||||||
|
return Ok(pairs);
|
||||||
|
}
|
||||||
|
return Err("unexpected end of input inside object".to_string());
|
||||||
|
}
|
||||||
|
Some(Token::Close) => {
|
||||||
|
if top_level {
|
||||||
|
return Err("unexpected '}' at top level".to_string());
|
||||||
|
}
|
||||||
|
return Ok(pairs);
|
||||||
|
}
|
||||||
|
Some(Token::Open) => {
|
||||||
|
return Err("expected key, found '{'".to_string());
|
||||||
|
}
|
||||||
|
Some(Token::Str(key)) => {
|
||||||
|
// A key must be followed by a value: a string or a nested object.
|
||||||
|
match lexer.next_token()? {
|
||||||
|
Some(Token::Str(val)) => pairs.push((key, Value::Str(val))),
|
||||||
|
Some(Token::Open) => {
|
||||||
|
let child = parse_object(lexer, depth + 1, false)?;
|
||||||
|
pairs.push((key, Value::Obj(child)));
|
||||||
|
}
|
||||||
|
Some(Token::Close) => {
|
||||||
|
return Err(format!("key '{key}' has no value (found '}}')"));
|
||||||
|
}
|
||||||
|
None => return Err(format!("key '{key}' has no value (end of input)")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Token {
|
||||||
|
Open,
|
||||||
|
Close,
|
||||||
|
Str(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Lexer<'a> {
|
||||||
|
rest: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Lexer<'_> {
|
||||||
|
/// Produce the next token, skipping whitespace and `//` line comments.
|
||||||
|
fn next_token(&mut self) -> Result<Option<Token>, String> {
|
||||||
|
loop {
|
||||||
|
self.rest = self.rest.trim_start();
|
||||||
|
if self.rest.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
// Line comments: `//` to end of line.
|
||||||
|
if let Some(after) = self.rest.strip_prefix("//") {
|
||||||
|
match after.find('\n') {
|
||||||
|
Some(nl) => self.rest = &after[nl + 1..],
|
||||||
|
None => {
|
||||||
|
self.rest = "";
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut chars = self.rest.char_indices();
|
||||||
|
let (_, first) = chars.next().expect("non-empty checked above");
|
||||||
|
return match first {
|
||||||
|
'{' => {
|
||||||
|
self.advance_bytes(first.len_utf8());
|
||||||
|
Ok(Some(Token::Open))
|
||||||
|
}
|
||||||
|
'}' => {
|
||||||
|
self.advance_bytes(first.len_utf8());
|
||||||
|
Ok(Some(Token::Close))
|
||||||
|
}
|
||||||
|
'"' => self.lex_quoted(),
|
||||||
|
_ => Ok(Some(self.lex_bareword())),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn advance_bytes(&mut self, n: usize) {
|
||||||
|
self.rest = &self.rest[n..];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lex a `"..."` string, decoding `\\ \" \n \t` escapes. Errors if unterminated.
|
||||||
|
fn lex_quoted(&mut self) -> Result<Option<Token>, String> {
|
||||||
|
// Skip the opening quote.
|
||||||
|
self.advance_bytes(1);
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut chars = self.rest.char_indices();
|
||||||
|
while let Some((i, c)) = chars.next() {
|
||||||
|
match c {
|
||||||
|
'"' => {
|
||||||
|
// Consume through the closing quote.
|
||||||
|
self.rest = &self.rest[i + 1..];
|
||||||
|
return Ok(Some(Token::Str(out)));
|
||||||
|
}
|
||||||
|
'\\' => {
|
||||||
|
// Decode the escape.
|
||||||
|
match chars.next() {
|
||||||
|
Some((_, esc)) => out.push(match esc {
|
||||||
|
'n' => '\n',
|
||||||
|
't' => '\t',
|
||||||
|
'r' => '\r',
|
||||||
|
// `\\`, `\"`, and anything else: take the literal char.
|
||||||
|
other => other,
|
||||||
|
}),
|
||||||
|
None => return Err("unterminated escape in quoted string".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => out.push(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err("unterminated quoted string".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lex an unquoted token: run of non-whitespace, non-brace, non-quote chars.
|
||||||
|
fn lex_bareword(&mut self) -> Token {
|
||||||
|
let end = self
|
||||||
|
.rest
|
||||||
|
.find(|c: char| c.is_whitespace() || matches!(c, '{' | '}' | '"'))
|
||||||
|
.unwrap_or(self.rest.len());
|
||||||
|
let word = self.rest[..end].to_string();
|
||||||
|
self.rest = &self.rest[end..];
|
||||||
|
Token::Str(word)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_appmanifest_name() {
|
||||||
|
// A trimmed-down real appmanifest_<id>.acf.
|
||||||
|
let acf = r#"
|
||||||
|
"AppState"
|
||||||
|
{
|
||||||
|
"appid" "730"
|
||||||
|
"name" "Counter-Strike 2"
|
||||||
|
"StateFlags" "4"
|
||||||
|
"installdir" "Counter-Strike Global Offensive"
|
||||||
|
"UserConfig"
|
||||||
|
{
|
||||||
|
"language" "english"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let root = parse(acf).unwrap();
|
||||||
|
assert_eq!(root.get_path(&["AppState", "name"]).and_then(Value::as_str), Some("Counter-Strike 2"));
|
||||||
|
assert_eq!(root.get_path(&["AppState", "appid"]).and_then(Value::as_str), Some("730"));
|
||||||
|
// Case-insensitive key lookup.
|
||||||
|
assert_eq!(root.get_path(&["appstate", "NAME"]).and_then(Value::as_str), Some("Counter-Strike 2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_libraryfolders_paths_with_escaped_backslashes() {
|
||||||
|
// Windows paths arrive with doubled backslashes (escaped).
|
||||||
|
let vdf = r#"
|
||||||
|
"libraryfolders"
|
||||||
|
{
|
||||||
|
"0"
|
||||||
|
{
|
||||||
|
"path" "C:\\Program Files (x86)\\Steam"
|
||||||
|
"apps"
|
||||||
|
{
|
||||||
|
"730" "35000000000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"1"
|
||||||
|
{
|
||||||
|
"path" "/home/eric/.local/share/Steam"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let root = parse(vdf).unwrap();
|
||||||
|
let lf = root.get("libraryfolders").unwrap();
|
||||||
|
assert_eq!(lf.get_path(&["0", "path"]).and_then(Value::as_str), Some(r"C:\Program Files (x86)\Steam"));
|
||||||
|
assert_eq!(lf.get_path(&["1", "path"]).and_then(Value::as_str), Some("/home/eric/.local/share/Steam"));
|
||||||
|
// The library folder ids are iterable for discovery.
|
||||||
|
let ids: Vec<&str> = lf.entries().iter().map(|(k, _)| k.as_str()).collect();
|
||||||
|
assert_eq!(ids, vec!["0", "1"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_registry_running_appid_deep_path() {
|
||||||
|
let reg = r#"
|
||||||
|
"Registry"
|
||||||
|
{
|
||||||
|
"HKCU"
|
||||||
|
{
|
||||||
|
"Software"
|
||||||
|
{
|
||||||
|
"Valve"
|
||||||
|
{
|
||||||
|
"Steam"
|
||||||
|
{
|
||||||
|
"RunningAppID" "570"
|
||||||
|
"language" "english"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let root = parse(reg).unwrap();
|
||||||
|
let appid = root
|
||||||
|
.get_path(&["Registry", "HKCU", "Software", "Valve", "Steam", "RunningAppID"])
|
||||||
|
.and_then(Value::as_str);
|
||||||
|
assert_eq!(appid, Some("570"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handles_comments_and_barewords() {
|
||||||
|
let vdf = "// a comment\n\"root\"\n{\n\tbarekey barevalue // trailing\n}\n";
|
||||||
|
let root = parse(vdf).unwrap();
|
||||||
|
assert_eq!(root.get_path(&["root", "barekey"]).and_then(Value::as_str), Some("barevalue"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_malformed_without_panicking() {
|
||||||
|
// Unbalanced braces.
|
||||||
|
assert!(parse("\"a\" {").is_err());
|
||||||
|
// Stray closing brace.
|
||||||
|
assert!(parse("}").is_err());
|
||||||
|
// Key with no value at EOF.
|
||||||
|
assert!(parse("\"lonely\"").is_err());
|
||||||
|
// Unterminated quoted string.
|
||||||
|
assert!(parse("\"key\" \"unterminated").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_pathologically_deep_nesting() {
|
||||||
|
// Build MAX_DEPTH+5 nested objects; must error, not overflow the stack.
|
||||||
|
let mut s = String::new();
|
||||||
|
for i in 0..(MAX_DEPTH + 5) {
|
||||||
|
s.push_str(&format!("\"k{i}\" {{"));
|
||||||
|
}
|
||||||
|
for _ in 0..(MAX_DEPTH + 5) {
|
||||||
|
s.push('}');
|
||||||
|
}
|
||||||
|
assert!(parse(&s).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_keys_return_none_not_error() {
|
||||||
|
let root = parse("\"AppState\" { \"appid\" \"1\" }").unwrap();
|
||||||
|
assert_eq!(root.get_path(&["AppState", "name"]), None);
|
||||||
|
assert_eq!(root.get_path(&["Nope"]), None);
|
||||||
|
// Treating a string as an object yields None rather than panicking.
|
||||||
|
assert_eq!(root.get_path(&["AppState", "appid", "deeper"]), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ pub mod recents;
|
|||||||
pub mod discovery;
|
pub mod discovery;
|
||||||
pub mod hotkeys;
|
pub mod hotkeys;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
|
pub mod game;
|
||||||
|
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ use crate::protocol::GOSSIP_SIG_DOMAIN;
|
|||||||
/// reasonable cross-peer clock skew without leaving a wide replay window.
|
/// reasonable cross-peer clock skew without leaving a wide replay window.
|
||||||
const GOSSIP_FRESHNESS_MS: u64 = 120_000;
|
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.
|
/// A gossip message plus the authentication envelope that proves who sent it.
|
||||||
/// `author` is the claimed sender (an `EndpointId`, which *is* an ed25519 public
|
/// `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
|
/// key); `sig` is that key's signature over [`signable_bytes`], so a forged
|
||||||
@@ -343,6 +352,17 @@ impl RoomState for IrohGossipState {
|
|||||||
match res {
|
match res {
|
||||||
Ok(iroh_gossip::api::Event::Received(msg)) => {
|
Ok(iroh_gossip::api::Event::Received(msg)) => {
|
||||||
crate::log_msg(&format!("Gossip received Event::Received from delivery={:?}", msg.delivered_from));
|
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) {
|
match serde_json::from_slice::<GossipPayload>(&msg.content) {
|
||||||
Ok(payload) => {
|
Ok(payload) => {
|
||||||
// Authenticate before trusting `author` for ANY
|
// Authenticate before trusting `author` for ANY
|
||||||
@@ -406,6 +426,15 @@ impl RoomState for IrohGossipState {
|
|||||||
// peer-supplied: cap/validate once at ingest
|
// peer-supplied: cap/validate once at ingest
|
||||||
// so invalid offers never render a Watch button.
|
// so invalid offers never render a Watch button.
|
||||||
state.sharing = state.sharing.and_then(crate::screenshare::sanitize_ticket);
|
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);
|
disconnected_peers.lock().unwrap().remove(&payload.author);
|
||||||
let (is_new, state_changed) = {
|
let (is_new, state_changed) = {
|
||||||
let mut peer_map = peers.lock().unwrap();
|
let mut peer_map = peers.lock().unwrap();
|
||||||
@@ -676,6 +705,7 @@ mod tests {
|
|||||||
addr,
|
addr,
|
||||||
sharing: None,
|
sharing: None,
|
||||||
avatar: crate::avatar::Avatar::default(),
|
avatar: crate::avatar::Avatar::default(),
|
||||||
|
game: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,58 @@ pub struct PeerState {
|
|||||||
/// peers/configs that predate the field still deserialize (→ monogram).
|
/// peers/configs that predate the field still deserialize (→ monogram).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub avatar: crate::avatar::Avatar,
|
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
|
||||||
|
/// that change only on explicit user action and persist for the whole core
|
||||||
|
/// session. The remaining `PeerState` fields are *volatile* — mute state, current
|
||||||
|
/// `addr`, and the active screen-share ticket are read fresh at each announce — so
|
||||||
|
/// they are passed into [`SelfPresence::to_state`] rather than stored here.
|
||||||
|
///
|
||||||
|
/// This is the single source of truth for building our own `PeerState`: core
|
||||||
|
/// reconstructs self-state in several command branches (join, mute toggle, avatar
|
||||||
|
/// change, screen-share start/stop), and centralizing the `PeerState` literal here
|
||||||
|
/// means a new presence field is added in exactly one place instead of at every
|
||||||
|
/// call site.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
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 {
|
||||||
|
/// Combine the sticky identity fields with the volatile per-announce fields
|
||||||
|
/// (`is_muted`, current `addr`, active-share `sharing` ticket) into a full
|
||||||
|
/// `PeerState` ready to announce over the gossip presence plane.
|
||||||
|
pub fn to_state(
|
||||||
|
&self,
|
||||||
|
is_muted: bool,
|
||||||
|
addr: iroh::EndpointAddr,
|
||||||
|
sharing: Option<String>,
|
||||||
|
) -> PeerState {
|
||||||
|
PeerState {
|
||||||
|
name: self.name.clone(),
|
||||||
|
is_muted,
|
||||||
|
addr,
|
||||||
|
sharing,
|
||||||
|
avatar: self.avatar.clone(),
|
||||||
|
game: self.game.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -250,6 +302,7 @@ mod tests {
|
|||||||
addr,
|
addr,
|
||||||
sharing: None,
|
sharing: None,
|
||||||
avatar: crate::avatar::Avatar::default(),
|
avatar: crate::avatar::Avatar::default(),
|
||||||
|
game: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,6 +411,29 @@ mod tests {
|
|||||||
assert_eq!(PeerSpeakTicket::restamp("not-a-ticket", EndpointAddr::from(me)), "not-a-ticket");
|
assert_eq!(PeerSpeakTicket::restamp("not-a-ticket", EndpointAddr::from(me)), "not-a-ticket");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn self_presence_builds_peer_state_with_volatile_fields() {
|
||||||
|
let addr = EndpointAddr::from(SecretKey::generate().public());
|
||||||
|
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()));
|
||||||
|
assert_eq!(muted.name, "Alice");
|
||||||
|
assert!(muted.is_muted);
|
||||||
|
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);
|
||||||
|
assert_eq!(unmuted.sharing, None);
|
||||||
|
assert_eq!(unmuted.name, muted.name);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_peer_state_serde_round_trip() {
|
fn test_peer_state_serde_round_trip() {
|
||||||
let original = sample_peer_state();
|
let original = sample_peer_state();
|
||||||
|
|||||||
+8
-2
@@ -26,7 +26,13 @@ pub const FRIENDS_PROTO: u32 = 1;
|
|||||||
/// v2 (0.3.0): `GossipMessage::Chat` gained an optional file attachment
|
/// v2 (0.3.0): `GossipMessage::Chat` gained an optional file attachment
|
||||||
/// (`ChatAttachment`), so a pre-v2 peer can't interpret/serve chat files — bumped
|
/// (`ChatAttachment`), so a pre-v2 peer can't interpret/serve chat files — bumped
|
||||||
/// to fail fast rather than half-work.
|
/// 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
|
/// File-transfer plane version (chat attachment request/stream shape). Bump on
|
||||||
/// any change. Mirrored in [`FILES_ALPN`].
|
/// any change. Mirrored in [`FILES_ALPN`].
|
||||||
pub const FILES_PROTO: u32 = 1;
|
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
|
/// ed25519 gossip signature domain: `peerspeak-gossip-v<GOSSIP_PROTO>`. Carries
|
||||||
/// the gossip protocol version into every signed payload — a version mismatch
|
/// the gossip protocol version into every signed payload — a version mismatch
|
||||||
/// fails verification (cryptographic separation between gossip versions).
|
/// 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
|
/// Version-namespace a room topic so peers on different gossip protocol versions
|
||||||
/// derive **different subscription topics from the same ticket** and therefore
|
/// derive **different subscription topics from the same ticket** and therefore
|
||||||
|
|||||||
+78
-7
@@ -27,19 +27,50 @@ fn is_spoofing_format_char(c: char) -> bool {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Max characters kept for a broadcast game-presence label after sanitizing
|
||||||
|
/// (game titles run longer than nicknames, so a wider cap than [`NAME_MAX_CHARS`]),
|
||||||
|
/// bounded additionally by [`GAME_LABEL_MAX_BYTES`] so a multibyte-heavy string
|
||||||
|
/// can't blow the presence frame.
|
||||||
|
pub const GAME_LABEL_MAX_CHARS: usize = 64;
|
||||||
|
/// Max UTF-8 bytes kept for a broadcast game-presence label, applied on top of
|
||||||
|
/// [`GAME_LABEL_MAX_CHARS`]. Caps the on-wire size regardless of scalar width.
|
||||||
|
pub const GAME_LABEL_MAX_BYTES: usize = 256;
|
||||||
|
|
||||||
|
/// Shared cleaning for untrusted short labels: strip bidi / zero-width spoofing
|
||||||
|
/// format characters, turn control characters into spaces, collapse any whitespace
|
||||||
|
/// run to a single space, and trim the ends. Length capping is the caller's job.
|
||||||
|
fn clean_label(input: &str) -> String {
|
||||||
|
let cleaned: String = input
|
||||||
|
.chars()
|
||||||
|
.filter(|c| !is_spoofing_format_char(*c))
|
||||||
|
.map(|c| if c.is_control() { ' ' } else { c })
|
||||||
|
.collect();
|
||||||
|
cleaned.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
/// Sanitize an untrusted peer display name for safe rendering. Strips bidi /
|
/// Sanitize an untrusted peer display name for safe rendering. Strips bidi /
|
||||||
/// zero-width format characters, turns control characters into spaces, collapses
|
/// zero-width format characters, turns control characters into spaces, collapses
|
||||||
/// any whitespace run to a single space, trims the ends, and caps the length at
|
/// any whitespace run to a single space, trims the ends, and caps the length at
|
||||||
/// [`NAME_MAX_CHARS`]. Returns `""` if nothing usable remains (callers may
|
/// [`NAME_MAX_CHARS`]. Returns `""` if nothing usable remains (callers may
|
||||||
/// substitute a placeholder such as a short id).
|
/// substitute a placeholder such as a short id).
|
||||||
pub fn sanitize_name(input: &str) -> String {
|
pub fn sanitize_name(input: &str) -> String {
|
||||||
let cleaned: String = input
|
clean_label(input).chars().take(NAME_MAX_CHARS).collect()
|
||||||
.chars()
|
}
|
||||||
.filter(|c| !is_spoofing_format_char(*c))
|
|
||||||
.map(|c| if c.is_control() { ' ' } else { c })
|
/// Sanitize an untrusted game-presence label (the `Playing <name>` status that
|
||||||
.collect();
|
/// rides the gossip presence plane). Same spoof/control cleaning as
|
||||||
let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
|
/// [`sanitize_name`], but capped at [`GAME_LABEL_MAX_CHARS`] scalars AND
|
||||||
collapsed.chars().take(NAME_MAX_CHARS).collect()
|
/// [`GAME_LABEL_MAX_BYTES`] bytes. Apply on BOTH the outgoing label we detect and
|
||||||
|
/// any incoming peer label. Returns `""` if nothing usable remains (no broadcast).
|
||||||
|
pub fn sanitize_game_label(input: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for c in clean_label(input).chars().take(GAME_LABEL_MAX_CHARS) {
|
||||||
|
if out.len() + c.len_utf8() > GAME_LABEL_MAX_BYTES {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A piece of a chat message after URL detection: literal text or a link.
|
/// A piece of a chat message after URL detection: literal text or a link.
|
||||||
@@ -135,6 +166,46 @@ mod tests {
|
|||||||
assert_eq!(sanitize_name(&long).chars().count(), NAME_MAX_CHARS);
|
assert_eq!(sanitize_name(&long).chars().count(), NAME_MAX_CHARS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- sanitize_game_label ----------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn game_label_keeps_ordinary_titles_and_strips_spoofing() {
|
||||||
|
assert_eq!(sanitize_game_label("Half-Life 2"), "Half-Life 2");
|
||||||
|
// Same spoof/control cleaning as names.
|
||||||
|
assert_eq!(sanitize_game_label("Doom\u{202E}txt"), "Doomtxt");
|
||||||
|
assert_eq!(sanitize_game_label("a\u{0}b\r\nc"), "a b c");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn game_label_caps_chars_wider_than_names() {
|
||||||
|
// A game label keeps more than a name's 48 (up to 64), so a title between
|
||||||
|
// the two caps survives in full.
|
||||||
|
let mid = "g".repeat(56);
|
||||||
|
assert_eq!(sanitize_game_label(&mid).chars().count(), 56);
|
||||||
|
let long = "g".repeat(GAME_LABEL_MAX_CHARS + 100);
|
||||||
|
assert_eq!(sanitize_game_label(&long).chars().count(), GAME_LABEL_MAX_CHARS);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn game_label_caps_bytes_for_multibyte_titles() {
|
||||||
|
// Each '世' is 3 bytes; 64 of them = 192 bytes (under 256) → all kept.
|
||||||
|
let cjk = "世".repeat(GAME_LABEL_MAX_CHARS);
|
||||||
|
let out = sanitize_game_label(&cjk);
|
||||||
|
assert_eq!(out.chars().count(), GAME_LABEL_MAX_CHARS);
|
||||||
|
assert!(out.len() <= GAME_LABEL_MAX_BYTES);
|
||||||
|
// Emoji are 4 bytes; the byte cap bites before the char cap (256/4 = 64,
|
||||||
|
// but the leading clean keeps them as a run) — never exceeds the byte cap.
|
||||||
|
let emoji = "🎮".repeat(GAME_LABEL_MAX_CHARS);
|
||||||
|
let out = sanitize_game_label(&emoji);
|
||||||
|
assert!(out.len() <= GAME_LABEL_MAX_BYTES);
|
||||||
|
assert!(out.chars().all(|c| c == '🎮'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn game_label_empty_when_nothing_usable() {
|
||||||
|
assert_eq!(sanitize_game_label("\u{0}\r\n\t "), "");
|
||||||
|
}
|
||||||
|
|
||||||
// --- linkify -----------------------------------------------------------
|
// --- linkify -----------------------------------------------------------
|
||||||
|
|
||||||
/// Concatenating every segment's inner text must reproduce the input exactly.
|
/// Concatenating every segment's inner text must reproduce the input exactly.
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ fn state(name: &str, addr: EndpointAddr) -> PeerState {
|
|||||||
addr,
|
addr,
|
||||||
sharing: None,
|
sharing: None,
|
||||||
avatar: Default::default(),
|
avatar: Default::default(),
|
||||||
|
game: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user