diff --git a/src-tauri/src/auth.rs b/src-tauri/src/auth.rs index 2502ccc..e847713 100644 --- a/src-tauri/src/auth.rs +++ b/src-tauri/src/auth.rs @@ -6,9 +6,7 @@ use keyring::Entry; use serde::Deserialize; use tokio::sync::Mutex as AsyncMutex; -use crate::config::{ - AppConfig, DetectedService, KEYRING_ACCESS, KEYRING_SERVICE, KEYRING_SESSION, -}; +use crate::config::{AppConfig, DetectedService, KEYRING_ACCESS, KEYRING_SERVICE, KEYRING_SESSION}; use crate::device; use crate::oauth_loopback::REDIRECT_URI; use crate::pkce; @@ -195,10 +193,7 @@ pub async fn detect_and_apply(cfg: &mut AppConfig) -> Result { if !res.status().is_success() { return Err(format!("health HTTP {}", res.status())); } - let body: HealthBody = res - .json() - .await - .map_err(|e| format!("health JSON: {e}"))?; + let body: HealthBody = res.json().await.map_err(|e| format!("health JSON: {e}"))?; if !body.ok { return Err("health ok=false".into()); } @@ -349,10 +344,8 @@ mod tests { #[test] fn parses_code_callback() { - let (c, s) = parse_callback_code( - "http://127.0.0.1:58473/callback?code=abc&state=xyz", - ) - .unwrap(); + let (c, s) = + parse_callback_code("http://127.0.0.1:58473/callback?code=abc&state=xyz").unwrap(); assert_eq!(c, "abc"); assert_eq!(s, "xyz"); } diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index f3e5ac5..83fc6f8 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -11,8 +11,7 @@ pub const OAUTH_REVOKE_PATH: &str = "/oauth/qmonitor/revoke"; pub const KEYRING_SERVICE: &str = "qmonitor"; pub const KEYRING_ACCESS: &str = "access_token"; pub const KEYRING_SESSION: &str = "session_token"; -pub const DEFAULT_DETECTABLE_URL: &str = - "https://discord.com/api/v10/applications/detectable"; +pub const DEFAULT_DETECTABLE_URL: &str = "https://discord.com/api/v10/applications/detectable"; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 89bf993..d9c45c9 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -653,7 +653,10 @@ mod tests { let path = dir.path().join("test.db"); let db = TursoDb::open(&path).await.expect("open"); db.ping().await.expect("ping"); - let row = db.open_session_at(&sample_identity(), Utc::now()).await.unwrap(); + let row = db + .open_session_at(&sample_identity(), Utc::now()) + .await + .unwrap(); assert_eq!(row.push_status, PushStatus::Active); let ended = db.end_session_at(&row.id, Utc::now()).await.unwrap(); assert_eq!(ended.push_status, PushStatus::Pending); @@ -678,8 +681,14 @@ mod tests { let dir = tempdir().unwrap(); let path = dir.path().join("dup.db"); let db = TursoDb::open(&path).await.expect("open"); - let a = db.open_session_at(&sample_identity(), Utc::now()).await.unwrap(); - let b = db.open_session_at(&sample_identity(), Utc::now()).await.unwrap(); + let a = db + .open_session_at(&sample_identity(), Utc::now()) + .await + .unwrap(); + let b = db + .open_session_at(&sample_identity(), Utc::now()) + .await + .unwrap(); assert_eq!(a.id, b.id); assert_eq!(db.list_active().await.unwrap().len(), 1); } @@ -689,7 +698,10 @@ mod tests { let dir = tempdir().unwrap(); let path = dir.path().join("discard.db"); let db = TursoDb::open(&path).await.expect("open"); - let row = db.open_session_at(&sample_identity(), Utc::now()).await.unwrap(); + let row = db + .open_session_at(&sample_identity(), Utc::now()) + .await + .unwrap(); db.discard_session(&row.id).await.unwrap(); assert!(db.list_active().await.unwrap().is_empty()); assert!(db.list_due_pushes().await.unwrap().is_empty()); diff --git a/src-tauri/src/detect/platform/linux.rs b/src-tauri/src/detect/platform/linux.rs index be0c36c..c9894f0 100644 --- a/src-tauri/src/detect/platform/linux.rs +++ b/src-tauri/src/detect/platform/linux.rs @@ -26,10 +26,7 @@ pub fn is_proton_wrapper(process_name: &str) -> bool { } #[cfg_attr(not(target_os = "linux"), allow(dead_code))] -pub fn detect_steam( - processes: &[ProcessSnapshot], - steam: &SteamLibraryIndex, -) -> Vec { +pub fn detect_steam(processes: &[ProcessSnapshot], steam: &SteamLibraryIndex) -> Vec { let mut identities = steamlaunch_identities(processes, steam); for proc in processes { if is_denied(&proc.name) || is_proton_wrapper(&proc.name) { @@ -87,7 +84,9 @@ mod tests { SteamGame { app_id: 730, title: "CS2".into(), - install_path: PathBuf::from("/games/steamapps/common/Counter-Strike Global Offensive"), + install_path: PathBuf::from( + "/games/steamapps/common/Counter-Strike Global Offensive", + ), }, ); let procs = vec![ProcessSnapshot { @@ -118,7 +117,9 @@ mod tests { let procs = vec![ProcessSnapshot { pid: 1, name: "dota2".into(), - exe_path: Some("/games/steamapps/common/dota 2 beta/game/bin/linuxsteamrt64/dota2".into()), + exe_path: Some( + "/games/steamapps/common/dota 2 beta/game/bin/linuxsteamrt64/dota2".into(), + ), cmdline: None, }]; let ids = detect_steam(&procs, &dota_index()); diff --git a/src-tauri/src/detect/platform/mod.rs b/src-tauri/src/detect/platform/mod.rs index b7aa10a..7ecdc47 100644 --- a/src-tauri/src/detect/platform/mod.rs +++ b/src-tauri/src/detect/platform/mod.rs @@ -3,14 +3,11 @@ pub mod linux; pub mod windows; -use crate::identity::{GameIdentity, ProcessSnapshot}; use crate::identity::steam_library::SteamLibraryIndex; +use crate::identity::{GameIdentity, ProcessSnapshot}; /// Dispatch to the host OS detector. -pub fn detect_steam( - processes: &[ProcessSnapshot], - steam: &SteamLibraryIndex, -) -> Vec { +pub fn detect_steam(processes: &[ProcessSnapshot], steam: &SteamLibraryIndex) -> Vec { #[cfg(target_os = "windows")] { windows::detect_steam(processes, steam) diff --git a/src-tauri/src/detect/platform/windows.rs b/src-tauri/src/detect/platform/windows.rs index 60613c8..52786ed 100644 --- a/src-tauri/src/detect/platform/windows.rs +++ b/src-tauri/src/detect/platform/windows.rs @@ -21,20 +21,12 @@ pub fn is_install_sidecar(proc: &ProcessSnapshot) -> bool { .unwrap_or("") .replace('\\', "/") .to_ascii_lowercase(); - const SEGMENTS: &[&str] = &[ - "/easyanticheat/", - "/battleye/", - "/eossdk/", - "/eosoverlay", - ]; + const SEGMENTS: &[&str] = &["/easyanticheat/", "/battleye/", "/eossdk/", "/eosoverlay"]; SEGMENTS.iter().any(|s| path.contains(s)) } #[cfg_attr(not(target_os = "windows"), allow(dead_code))] -pub fn detect_steam( - processes: &[ProcessSnapshot], - steam: &SteamLibraryIndex, -) -> Vec { +pub fn detect_steam(processes: &[ProcessSnapshot], steam: &SteamLibraryIndex) -> Vec { let mut identities = steamlaunch_identities(processes, steam); for proc in processes { if is_denied(&proc.name) || is_install_sidecar(proc) { diff --git a/src-tauri/src/health.rs b/src-tauri/src/health.rs index d8cedd5..4028b48 100644 --- a/src-tauri/src/health.rs +++ b/src-tauri/src/health.rs @@ -75,16 +75,12 @@ pub fn init_tracing() { FILE_ON.store(level.file_enabled(), Ordering::Relaxed); prune_now(level); - let (filter, reload_handle) = - reload::Layer::new(EnvFilter::new(level.env_filter())); + let (filter, reload_handle) = reload::Layer::new(EnvFilter::new(level.env_filter())); let _ = FILTER_RELOAD.set(reload_handle); tracing_subscriber::registry() .with(filter) - .with( - tracing_subscriber::fmt::layer() - .with_writer(std::io::stderr.and(GatedMakeWriter)), - ) + .with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr.and(GatedMakeWriter))) .init(); if level.file_enabled() { @@ -369,9 +365,9 @@ mod tests { } let _reset = ResetSink; - let active = dir.path().join( - active_daily_log_name().expect("sink is active"), - ); + let active = dir + .path() + .join(active_daily_log_name().expect("sink is active")); fs::write(&active, vec![b'x'; LOG_MAX_BYTES as usize + 64]).unwrap(); let other = dir.path().join("qmonitor.log.2020-01-01"); fs::write(&other, vec![b'y'; 200]).unwrap(); diff --git a/src-tauri/src/identity/catalog.rs b/src-tauri/src/identity/catalog.rs index 9b34e7f..952fbb5 100644 --- a/src-tauri/src/identity/catalog.rs +++ b/src-tauri/src/identity/catalog.rs @@ -41,11 +41,7 @@ impl LocalCatalog { pub fn match_process(&self, proc: &ProcessSnapshot) -> Option { let os = current_os_label(); let pname = proc.name.to_ascii_lowercase(); - let path_l = proc - .exe_path - .as_deref() - .unwrap_or("") - .to_ascii_lowercase(); + let path_l = proc.exe_path.as_deref().unwrap_or("").to_ascii_lowercase(); let cmd = proc.cmdline.as_deref().unwrap_or("").to_ascii_lowercase(); let mut best: Option<(Confidence, &CatalogEntry)> = None; diff --git a/src-tauri/src/identity/deny.rs b/src-tauri/src/identity/deny.rs index 5356789..4bd0d1e 100644 --- a/src-tauri/src/identity/deny.rs +++ b/src-tauri/src/identity/deny.rs @@ -66,6 +66,9 @@ pub fn is_denied(process_name: &str) -> bool { || stem.contains("crashhandler") || stem.contains("crashreporter") || stem.contains("webview") + || stem.contains("subprocess") + || stem.contains("unrealcef") + || stem.contains("crashpad") || stem.starts_with("qtwebengine") || name.contains("easyanticheat") || name.contains("battleye") @@ -83,6 +86,7 @@ mod tests { assert!(is_denied("UnityCrashHandler64.exe")); assert!(!is_denied("dota2.exe")); assert!(!is_denied("Hades.exe")); + assert!(!is_denied("Caffeine.exe")); } #[test] @@ -91,5 +95,7 @@ mod tests { assert!(is_denied("QtWebEngineProcess.exe")); assert!(is_denied("obs-browser-page.exe")); assert!(is_denied("NVIDIA Broadcast.exe")); + assert!(is_denied("UnrealCEFSubProcess.exe")); + assert!(!is_denied("Caffeine.exe")); } } diff --git a/src-tauri/src/identity/detectable.rs b/src-tauri/src/identity/detectable.rs index ed9c7c7..bcf07fb 100644 --- a/src-tauri/src/identity/detectable.rs +++ b/src-tauri/src/identity/detectable.rs @@ -210,11 +210,7 @@ fn normalize_path(s: &str) -> String { } fn pattern_basename(pattern: &str) -> String { - pattern - .rsplit('/') - .next() - .unwrap_or(pattern) - .to_string() + pattern.rsplit('/').next().unwrap_or(pattern).to_string() } fn current_discord_os() -> &'static str { diff --git a/src-tauri/src/identity/fixtures.rs b/src-tauri/src/identity/fixtures.rs index dd1cd17..f9a65d5 100644 --- a/src-tauri/src/identity/fixtures.rs +++ b/src-tauri/src/identity/fixtures.rs @@ -114,9 +114,7 @@ pub fn crash_report_client() -> ProcessSnapshot { proc( 1, "CrashReportClient.exe", - Some( - r"D:\Steam\steamapps\common\Apex Legends\Engine\Binaries\Win64\CrashReportClient.exe", - ), + Some(r"D:\Steam\steamapps\common\Apex Legends\Engine\Binaries\Win64\CrashReportClient.exe"), None, ) } @@ -131,20 +129,13 @@ pub fn r5apex_under_install() -> ProcessSnapshot { proc( 1, name, - Some(&format!( - r"D:\Steam\steamapps\common\Apex Legends\{name}" - )), + Some(&format!(r"D:\Steam\steamapps\common\Apex Legends\{name}")), None, ) } pub fn reaper_dota() -> ProcessSnapshot { - proc( - 9, - "reaper", - None, - Some("reaper SteamLaunch AppId=570 --"), - ) + proc(9, "reaper", None, Some("reaper SteamLaunch AppId=570 --")) } pub fn wineserver_under_dota() -> ProcessSnapshot { @@ -227,11 +218,47 @@ mod tests { } #[test] - fn path_only_game_exe_is_not_auto_tracked_without_discord() { + fn path_only_mismatched_exe_goes_pending() { let pipeline = pipeline_with(apex_steam(), DetectableCatalog::default(), Vec::new()); let (ids, pending) = pipeline.resolve_running(&[r5apex_under_install()]); - assert!(ids.is_empty(), "path-only Low must not auto-track: {ids:?}"); - assert!(pending.is_empty()); + assert!( + ids.is_empty(), + "mismatched exe must not auto-track: {ids:?}" + ); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].suggested_title, "Apex Legends"); + assert_eq!(pending[0].identity_id.as_deref(), Some("steam:1172470")); + } + + #[test] + fn steam_path_title_like_exe_auto_tracks_without_discord() { + let mut steam = SteamLibraryIndex::default(); + steam.games.insert( + 3321460, + SteamGame { + app_id: 3321460, + title: "Crimson Desert Enhanced".into(), + install_path: PathBuf::from(r"D:\SteamLibrary\steamapps\common\Crimson Desert"), + }, + ); + let pipeline = pipeline_with(steam, DetectableCatalog::default(), Vec::new()); + let name = if cfg!(target_os = "windows") { + "CrimsonDesert.exe" + } else { + "CrimsonDesert" + }; + let procs = vec![proc( + 1, + name, + Some(r"D:\SteamLibrary\steamapps\common\Crimson Desert\bin64\CrimsonDesert.exe"), + None, + )]; + let (ids, pending) = pipeline.resolve_running(&procs); + assert!(pending.is_empty(), "pending: {pending:?}"); + assert_eq!(ids.len(), 1); + assert_eq!(ids[0].steam_app_id, Some(3321460)); + assert_eq!(ids[0].confidence, Confidence::Medium); + assert_eq!(ids[0].title, "Crimson Desert Enhanced"); } #[test] diff --git a/src-tauri/src/identity/mod.rs b/src-tauri/src/identity/mod.rs index 1a5c3c3..59e5f02 100644 --- a/src-tauri/src/identity/mod.rs +++ b/src-tauri/src/identity/mod.rs @@ -4,6 +4,7 @@ pub mod detectable; pub mod fingerprint; pub mod resolver; pub mod steam_library; +pub mod steam_path; #[cfg(test)] pub mod fixtures; diff --git a/src-tauri/src/identity/resolver.rs b/src-tauri/src/identity/resolver.rs index d4098e5..cf55224 100644 --- a/src-tauri/src/identity/resolver.rs +++ b/src-tauri/src/identity/resolver.rs @@ -12,6 +12,7 @@ use super::deny::is_denied; use super::detectable::DetectableCatalog; use super::fingerprint::fingerprint_process; use super::steam_library::SteamLibraryIndex; +use super::steam_path; use super::{Confidence, GameIdentity, ManualGame, PendingDetection, ProcessSnapshot}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -51,12 +52,20 @@ impl IdentityPipeline { steam_override: Option<&Path>, catalog_path: Option<&Path>, user_mappings: HashMap, + steam_fallback: Option, ) -> Self { let catalog = catalog_path .map(LocalCatalog::load_from_path) .unwrap_or_default(); + let steam = match SteamLibraryIndex::load(steam_override) { + Ok(index) => index, + Err(e) => { + tracing::warn!(%e, "steam library load failed"); + steam_fallback.unwrap_or_default() + } + }; Self { - steam: SteamLibraryIndex::load(steam_override), + steam, catalog, detectable: DetectableCatalog::load_from_disk(), user_mappings, @@ -144,6 +153,8 @@ impl IdentityPipeline { } } + steam_path::finalize_steam_path_hits(&self.steam, &mut identities, &mut pending, processes); + identities.retain(|i| { i.confidence.allows_auto_track() && !self.ignored_identities.contains(&i.id) }); @@ -425,8 +436,7 @@ mod tests { #[test] fn manual_game_matches_path() { - let (exe_name, path_hint) = - parse_exe_input(r"D:\Games\Hades\Hades.exe").unwrap(); + let (exe_name, path_hint) = parse_exe_input(r"D:\Games\Hades\Hades.exe").unwrap(); assert_eq!(exe_name, "Hades.exe"); assert_eq!(path_hint.as_deref(), Some("Hades")); @@ -465,4 +475,36 @@ mod tests { assert_eq!(name, "Hades.exe"); assert_eq!(hint.as_deref(), Some("Hades")); } + + fn prior_dota() -> SteamLibraryIndex { + let mut steam = SteamLibraryIndex::default(); + steam.games.insert( + 570, + super::super::steam_library::SteamGame { + app_id: 570, + title: "Dota 2".into(), + install_path: std::path::PathBuf::from("/games/dota"), + }, + ); + steam + } + + #[test] + fn new_preserves_fallback_steam_when_load_fails() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("missing-steam"); + let pipe = IdentityPipeline::new(Some(&missing), None, HashMap::new(), Some(prior_dota())); + assert_eq!( + pipe.steam.games.get(&570).map(|g| g.title.as_str()), + Some("Dota 2") + ); + } + + #[test] + fn new_defaults_steam_when_load_fails_without_fallback() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("missing-steam"); + let pipe = IdentityPipeline::new(Some(&missing), None, HashMap::new(), None); + assert!(pipe.steam.games.is_empty()); + } } diff --git a/src-tauri/src/identity/steam_library.rs b/src-tauri/src/identity/steam_library.rs index 0175f60..c20d5d0 100644 --- a/src-tauri/src/identity/steam_library.rs +++ b/src-tauri/src/identity/steam_library.rs @@ -19,16 +19,21 @@ pub struct SteamLibraryIndex { } impl SteamLibraryIndex { - pub fn load(steam_root: Option<&Path>) -> Self { + pub fn load(steam_root: Option<&Path>) -> Result { let Some(root) = steam_root.map(PathBuf::from).or_else(detect_steam_root) else { - return Self::default(); + return Ok(Self::default()); }; + if !root.exists() { + return Err(format!("steam root not found: {}", root.display())); + } let mut games = HashMap::new(); + let mut steamapps_ok = 0u32; for lib in library_folders(&root) { let steamapps = lib.join("steamapps"); let Ok(entries) = fs::read_dir(&steamapps) else { continue; }; + steamapps_ok += 1; for entry in entries.flatten() { let name = entry.file_name().to_string_lossy().into_owned(); if !name.starts_with("appmanifest_") || !name.ends_with(".acf") { @@ -39,7 +44,10 @@ impl SteamLibraryIndex { } } } - Self { games } + if steamapps_ok == 0 { + return Err(format!("steamapps unreadable under {}", root.display())); + } + Ok(Self { games }) } pub fn resolve_app_id(&self, app_id: u32) -> Option { @@ -186,10 +194,7 @@ pub fn parse_reaper_app_ids(cmdline: &str) -> Vec { let mut search = cmdline; while let Some(idx) = search.find(marker) { let after = &search[idx + marker.len()..]; - let id_str: String = after - .chars() - .take_while(|c| c.is_ascii_digit()) - .collect(); + let id_str: String = after.chars().take_while(|c| c.is_ascii_digit()).collect(); if let Ok(id) = id_str.parse::() { // Boundary: next char must be non-digit (space/end) — already ensured by take_while. // Avoid substring: "440" matching inside "4400" — take_while gets full number so 4400 is fine as distinct. @@ -215,13 +220,32 @@ pub fn cmdline_has_app_id(cmdline: &str, app_id: u32) -> bool { false } +#[cfg(test)] +pub(crate) fn write_test_library( + root: &Path, + app_id: u32, + title: &str, + installdir: &str, +) -> std::io::Result<()> { + let steamapps = root.join("steamapps"); + fs::create_dir_all(steamapps.join("common").join(installdir))?; + let body = format!( + "\"AppState\"\n{{\n\t\"appid\"\t\t\"{app_id}\"\n\t\"name\"\t\t\"{title}\"\n\t\"installdir\"\t\t\"{installdir}\"\n}}\n" + ); + fs::write(steamapps.join(format!("appmanifest_{app_id}.acf")), body)?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; #[test] fn reaper_app_id_boundary() { - assert!(cmdline_has_app_id("reaper SteamLaunch AppId=440 -- game", 440)); + assert!(cmdline_has_app_id( + "reaper SteamLaunch AppId=440 -- game", + 440 + )); assert!(!cmdline_has_app_id( "reaper SteamLaunch AppId=4400 -- game", 440 @@ -250,7 +274,9 @@ mod tests { let proc = ProcessSnapshot { pid: 1, name: "dota2.exe".into(), - exe_path: Some(r"D:\Steam\steamapps\common\dota 2 beta\game\bin\win64\dota2.exe".into()), + exe_path: Some( + r"D:\Steam\steamapps\common\dota 2 beta\game\bin\win64\dota2.exe".into(), + ), cmdline: None, }; let id = index.match_path(&proc).unwrap(); @@ -262,8 +288,7 @@ mod tests { #[test] fn path_boundary_portal_vs_portal_2() { let portal = PathBuf::from(r"D:\Steam\steamapps\common\Portal"); - let portal2_exe = - r"D:\Steam\steamapps\common\Portal 2\bin\portal2.exe"; + let portal2_exe = r"D:\Steam\steamapps\common\Portal 2\bin\portal2.exe"; assert!(!path_is_under_install(portal2_exe, &portal)); assert!(path_is_under_install( r"D:\Steam\steamapps\common\Portal\portal.exe", @@ -298,4 +323,30 @@ mod tests { }; assert!(index.match_path(&proc).is_none()); } + + #[test] + fn load_missing_root_is_error() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("no-steam"); + assert!(SteamLibraryIndex::load(Some(&missing)).is_err()); + } + + #[test] + fn load_readable_empty_steamapps_is_ok_empty() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("steamapps")).unwrap(); + let index = SteamLibraryIndex::load(Some(dir.path())).unwrap(); + assert!(index.games.is_empty()); + } + + #[test] + fn load_parses_appmanifest() { + let dir = tempfile::tempdir().unwrap(); + write_test_library(dir.path(), 570, "Dota 2", "dota 2 beta").unwrap(); + let index = SteamLibraryIndex::load(Some(dir.path())).unwrap(); + assert_eq!( + index.games.get(&570).map(|g| g.title.as_str()), + Some("Dota 2") + ); + } } diff --git a/src-tauri/src/identity/steam_path.rs b/src-tauri/src/identity/steam_path.rs new file mode 100644 index 0000000..0e75821 --- /dev/null +++ b/src-tauri/src/identity/steam_path.rs @@ -0,0 +1,298 @@ +//! Steam install-dir path hits: title-like exe promotion and pending leftovers. + +use super::deny::is_denied; +use super::fingerprint::fingerprint_process; +use super::steam_library::{path_is_under_install, SteamLibraryIndex}; +use super::{Confidence, GameIdentity, PendingDetection, ProcessSnapshot}; +use crate::detect::platform; + +const MIN_SLUG: usize = 4; +const MIN_AFFIX: usize = 8; + +const TITLE_TAILS: &[&str] = &[ + "definitiveedition", + "completeedition", + "gameoftheyear", + "ultimateedition", + "deluxeedition", + "goldedition", + "standardedition", + "remastered", + "enhanced", + "complete", + "definitive", + "ultimate", + "deluxe", + "goty", +]; + +const EXE_TAILS: &[&str] = &[ + "win64shipping", + "linuxsteamrt64", + "shipping", + "win64", + "win32", +]; + +const GENERIC_EXE: &[&str] = &[ + "game", "games", "launcher", "launch", "client", "app", "unity", "unreal", "editor", "server", + "helper", "service", "crash", "setup", "update", "play", "start", "engine", +]; + +/// Alphanumeric slug of a library title vs process name (e.g. Crimson Desert Enhanced / CrimsonDesert.exe). +pub fn exe_looks_like_title(title: &str, process_name: &str) -> bool { + let title_slug = strip_tails(alnum_lower(title), TITLE_TAILS); + let basename = process_basename(process_name); + let stem = basename.strip_suffix(".exe").unwrap_or(&basename); + let exe_slug = strip_tails(alnum_lower(stem), EXE_TAILS); + + if title_slug.len() < MIN_SLUG || exe_slug.len() < MIN_SLUG { + return false; + } + if GENERIC_EXE.contains(&exe_slug.as_str()) { + return false; + } + if title_slug == exe_slug { + return true; + } + (exe_slug.len() >= MIN_AFFIX && title_slug.ends_with(&exe_slug)) + || (title_slug.len() >= MIN_AFFIX && exe_slug.ends_with(&title_slug)) +} + +/// Process under `steamapps/common` that did not unique-match any indexed install dir. +pub fn is_unindexed_steamapps_process(proc: &ProcessSnapshot, steam: &SteamLibraryIndex) -> bool { + if is_path_noise(proc) { + return false; + } + let Some(path) = proc.exe_path.as_deref() else { + return false; + }; + let n = path.replace('\\', "/").to_ascii_lowercase(); + if !n.contains("/steamapps/common/") { + return false; + } + steam.match_path(proc).is_none() +} + +/// Unique Steam-path Low hits: title-like exe → Medium auto-track; otherwise pending (not silent drop). +pub fn finalize_steam_path_hits( + steam: &SteamLibraryIndex, + identities: &mut Vec, + pending: &mut Vec, + processes: &[ProcessSnapshot], +) { + let mut drop_idx: Vec = Vec::new(); + for (idx, id) in identities.iter_mut().enumerate() { + if id.source != "steam-path" || id.confidence != Confidence::Low { + continue; + } + let Some(app_id) = id.steam_app_id else { + continue; + }; + let Some(proc) = best_process_under_app(steam, app_id, processes) else { + continue; + }; + if exe_looks_like_title(&id.title, &proc.name) { + id.confidence = Confidence::Medium; + id.exe = Some(proc.name.clone()); + continue; + } + pending.push(PendingDetection { + process_name: proc.name.clone(), + exe_path: proc.exe_path.clone(), + fingerprint: fingerprint_process(proc), + suggested_title: id.title.clone(), + identity_id: Some(id.id.clone()), + }); + drop_idx.push(idx); + } + for idx in drop_idx.into_iter().rev() { + identities.remove(idx); + } +} + +fn best_process_under_app<'a>( + steam: &SteamLibraryIndex, + app_id: u32, + processes: &'a [ProcessSnapshot], +) -> Option<&'a ProcessSnapshot> { + let game = steam.games.get(&app_id)?; + let under: Vec<&ProcessSnapshot> = processes + .iter() + .filter(|p| { + !is_path_noise(p) + && p.exe_path + .as_deref() + .is_some_and(|path| path_is_under_install(path, &game.install_path)) + }) + .collect(); + if under.is_empty() { + return None; + } + if let Some(i) = under + .iter() + .position(|p| exe_looks_like_title(&game.title, &p.name)) + { + return Some(under[i]); + } + Some(under[0]) +} + +fn is_path_noise(proc: &ProcessSnapshot) -> bool { + is_denied(&proc.name) + || platform::linux::is_proton_wrapper(&proc.name) + || platform::windows::is_install_sidecar(proc) +} + +fn process_basename(process_name: &str) -> String { + process_name + .rsplit(['/', '\\']) + .next() + .unwrap_or(process_name) + .to_ascii_lowercase() +} + +fn alnum_lower(s: &str) -> String { + s.chars() + .filter(|c| c.is_ascii_alphanumeric()) + .map(|c| c.to_ascii_lowercase()) + .collect() +} + +fn strip_tails(mut slug: String, tails: &[&str]) -> String { + let mut changed = true; + while changed { + changed = false; + for tail in tails { + if slug.ends_with(tail) && slug.len() - tail.len() >= MIN_SLUG { + slug.truncate(slug.len() - tail.len()); + changed = true; + break; + } + } + } + slug +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::identity::steam_library::SteamGame; + use std::path::PathBuf; + + fn crimson_steam() -> SteamLibraryIndex { + let mut steam = SteamLibraryIndex::default(); + steam.games.insert( + 3321460, + SteamGame { + app_id: 3321460, + title: "Crimson Desert Enhanced".into(), + install_path: PathBuf::from(r"D:\SteamLibrary\steamapps\common\Crimson Desert"), + }, + ); + steam + } + + fn proc(name: &str, exe: &str) -> ProcessSnapshot { + ProcessSnapshot { + pid: 1, + name: name.into(), + exe_path: Some(exe.into()), + cmdline: None, + } + } + + #[test] + fn crimson_desert_enhanced_matches_exe() { + assert!(exe_looks_like_title( + "Crimson Desert Enhanced", + "CrimsonDesert.exe" + )); + assert!(exe_looks_like_title("Dota 2", "dota2.exe")); + assert!(exe_looks_like_title("Hades", r"D:\Games\Hades\Hades.exe")); + } + + #[test] + fn mismatched_and_generic_exes_do_not_match() { + assert!(!exe_looks_like_title("Apex Legends", "r5apex.exe")); + assert!(!exe_looks_like_title("Some Game Title", "game.exe")); + assert!(!exe_looks_like_title( + "Crimson Desert", + "UnrealCEFSubProcess.exe" + )); + assert!(!exe_looks_like_title("IT", "it.exe")); + } + + #[test] + fn title_like_path_hit_is_medium() { + let steam = crimson_steam(); + let proc = proc( + "CrimsonDesert.exe", + r"D:\SteamLibrary\steamapps\common\Crimson Desert\bin64\CrimsonDesert.exe", + ); + let mut identities = vec![steam.match_path(&proc).unwrap()]; + assert_eq!(identities[0].confidence, Confidence::Low); + let mut pending = Vec::new(); + finalize_steam_path_hits(&steam, &mut identities, &mut pending, &[proc]); + assert!(pending.is_empty()); + assert_eq!(identities.len(), 1); + assert_eq!(identities[0].confidence, Confidence::Medium); + assert_eq!(identities[0].steam_app_id, Some(3321460)); + assert_eq!(identities[0].exe.as_deref(), Some("CrimsonDesert.exe")); + } + + #[test] + fn prefers_title_like_exe_over_launcher() { + let steam = crimson_steam(); + let launcher = proc( + "PALauncher.exe", + r"D:\SteamLibrary\steamapps\common\Crimson Desert\PALauncher.exe", + ); + let game = proc( + "CrimsonDesert.exe", + r"D:\SteamLibrary\steamapps\common\Crimson Desert\bin64\CrimsonDesert.exe", + ); + let mut identities = vec![steam.match_path(&launcher).unwrap()]; + assert_eq!(identities[0].exe.as_deref(), Some("PALauncher.exe")); + let mut pending = Vec::new(); + finalize_steam_path_hits(&steam, &mut identities, &mut pending, &[launcher, game]); + assert!(pending.is_empty()); + assert_eq!(identities[0].confidence, Confidence::Medium); + assert_eq!(identities[0].exe.as_deref(), Some("CrimsonDesert.exe")); + } + + #[test] + fn mismatched_path_hit_goes_pending() { + let mut steam = SteamLibraryIndex::default(); + steam.games.insert( + 1172470, + SteamGame { + app_id: 1172470, + title: "Apex Legends".into(), + install_path: PathBuf::from(r"D:\Steam\steamapps\common\Apex Legends"), + }, + ); + let proc = proc( + "r5apex.exe", + r"D:\Steam\steamapps\common\Apex Legends\r5apex.exe", + ); + let mut identities = vec![steam.match_path(&proc).unwrap()]; + let mut pending = Vec::new(); + finalize_steam_path_hits(&steam, &mut identities, &mut pending, &[proc]); + assert!(identities.is_empty()); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].suggested_title, "Apex Legends"); + assert_eq!(pending[0].identity_id.as_deref(), Some("steam:1172470")); + } + + #[test] + fn unindexed_steamapps_detects_missing_library_row() { + let steam = SteamLibraryIndex::default(); + let proc = proc( + "CrimsonDesert.exe", + r"D:\SteamLibrary\steamapps\common\Crimson Desert\bin64\CrimsonDesert.exe", + ); + assert!(is_unindexed_steamapps_process(&proc, &steam)); + assert!(!is_unindexed_steamapps_process(&proc, &crimson_steam())); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f895530..f9d30c0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -47,12 +47,13 @@ async fn save_config( ) -> Result { let (prev_url, prev_channel) = { let current = state.config.read().await; - ( - current.resolved_detectable_url(), - current.update_channel, - ) + (current.resolved_detectable_url(), current.update_channel) }; - if config.base_url.as_ref().is_some_and(|u| !u.trim().is_empty()) { + if config + .base_url + .as_ref() + .is_some_and(|u| !u.trim().is_empty()) + { auth::detect_and_apply(&mut config).await?; } else { config.api_root = None; @@ -127,8 +128,7 @@ async fn start_login( *attempt.0.lock().await = Some(login_attempt); let app_state = Arc::clone(&*state); - let guard = - oauth_loopback::start_listener(app.clone(), attempt.0.clone(), app_state).await?; + let guard = oauth_loopback::start_listener(app.clone(), attempt.0.clone(), app_state).await?; *listener.0.lock().await = Some(guard); open::that(&url).map_err(|e| e.to_string())?; @@ -222,10 +222,7 @@ async fn ignore_game( } #[tauri::command] -async fn unignore_game( - state: State<'_, Arc>, - identity_id: String, -) -> Result<(), String> { +async fn unignore_game(state: State<'_, Arc>, identity_id: String) -> Result<(), String> { state.unignore_game(identity_id).await } @@ -379,6 +376,14 @@ pub fn run() { } }); + let steam_state = app_state.clone(); + tauri::async_runtime::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + steam_state.refresh_steam_library().await; + } + }); + let update_state = app_state.clone(); let update_handle = app.handle().clone(); tauri::async_runtime::spawn(async move { diff --git a/src-tauri/src/live_session.rs b/src-tauri/src/live_session.rs index 3b8e553..b91efb9 100644 --- a/src-tauri/src/live_session.rs +++ b/src-tauri/src/live_session.rs @@ -50,9 +50,7 @@ impl LiveSession { /// Apply a detect sample. Never waits on I/O. pub fn apply(&mut self, sample: &DetectSample) { let now = sample.observed_at; - let gap = self - .last_tick_at - .map(|t| now.signed_duration_since(t)); + let gap = self.last_tick_at.map(|t| now.signed_duration_since(t)); self.last_tick_at = Some(now); if gap.is_some_and(|g| g > SLEEP_SPLIT) && self.identity.is_some() { @@ -61,7 +59,11 @@ impl LiveSession { match &sample.primary { Some(primary) => { - if self.identity.as_ref().is_some_and(|cur| cur.id == primary.id) { + if self + .identity + .as_ref() + .is_some_and(|cur| cur.id == primary.id) + { self.last_seen_at = Some(now); self.identity = Some(primary.clone()); } else { @@ -196,7 +198,10 @@ mod tests { let mut live = LiveSession::default(); let t0 = Utc::now(); live.apply(&sample_at(t0, Some(game("steam:1", "A")))); - live.apply(&sample_at(t0 + Duration::seconds(3), Some(game("steam:2", "B")))); + live.apply(&sample_at( + t0 + Duration::seconds(3), + Some(game("steam:2", "B")), + )); assert_eq!(live.pending_ends.len(), 1); assert_eq!(live.pending_ends[0].identity.id, "steam:1"); assert_eq!(live.identity_id(), Some("steam:2")); diff --git a/src-tauri/src/oauth_loopback.rs b/src-tauri/src/oauth_loopback.rs index cc30bc8..8bb063c 100644 --- a/src-tauri/src/oauth_loopback.rs +++ b/src-tauri/src/oauth_loopback.rs @@ -57,11 +57,9 @@ pub async fn start_listener( tauri::async_runtime::spawn(async move { while !cancel_task.load(Ordering::SeqCst) { - let accept = tokio::time::timeout( - std::time::Duration::from_millis(400), - listener.accept(), - ) - .await; + let accept = + tokio::time::timeout(std::time::Duration::from_millis(400), listener.accept()) + .await; let Ok(Ok((mut socket, _))) = accept else { continue; }; @@ -83,13 +81,7 @@ pub async fn start_listener( if let Ok((code, state)) = auth::parse_callback_code(&format!("http://127.0.0.1{path}")) { - match finish_code_exchange( - &attempt_slot, - &config_slot, - &code, - &state, - ) - .await + match finish_code_exchange(&attempt_slot, &config_slot, &code, &state).await { Ok(()) => { let _ = app.emit( @@ -192,7 +184,9 @@ async fn finish_code_exchange( ) -> Result<(), String> { let attempt = { let guard = attempt_slot.lock().await; - guard.clone().ok_or_else(|| "no login attempt".to_string())? + guard + .clone() + .ok_or_else(|| "no login attempt".to_string())? }; let cfg = config_slot.config.read().await.clone(); auth::exchange_authorization_code(&cfg, &attempt, code, state).await?; @@ -252,9 +246,8 @@ const MARK_SVG: &str = r##" String { - let mut html = String::from( - "\n", - ); + let mut html = + String::from("<!DOCTYPE html>\n<html lang=\"en\"><head><meta charset=\"utf-8\"/><title>"); html.push_str(title); html.push_str("