Files
umutray/src/detect.rs
T
funman300 2f4f1c64d2 refactor: idiomatic Rust cleanup and quality improvements
- Replace .map().unwrap_or(false) with .is_some_and()/.is_ok_and()
- Use path.display() instead of {:?} for user-facing messages
- Replace Option<Option<Vec<String>>> with GamescopeUpdate enum
- Replace manual parent-walking loops with .ancestors() iterators
- Simplify kill()/kill_all() signatures to return () instead of Result
- Use tokio::task::spawn_blocking instead of hand-rolled thread+oneshot
- Read /proc/self/status for UID instead of spawning id subprocess
- Build Exec= line directly in render_desktop instead of string-replace
- Bump PKGBUILD pkgrel to 6
2026-04-19 11:29:42 -07:00

618 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::config::{Config, Launcher};
use anyhow::Result;
use owo_colors::OwoColorize;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex};
#[derive(Debug, Clone)]
pub struct DetectHit {
pub display: String,
pub prefix: PathBuf,
/// True if this launcher is already in config with this exact prefix.
pub configured: bool,
}
/// Scan default Wine prefix locations and return hits against known presets.
pub fn scan_for_gui(config: &Config) -> Vec<DetectHit> {
let roots: Vec<PathBuf> = default_roots().into_iter().filter(|r| r.is_dir()).collect();
let prefixes = scan_prefixes(&roots);
let mut hits = Vec::new();
for preset in crate::config::presets() {
for prefix in &prefixes {
if prefix.join("drive_c").join(&preset.exe_path).exists() {
let configured = config
.find(&preset.name)
.is_some_and(|l| l.prefix_dir == *prefix);
hits.push(DetectHit {
display: preset.display.clone(),
prefix: prefix.clone(),
configured,
});
break;
}
}
}
hits
}
/// Directories inside drive_c that contain Windows system files, not games.
const SYSTEM_DIRS: &[&str] = &[
"windows",
"users",
"programdata",
"internet explorer",
"windows media player",
"windowspowershell",
"microsoft.net",
"common files",
"microsoft",
"windows nt",
"windowsapps",
];
/// Directory names that are pure launcher infrastructure — no game executables
/// are ever installed here. Do NOT add parent dirs like "Epic Games" or
/// "Ubisoft" that also contain game subdirectories; use SKIP_EXES instead.
const SKIP_DIRS: &[&str] = &[
"battle.net", // Battle.net launcher dir; its games live elsewhere
"ea desktop", // EA Desktop launcher subfolder only
"gog galaxy", // GOG Galaxy launcher; games are normally in GOG Games/
"wine",
"mono",
"gecko",
];
/// Exe filename patterns that are launcher tools, not games.
const SKIP_EXES: &[&str] = &[
"uninstall",
"uninst",
"crash",
"error",
"reporter",
"update",
"updater",
"setup",
"installer",
"helper",
"agent",
"service",
"repair",
"diagnostic",
"redist",
"vcredist",
"dxsetup",
"dxwebsetup",
"dotnetfx",
"vc_redist",
"bootstrapper",
"launcher", // launcher tools, not games
"battlenet",
"blizzard",
"eadesktop",
"eabackgroundservice",
"ealink",
"epicgameslauncher",
"epicwebhelper",
"ubisoftconnect",
"ubisoftgamelauncher",
"upc",
"galaxyclient",
"galaxycommunication",
"galaxypeer",
"socialclubhelper",
"subprocess",
"cefprocess",
"webhelper",
"webview",
"7za",
"aria2c",
];
// --- Name resolution ---
/// Cache of absolute exe path → resolved display name (populated lazily).
static NAME_CACHE: LazyLock<Mutex<HashMap<PathBuf, String>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// Install-path → display title, built once from Legendary / Heroic metadata.
static STORE_TITLES: LazyLock<HashMap<PathBuf, String>> =
LazyLock::new(build_store_titles);
/// Read every `installed.json` that Legendary or Heroic may have written and
/// return a map of absolute install directory → game title.
fn build_store_titles() -> HashMap<PathBuf, String> {
let mut map = HashMap::new();
let Ok(home) = std::env::var("HOME") else { return map };
let home = PathBuf::from(home);
// Legendary standalone + Heroic's bundled copy (native and Flatpak).
let legendary_candidates = [
home.join(".config/legendary/installed.json"),
home.join(".config/heroic/legendaryConfig/legendary/installed.json"),
home.join(".var/app/com.heroicgameslauncher.hgl/config/legendary/installed.json"),
home.join(".var/app/com.heroicgameslauncher.hgl/config/heroic/legendaryConfig/legendary/installed.json"),
];
for path in &legendary_candidates {
if let Ok(text) = std::fs::read_to_string(path) {
parse_legendary_installed(&text, &mut map);
}
}
// Heroic GOG store (native and Flatpak).
let gog_candidates = [
home.join(".config/heroic/gog_store/installed.json"),
home.join(".var/app/com.heroicgameslauncher.hgl/config/heroic/gog_store/installed.json"),
];
for path in &gog_candidates {
if let Ok(text) = std::fs::read_to_string(path) {
parse_heroic_gog_installed(&text, &mut map);
}
}
map
}
/// Legendary `installed.json`: `{ "AppName": { "install_path": "...", "title": "..." } }`
fn parse_legendary_installed(text: &str, map: &mut HashMap<PathBuf, String>) {
let Ok(json) = serde_json::from_str::<serde_json::Value>(text) else { return };
let Some(obj) = json.as_object() else { return };
for entry in obj.values() {
let Some(path) = entry.get("install_path").and_then(|v| v.as_str()) else { continue };
let Some(title) = entry.get("title").and_then(|v| v.as_str()) else { continue };
if !title.is_empty() {
map.insert(PathBuf::from(path), title.to_string());
}
}
}
/// Heroic GOG `installed.json`: `{ "installed": [ { "install_path": "...", "title": "..." } ] }`
fn parse_heroic_gog_installed(text: &str, map: &mut HashMap<PathBuf, String>) {
let Ok(json) = serde_json::from_str::<serde_json::Value>(text) else { return };
let Some(arr) = json.get("installed").and_then(|v| v.as_array()) else { return };
for entry in arr {
let Some(path) = entry.get("install_path").and_then(|v| v.as_str()) else { continue };
let Some(title) = entry.get("title").and_then(|v| v.as_str()) else { continue };
if !title.is_empty() {
map.insert(PathBuf::from(path), title.to_string());
}
}
}
/// Walk up from `exe_path` checking each ancestor against `STORE_TITLES`.
fn store_title(exe_path: &Path) -> Option<String> {
exe_path.ancestors().skip(1).find_map(|d| STORE_TITLES.get(d).cloned())
}
/// Scan a launcher's Wine prefix for installed game executables.
/// Returns (display_name, path_relative_to_drive_c) pairs, sorted alphabetically,
/// excluding the launcher's own exe and any already-configured games.
pub fn scan_games_in_prefix(launcher: &Launcher) -> Vec<(String, String)> {
let drive_c = launcher.prefix_dir.join("drive_c");
if !drive_c.exists() {
return vec![];
}
let search_dirs = [
drive_c.join("Program Files"),
drive_c.join("Program Files (x86)"),
];
let already: HashSet<String> = launcher
.games
.iter()
.map(|g| g.exe_path.to_string_lossy().to_lowercase())
.collect();
let launcher_exe = launcher.exe_path.to_string_lossy().to_lowercase();
let mut results = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
for dir in &search_dirs {
scan_exe_dir(dir, &drive_c, &launcher_exe, &already, &mut results, &mut seen, 0);
}
results.sort_by(|a, b| a.0.cmp(&b.0));
results
}
fn scan_exe_dir(
dir: &Path,
drive_c: &Path,
launcher_exe: &str,
already: &HashSet<String>,
out: &mut Vec<(String, String)>,
seen: &mut HashSet<String>,
depth: u32,
) {
if depth > 4 {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else { return };
for entry in entries.flatten() {
let path = entry.path();
let lower = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_lowercase();
if SYSTEM_DIRS.iter().any(|s| lower.starts_with(s)) {
continue;
}
if SKIP_DIRS.iter().any(|s| lower == *s) {
continue;
}
if path.is_dir() {
scan_exe_dir(&path, drive_c, launcher_exe, already, out, seen, depth + 1);
} else if path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("exe"))
{
let Ok(rel) = path.strip_prefix(drive_c) else { continue };
let rel_str = rel.to_string_lossy().to_string();
let rel_lower = rel_str.to_lowercase();
if rel_lower == launcher_exe || already.contains(&rel_lower) {
continue;
}
// Skip launcher tools, updaters, and non-game executables
let stem_lower = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_lowercase();
if SKIP_EXES.iter().any(|s| stem_lower.contains(s)) {
continue;
}
if !seen.insert(rel_lower) {
continue;
}
let display = resolve_game_name(&path, None);
out.push((display, rel_str));
}
}
}
/// Resolve a human-readable display name for a game exe.
///
/// Resolution pipeline (first hit wins):
/// 1. Explicit name — if `explicit_name` is `Some`, return it immediately.
/// 2. Legendary / Heroic `installed.json` — maps install path → title,
/// covers both Epic (via Legendary) and GOG (via Heroic's GOG store).
/// 3. Manifest walk — walks up from the exe looking for GOG `.info` and
/// Epic `.egstore/*.item` JSON files at the game's installation root.
/// 4. Launcher path — reads the game name from well-known directory
/// structures laid down by the launcher (e.g. `Epic Games/<Name>/`).
/// 5. Nearest non-generic parent directory name, or raw exe stem.
/// No name generation — if the directory name is unknown, it is used
/// as-is rather than being fabricated from the exe filename.
///
/// Results from stages 25 are cached by path after first computation.
pub fn resolve_game_name(exe_path: &Path, explicit_name: Option<&str>) -> String {
if let Some(name) = explicit_name {
return name.to_string();
}
{
let cache = NAME_CACHE.lock().unwrap_or_else(|e| e.into_inner());
if let Some(cached) = cache.get(exe_path) {
return cached.clone();
}
}
let name = resolve_uncached(exe_path);
NAME_CACHE
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(exe_path.to_path_buf(), name.clone());
name
}
fn resolve_uncached(exe_path: &Path) -> String {
// Stage 2 Legendary / Heroic installed.json (install path → title)
if let Some(name) = store_title(exe_path) {
return name;
}
// Stage 3 manifest files at the game's installation root
if let Some(name) = read_manifest_name(exe_path) {
return name;
}
// Stage 4 game name from known launcher directory structures
if let Some(name) = name_from_launcher_path(exe_path) {
return name;
}
// Stage 5 nearest non-generic parent directory, or raw exe stem.
// No name generation: if we don't know, we say so honestly.
nearest_dir_name(exe_path)
}
/// Walk up from `exe_path` looking for platform manifest files that record the
/// game's display name. Manifests live at the game's installation *root*, which
/// can be several directories above the actual exe.
///
/// Supported formats:
/// - GOG: `goggame-<id>.info` → `{ "gameName": "..." }`
/// - Epic: `.egstore/<id>.item` → `{ "DisplayName": "..." }`
fn read_manifest_name(exe_path: &Path) -> Option<String> {
for d in exe_path.ancestors().skip(1) {
let dirname = d.file_name().and_then(|n| n.to_str()).unwrap_or("").to_lowercase();
// Stop once we reach drive_c root or the Program Files tier — manifests
// are never above the game's installation folder.
if dirname == "drive_c" || dirname.starts_with("program files") {
break;
}
if let Some(name) = read_gog_manifest(d).or_else(|| read_epic_manifest(d)) {
return Some(name);
}
}
None
}
fn read_gog_manifest(dir: &Path) -> Option<String> {
for entry in std::fs::read_dir(dir).ok()?.flatten() {
let fname = entry.file_name();
let fname = fname.to_string_lossy();
if fname.starts_with("goggame-") && fname.ends_with(".info") {
let text = std::fs::read_to_string(entry.path()).ok()?;
let json: serde_json::Value = serde_json::from_str(&text).ok()?;
let t = json.get("gameName")?.as_str()?.trim();
if !t.is_empty() {
return Some(t.to_string());
}
}
}
None
}
fn read_epic_manifest(dir: &Path) -> Option<String> {
let egstore = dir.join(".egstore");
if !egstore.is_dir() {
return None;
}
for entry in std::fs::read_dir(&egstore).ok()?.flatten() {
if entry.path().extension().and_then(|e| e.to_str()) == Some("item") {
let text = std::fs::read_to_string(entry.path()).ok()?;
let json: serde_json::Value = serde_json::from_str(&text).ok()?;
let t = json.get("DisplayName")?.as_str()?.trim();
if !t.is_empty() {
return Some(t.to_string());
}
}
}
None
}
/// Extract a game name from well-known launcher directory conventions.
///
/// Launchers install each game into a named subdirectory of their own folder.
/// That subdirectory name *is* the display name:
/// - Epic: `…/Epic Games/<GameName>/…`
/// - GOG: `…/GOG Games/<GameName>/…`
/// - Steam: `…/steamapps/common/<GameName>/…`
/// - Rockstar:`…/Rockstar Games/<GameName>/…`
/// - EA: `…/EA Games/<GameName>/…`
/// - Ubisoft: `…/Ubisoft Game Launcher/games/<GameName>/…`
fn name_from_launcher_path(exe_path: &Path) -> Option<String> {
let comps: Vec<&std::ffi::OsStr> = exe_path.components().map(|c| c.as_os_str()).collect();
for (i, comp) in comps.iter().enumerate() {
let lower = comp.to_str().unwrap_or("").to_lowercase();
match lower.as_str() {
"epic games" | "gog games" | "rockstar games" | "ea games" => {
return comps.get(i + 1).and_then(|c| c.to_str()).map(str::to_string);
}
// Ubisoft: …/Ubisoft Game Launcher/games/<GameName>/…
"ubisoft game launcher" => {
return comps.get(i + 2).and_then(|c| c.to_str()).map(str::to_string);
}
"common"
if i > 0
&& comps[i - 1].to_str().unwrap_or("").to_lowercase() == "steamapps" =>
{
return comps.get(i + 1).and_then(|c| c.to_str()).map(str::to_string);
}
_ => {}
}
}
None
}
fn nearest_dir_name(path: &Path) -> String {
const GENERIC_DIRS: &[&str] = &[
"bin", "binaries", "x64", "x86", "win64", "win32", "retail",
"shipping", "game", "runtime", "_retail_", "_commonredist",
"launcher", "engine", "client",
];
for d in path.ancestors().skip(1) {
let name = d.file_name().and_then(|n| n.to_str()).unwrap_or("");
let lower = name.to_lowercase();
if !name.is_empty()
&& !GENERIC_DIRS.iter().any(|g| lower == *g)
&& !lower.starts_with("program files")
{
return name.to_string();
}
}
// Nothing useful in the path — return the exe stem as-is.
path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Unknown")
.to_string()
}
const MAX_DEPTH: u32 = 3;
pub fn run(config: &Config, extra_dirs: &[PathBuf], apply: bool) -> Result<()> {
let mut roots = default_roots();
roots.extend(extra_dirs.iter().cloned());
roots.sort();
roots.dedup();
let existing: Vec<PathBuf> = roots.into_iter().filter(|r| r.is_dir()).collect();
let prefixes = scan_prefixes(&existing);
println!(
"Scanned {} root{} → found {} prefix{}.\n",
existing.len(),
if existing.len() == 1 { "" } else { "s" },
prefixes.len(),
if prefixes.len() == 1 { "" } else { "es" },
);
let by_launcher = match_launchers(config, &prefixes);
if apply {
apply_findings(config, &by_launcher)?;
} else {
print_findings(config, &by_launcher);
}
Ok(())
}
fn default_roots() -> Vec<PathBuf> {
let Ok(home) = std::env::var("HOME").map(PathBuf::from) else {
return Vec::new();
};
vec![
home.join("Games"),
home.join(".wine"),
home.join(".local/share/lutris/runners/wine"),
home.join(".local/share/bottles/bottles"),
home.join(".var/app/com.usebottles.bottles/data/bottles/bottles"),
home.join("Games/Heroic/Prefixes/default"),
home.join(".var/app/com.heroicgameslauncher.hgl/config/heroic/Prefixes/default"),
]
}
fn scan_prefixes(roots: &[PathBuf]) -> Vec<PathBuf> {
let mut out = Vec::new();
for root in roots {
collect_prefixes(root, 0, &mut out);
}
out.sort();
out.dedup();
out
}
fn collect_prefixes(dir: &Path, depth: u32, out: &mut Vec<PathBuf>) {
if dir.join("drive_c").is_dir() {
out.push(dir.to_path_buf());
return;
}
// Proton / umu layout: <gameid>/pfx/drive_c
if dir.join("pfx/drive_c").is_dir() {
out.push(dir.join("pfx"));
return;
}
if depth >= MAX_DEPTH {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
if entry.file_type().is_ok_and(|t| t.is_dir()) {
collect_prefixes(&entry.path(), depth + 1, out);
}
}
}
fn match_launchers(config: &Config, prefixes: &[PathBuf]) -> HashMap<String, Vec<PathBuf>> {
let mut by_launcher: HashMap<String, Vec<PathBuf>> = HashMap::new();
for l in &config.launchers {
for prefix in prefixes {
if prefix.join("drive_c").join(&l.exe_path).exists() {
by_launcher
.entry(l.name.clone())
.or_default()
.push(prefix.clone());
}
}
}
by_launcher
}
fn print_findings(config: &Config, by_launcher: &HashMap<String, Vec<PathBuf>>) {
let mut any_divergent = false;
for l in &config.launchers {
match by_launcher.get(&l.name) {
None => {
println!(" · {:12} not found", l.name);
}
Some(matches) if matches.len() > 1 => {
println!(" {} {:12} multiple prefixes:", "".yellow(), l.name);
for p in matches {
println!(" {}", p.display());
}
}
Some(matches) => {
let detected = &matches[0];
if *detected == l.prefix_dir {
println!(" {} {:12} {}", "".green().bold(), l.name, detected.display());
} else {
any_divergent = true;
println!(
" {} {:12} {} (was {})",
"".cyan(),
l.name,
detected.display(),
l.prefix_dir.display()
);
}
}
}
}
if any_divergent {
println!("\nRerun with --apply to update config.");
}
}
fn apply_findings(config: &Config, by_launcher: &HashMap<String, Vec<PathBuf>>) -> Result<()> {
let mut c = config.clone();
let mut updated = 0;
let mut ambiguous = 0;
for l in c.launchers.iter_mut() {
let Some(matches) = by_launcher.get(&l.name) else {
continue;
};
if matches.len() > 1 {
ambiguous += 1;
println!(" {} {:12} ambiguous — update via `config edit`", "".yellow(), l.name);
continue;
}
let detected = &matches[0];
if *detected == l.prefix_dir {
println!(" {} {:12} unchanged", "".green().bold(), l.name);
continue;
}
println!(
" {} {:12} {}{}",
"".green().bold(),
l.name,
l.prefix_dir.display(),
detected.display()
);
l.prefix_dir = detected.clone();
updated += 1;
}
if updated > 0 {
c.save()?;
println!(
"\nUpdated {updated} launcher{}.",
if updated == 1 { "" } else { "s" }
);
} else {
println!("\nNothing to update.");
}
if ambiguous > 0 {
println!(
"{ambiguous} launcher{} skipped (multiple matches).",
if ambiguous == 1 { "" } else { "s" }
);
}
Ok(())
}