feat(gui): auto-detect games in Wine prefix and browse for exe
- detect::scan_games_in_prefix() walks drive_c/Program Files and
Program Files (x86) up to depth 4, skipping Windows system dirs,
the launcher's own exe, and already-configured games
- Empty game list now shows "No games added yet." with two actions:
· Scan for games — async scan of the Wine prefix, results appear
as a clickable list with + buttons to add each found exe
· Browse exe… — native file picker (zenity/kdialog) opening in
drive_c/, computes the relative path automatically
- Add-game form gains a Browse… button to pick exe from the prefix
- util::pick_file() added alongside pick_folder()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+95
-2
@@ -1,7 +1,7 @@
|
|||||||
use crate::config::Config;
|
use crate::config::{Config, Launcher};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use owo_colors::OwoColorize;
|
use owo_colors::OwoColorize;
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -36,6 +36,99 @@ pub fn scan_for_gui(config: &Config) -> Vec<DetectHit> {
|
|||||||
hits
|
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<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 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;
|
const MAX_DEPTH: u32 = 3;
|
||||||
|
|
||||||
pub fn run(config: &Config, extra_dirs: &[PathBuf], apply: bool) -> Result<()> {
|
pub fn run(config: &Config, extra_dirs: &[PathBuf], apply: bool) -> Result<()> {
|
||||||
|
|||||||
+174
-3
@@ -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 anyhow::Result;
|
||||||
use iced::widget::{
|
use iced::widget::{
|
||||||
button, column, container, mouse_area, pick_list, row, scrollable, text, text_input, Column,
|
button, column, container, mouse_area, pick_list, row, scrollable, text, text_input, Column,
|
||||||
@@ -44,6 +44,11 @@ pub enum Message {
|
|||||||
AddGameExeChanged(String, String),
|
AddGameExeChanged(String, String),
|
||||||
AddGameConfirm(String),
|
AddGameConfirm(String),
|
||||||
RemoveGame(String, String),
|
RemoveGame(String, String),
|
||||||
|
ScanGames(String),
|
||||||
|
ScanGamesDone(String, Vec<(String, String)>),
|
||||||
|
AddScannedGame(String, String, String),
|
||||||
|
BrowseGameExe(String),
|
||||||
|
BrowseGameExeDone(String, Option<String>),
|
||||||
// Settings
|
// Settings
|
||||||
ShowSettings,
|
ShowSettings,
|
||||||
HideSettings,
|
HideSettings,
|
||||||
@@ -72,6 +77,8 @@ struct Dashboard {
|
|||||||
diagnose_result: Option<(String, Vec<diagnose::CheckResult>)>,
|
diagnose_result: Option<(String, Vec<diagnose::CheckResult>)>,
|
||||||
// Games
|
// Games
|
||||||
adding_game: HashMap<String, (String, String)>,
|
adding_game: HashMap<String, (String, String)>,
|
||||||
|
scan_results: HashMap<String, Vec<(String, String)>>,
|
||||||
|
scan_busy: std::collections::HashSet<String>,
|
||||||
// Settings
|
// Settings
|
||||||
settings_open: bool,
|
settings_open: bool,
|
||||||
settings_proton_version: String,
|
settings_proton_version: String,
|
||||||
@@ -102,6 +109,8 @@ impl Dashboard {
|
|||||||
diagnose_open: None,
|
diagnose_open: None,
|
||||||
diagnose_result: None,
|
diagnose_result: None,
|
||||||
adding_game: HashMap::new(),
|
adding_game: HashMap::new(),
|
||||||
|
scan_results: HashMap::new(),
|
||||||
|
scan_busy: std::collections::HashSet::new(),
|
||||||
settings_open: false,
|
settings_open: false,
|
||||||
settings_proton_version,
|
settings_proton_version,
|
||||||
settings_compat_dir,
|
settings_compat_dir,
|
||||||
@@ -385,6 +394,79 @@ fn update(state: &mut Dashboard, msg: Message) -> Task<Message> {
|
|||||||
}
|
}
|
||||||
Task::none()
|
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 ───────────────────────────────────────────────────────
|
// ── Settings ───────────────────────────────────────────────────────
|
||||||
Message::ShowSettings => {
|
Message::ShowSettings => {
|
||||||
state.settings_open = true;
|
state.settings_open = true;
|
||||||
@@ -581,7 +663,14 @@ fn view(state: &Dashboard) -> Element<'_, Message> {
|
|||||||
} else if menu_open {
|
} else if menu_open {
|
||||||
context_menu_card(l)
|
context_menu_card(l)
|
||||||
} else {
|
} 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(
|
let card = mouse_area(
|
||||||
@@ -683,6 +772,8 @@ fn launcher_card<'a>(
|
|||||||
installed: bool,
|
installed: bool,
|
||||||
running: bool,
|
running: bool,
|
||||||
add_form: Option<&'a (String, String)>,
|
add_form: Option<&'a (String, String)>,
|
||||||
|
scan_results: Option<&'a Vec<(String, String)>>,
|
||||||
|
scan_busy: bool,
|
||||||
) -> Element<'a, Message> {
|
) -> Element<'a, Message> {
|
||||||
let status_label = if running {
|
let status_label = if running {
|
||||||
"● Running"
|
"● Running"
|
||||||
@@ -735,6 +826,82 @@ fn launcher_card<'a>(
|
|||||||
|
|
||||||
let mut rows: Vec<Element<Message>> = vec![header.into()];
|
let mut rows: Vec<Element<Message>> = 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 {
|
for g in &l.games {
|
||||||
let lname = l.name.clone();
|
let lname = l.name.clone();
|
||||||
let gname = g.name.clone();
|
let gname = g.name.clone();
|
||||||
@@ -782,6 +949,7 @@ fn launcher_card<'a>(
|
|||||||
let lname2 = l.name.clone();
|
let lname2 = l.name.clone();
|
||||||
let lname3 = l.name.clone();
|
let lname3 = l.name.clone();
|
||||||
let lname4 = l.name.clone();
|
let lname4 = l.name.clone();
|
||||||
|
let lname5 = l.name.clone();
|
||||||
let name_input = text_input("Game name", name_val)
|
let name_input = text_input("Game name", name_val)
|
||||||
.on_input(move |v| Message::AddGameNameChanged(lname.clone(), v))
|
.on_input(move |v| Message::AddGameNameChanged(lname.clone(), v))
|
||||||
.padding(5)
|
.padding(5)
|
||||||
@@ -792,6 +960,9 @@ fn launcher_card<'a>(
|
|||||||
.padding(5)
|
.padding(5)
|
||||||
.size(12)
|
.size(12)
|
||||||
.width(Length::FillPortion(3));
|
.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 can_confirm = !name_val.trim().is_empty() && !exe_val.trim().is_empty();
|
||||||
let confirm_btn = button(text("Add").size(11))
|
let confirm_btn = button(text("Add").size(11))
|
||||||
.on_press_maybe(can_confirm.then_some(Message::AddGameConfirm(lname3)))
|
.on_press_maybe(can_confirm.then_some(Message::AddGameConfirm(lname3)))
|
||||||
@@ -800,7 +971,7 @@ fn launcher_card<'a>(
|
|||||||
.on_press(Message::AddGamePressed(lname4))
|
.on_press(Message::AddGamePressed(lname4))
|
||||||
.style(button::secondary);
|
.style(button::secondary);
|
||||||
rows.push(
|
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)
|
.align_y(Alignment::Center)
|
||||||
.spacing(6)
|
.spacing(6)
|
||||||
.into(),
|
.into(),
|
||||||
|
|||||||
+27
@@ -34,3 +34,30 @@ pub fn pick_folder(title: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
None
|
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<String> {
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user