diff --git a/src/detect.rs b/src/detect.rs index 95c4958..a7686b6 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -1,7 +1,7 @@ -use crate::config::Config; +use crate::config::{Config, Launcher}; use anyhow::Result; use owo_colors::OwoColorize; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; #[derive(Debug, Clone)] @@ -36,6 +36,99 @@ pub fn scan_for_gui(config: &Config) -> Vec { 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", +]; + +/// 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 = 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 = 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, + out: &mut Vec<(String, String)>, + seen: &mut HashSet, + 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 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()) + .map(|e| e.eq_ignore_ascii_case("exe")) + .unwrap_or(false) + { + 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; + } + if !seen.insert(rel_lower) { + continue; + } + let display = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Unknown") + .to_string(); + out.push((display, rel_str)); + } + } +} + const MAX_DEPTH: u32 = 3; pub fn run(config: &Config, extra_dirs: &[PathBuf], apply: bool) -> Result<()> { diff --git a/src/gui.rs b/src/gui.rs index 35a4117..ebb00fb 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -1,4 +1,4 @@ -use crate::{config::Config, detect, diagnose, launcher, proton, service, util::{async_blocking, pick_folder}}; +use crate::{config::Config, detect, diagnose, launcher, proton, service, util::{async_blocking, pick_file, pick_folder}}; use anyhow::Result; use iced::widget::{ button, column, container, mouse_area, pick_list, row, scrollable, text, text_input, Column, @@ -44,6 +44,11 @@ pub enum Message { AddGameExeChanged(String, String), AddGameConfirm(String), RemoveGame(String, String), + ScanGames(String), + ScanGamesDone(String, Vec<(String, String)>), + AddScannedGame(String, String, String), + BrowseGameExe(String), + BrowseGameExeDone(String, Option), // Settings ShowSettings, HideSettings, @@ -72,6 +77,8 @@ struct Dashboard { diagnose_result: Option<(String, Vec)>, // Games adding_game: HashMap, + scan_results: HashMap>, + scan_busy: std::collections::HashSet, // Settings settings_open: bool, settings_proton_version: String, @@ -102,6 +109,8 @@ impl Dashboard { diagnose_open: None, diagnose_result: None, adding_game: HashMap::new(), + scan_results: HashMap::new(), + scan_busy: std::collections::HashSet::new(), settings_open: false, settings_proton_version, settings_compat_dir, @@ -385,6 +394,79 @@ fn update(state: &mut Dashboard, msg: Message) -> Task { } Task::none() } + Message::ScanGames(lname) => { + if let Some(l) = state.config.find(&lname) { + let l = l.clone(); + let lname2 = lname.clone(); + state.scan_busy.insert(lname); + return Task::perform( + async_blocking(move || detect::scan_games_in_prefix(&l)), + move |hits| Message::ScanGamesDone(lname2.clone(), hits), + ); + } + Task::none() + } + Message::ScanGamesDone(lname, hits) => { + state.scan_busy.remove(&lname); + state.scan_results.insert(lname, hits); + Task::none() + } + Message::AddScannedGame(lname, display, exe) => { + state.last_error = None; + match state.config.add_game( + &lname, + display.to_lowercase().replace(' ', "_"), + Some(display), + PathBuf::from(&exe), + false, + false, + None, + ) { + Ok(()) => { + // Remove this exe from scan results so it disappears + if let Some(results) = state.scan_results.get_mut(&lname) { + results.retain(|(_, e)| e != &exe); + } + } + Err(e) => state.last_error = Some(format!("Add game failed: {e}")), + } + Task::none() + } + Message::BrowseGameExe(lname) => { + let start = state + .config + .find(&lname) + .map(|l| l.prefix_dir.join("drive_c").to_string_lossy().into_owned()) + .unwrap_or_default(); + let lname2 = lname.clone(); + Task::perform( + async_blocking(move || pick_file("Select game executable", &start)), + move |res| Message::BrowseGameExeDone(lname2.clone(), res), + ) + } + Message::BrowseGameExeDone(lname, path_opt) => { + if let (Some(path_str), Some(l)) = + (path_opt, state.config.find(&lname)) + { + let drive_c = l.prefix_dir.join("drive_c"); + let abs = PathBuf::from(&path_str); + let rel = abs + .strip_prefix(&drive_c) + .map(|r| r.to_string_lossy().to_string()) + .unwrap_or(path_str); + let display = abs + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("game") + .to_string(); + let entry = state.adding_game.entry(lname).or_default(); + if entry.0.is_empty() { + entry.0 = display; + } + entry.1 = rel; + } + Task::none() + } // ── Settings ─────────────────────────────────────────────────────── Message::ShowSettings => { state.settings_open = true; @@ -581,7 +663,14 @@ fn view(state: &Dashboard) -> Element<'_, Message> { } else if menu_open { context_menu_card(l) } else { - launcher_card(l, installed, running, state.adding_game.get(&l.name)) + launcher_card( + l, + installed, + running, + state.adding_game.get(&l.name), + state.scan_results.get(&l.name), + state.scan_busy.contains(&l.name), + ) }; let card = mouse_area( @@ -683,6 +772,8 @@ fn launcher_card<'a>( installed: bool, running: bool, add_form: Option<&'a (String, String)>, + scan_results: Option<&'a Vec<(String, String)>>, + scan_busy: bool, ) -> Element<'a, Message> { let status_label = if running { "● Running" @@ -735,6 +826,82 @@ fn launcher_card<'a>( let mut rows: Vec> = vec![header.into()]; + if l.games.is_empty() && scan_results.map(|r| r.is_empty()).unwrap_or(true) && !scan_busy { + let lname = l.name.clone(); + let lname2 = l.name.clone(); + let scan_btn = button(text("Scan for games").size(11)) + .on_press(Message::ScanGames(lname)) + .style(button::secondary); + let browse_btn = button(text("Browse exe…").size(11)) + .on_press(Message::BrowseGameExe(lname2)) + .style(button::secondary); + rows.push( + row![ + text(" ").size(12), + text("No games added yet.").size(12).style(|_: &Theme| text::Style { + color: Some(Color::from_rgb(0.55, 0.55, 0.55)), + }), + iced::widget::horizontal_space(), + scan_btn, + browse_btn, + ] + .align_y(Alignment::Center) + .spacing(6) + .into(), + ); + } + + if scan_busy { + rows.push(text(" Scanning…").size(12).into()); + } + + // Scan results (exes found in prefix, not yet added) + if let Some(results) = scan_results { + for (display, exe) in results { + let lname = l.name.clone(); + let d = display.clone(); + let e = exe.clone(); + let add_btn = button(text("+").size(11)) + .on_press(Message::AddScannedGame(lname, d, e)) + .style(button::primary); + rows.push( + row![ + text(" ").size(12), + text(format!("⊕ {display}")).size(12).style(|_: &Theme| text::Style { + color: Some(Color::from_rgb(0.65, 0.65, 0.65)), + }), + text(format!(" {exe}")).size(10).style(|_: &Theme| text::Style { + color: Some(Color::from_rgb(0.45, 0.45, 0.45)), + }), + iced::widget::horizontal_space(), + add_btn, + ] + .align_y(Alignment::Center) + .spacing(4) + .into(), + ); + } + if !results.is_empty() { + rows.push( + text(" Found in Wine prefix — click + to add") + .size(10) + .style(|_: &Theme| text::Style { + color: Some(Color::from_rgb(0.45, 0.45, 0.45)), + }) + .into(), + ); + } else if !scan_busy { + rows.push( + text(" No new executables found in the Wine prefix.") + .size(11) + .style(|_: &Theme| text::Style { + color: Some(Color::from_rgb(0.45, 0.45, 0.45)), + }) + .into(), + ); + } + } + for g in &l.games { let lname = l.name.clone(); let gname = g.name.clone(); @@ -782,6 +949,7 @@ fn launcher_card<'a>( let lname2 = l.name.clone(); let lname3 = l.name.clone(); let lname4 = l.name.clone(); + let lname5 = l.name.clone(); let name_input = text_input("Game name", name_val) .on_input(move |v| Message::AddGameNameChanged(lname.clone(), v)) .padding(5) @@ -792,6 +960,9 @@ fn launcher_card<'a>( .padding(5) .size(12) .width(Length::FillPortion(3)); + let browse_btn = button(text("Browse…").size(11)) + .on_press(Message::BrowseGameExe(lname5)) + .style(button::secondary); let can_confirm = !name_val.trim().is_empty() && !exe_val.trim().is_empty(); let confirm_btn = button(text("Add").size(11)) .on_press_maybe(can_confirm.then_some(Message::AddGameConfirm(lname3))) @@ -800,7 +971,7 @@ fn launcher_card<'a>( .on_press(Message::AddGamePressed(lname4)) .style(button::secondary); rows.push( - row![name_input, exe_input, confirm_btn, cancel_btn,] + row![name_input, exe_input, browse_btn, confirm_btn, cancel_btn] .align_y(Alignment::Center) .spacing(6) .into(), diff --git a/src/util.rs b/src/util.rs index 518e8a9..3bd7433 100644 --- a/src/util.rs +++ b/src/util.rs @@ -34,3 +34,30 @@ pub fn pick_folder(title: &str) -> Option { } None } + +/// Open a native file picker dialog starting in `start_dir`, or None if +/// the user cancelled. Tries zenity then kdialog. +pub fn pick_file(title: &str, start_dir: &str) -> Option { + // zenity uses --filename with a trailing slash to open a directory + let start_slash = format!("{}/", start_dir.trim_end_matches('/')); + let zenity_args = vec![ + "--file-selection", + "--title", + title, + "--filename", + &start_slash, + ]; + let kdialog_args = vec!["--getopenfilename", start_dir, "--title", title]; + for (cmd, args) in [("zenity", zenity_args.as_slice()), ("kdialog", kdialog_args.as_slice())] { + let Ok(out) = std::process::Command::new(cmd).args(args).output() else { + continue; + }; + if out.status.success() { + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if !s.is_empty() { + return Some(s); + } + } + } + None +}