diff --git a/Cargo.lock b/Cargo.lock index 4494846..b93e0eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4439,6 +4439,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "tokio", "toml", ] diff --git a/Cargo.toml b/Cargo.toml index 70cc8d0..5cadc2a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,3 +45,4 @@ reqwest = { version = "0.12", features = ["blocking", "json"] } # GUI for the setup wizard iced = { version = "0.13", features = ["tokio"] } iced_fonts = { version = "0.1", features = ["bootstrap"] } +tokio = { version = "1.52.1", features = ["rt"] } diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index c0784a5..050bcdb 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -12,7 +12,7 @@ pkgname=umutray pkgver=0.1.0 -pkgrel=4 +pkgrel=6 pkgdesc='Tray-based Wine launcher manager for Linux via umu/Proton-GE' arch=('x86_64') url='https://git.aleshym.co/funman300/umutray' diff --git a/src/config.rs b/src/config.rs index 52300b7..b1c3951 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,6 +4,17 @@ use owo_colors::OwoColorize; use serde::{Deserialize, Serialize}; use std::path::PathBuf; +/// Expresses the desired change to a game's gamescope setting. +#[derive(Debug, Clone)] +pub enum GamescopeUpdate { + /// Leave the current value unchanged. + Unchanged, + /// Disable gamescope. + Disable, + /// Enable gamescope with the given CLI arguments. + Enable(Vec), +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Launcher { /// Short CLI name (e.g. "battlenet"). @@ -236,7 +247,7 @@ impl Config { return Ok(c); } let content = std::fs::read_to_string(&path) - .with_context(|| format!("Failed to read config from {path:?}"))?; + .with_context(|| format!("Failed to read config from {}", path.display()))?; match toml::from_str::(&content) { Ok(c) => { Ok(c) @@ -244,7 +255,7 @@ impl Config { Err(e) => { let bak = path.with_extension("toml.bak"); std::fs::rename(&path, &bak) - .with_context(|| format!("Failed to back up stale config to {bak:?}"))?; + .with_context(|| format!("Failed to back up stale config to {}", bak.display()))?; eprintln!("warning: couldn't parse {}: {e}", path.display()); eprintln!( " backed up to {} — writing fresh config with presets", @@ -264,7 +275,7 @@ impl Config { } let content = toml::to_string_pretty(self)?; std::fs::write(&path, content) - .with_context(|| format!("Failed to write config to {path:?}")) + .with_context(|| format!("Failed to write config to {}", path.display())) } pub fn find(&self, name: &str) -> Option<&Launcher> { @@ -386,16 +397,15 @@ impl Config { } /// Update per-game overlay flags. Each arg is `None` = leave as-is. - /// `gamescope = Some(None)` disables it; `Some(Some(vec))` enables with args. pub fn set_game_flags( &mut self, launcher: &str, name: &str, gamemode: Option, mangohud: Option, - gamescope: Option>>, + gamescope: GamescopeUpdate, ) -> Result<()> { - if gamemode.is_none() && mangohud.is_none() && gamescope.is_none() { + if gamemode.is_none() && mangohud.is_none() && matches!(gamescope, GamescopeUpdate::Unchanged) { anyhow::bail!( "nothing to set — pass --gamemode, --mangohud, --gamescope, or --no-gamescope" ); @@ -415,8 +425,10 @@ impl Config { if let Some(v) = mangohud { g.mangohud = v; } - if let Some(v) = gamescope { - g.gamescope = v; + match gamescope { + GamescopeUpdate::Unchanged => {} + GamescopeUpdate::Disable => g.gamescope = None, + GamescopeUpdate::Enable(args) => g.gamescope = Some(args), } self.save()?; println!("{} Updated flags for '{launcher}/{name}'.", "✓".green().bold()); diff --git a/src/detect.rs b/src/detect.rs index 0f58953..9f185d4 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -23,8 +23,7 @@ pub fn scan_for_gui(config: &Config) -> Vec { if prefix.join("drive_c").join(&preset.exe_path).exists() { let configured = config .find(&preset.name) - .map(|l| l.prefix_dir == *prefix) - .unwrap_or(false); + .is_some_and(|l| l.prefix_dir == *prefix); hits.push(DetectHit { display: preset.display.clone(), prefix: prefix.clone(), @@ -182,14 +181,7 @@ fn parse_heroic_gog_installed(text: &str, map: &mut HashMap) { /// Walk up from `exe_path` checking each ancestor against `STORE_TITLES`. fn store_title(exe_path: &Path) -> Option { - let mut dir = exe_path.parent(); - while let Some(d) = dir { - if let Some(title) = STORE_TITLES.get(d) { - return Some(title.clone()); - } - dir = d.parent(); - } - None + exe_path.ancestors().skip(1).find_map(|d| STORE_TITLES.get(d).cloned()) } /// Scan a launcher's Wine prefix for installed game executables. @@ -251,8 +243,7 @@ fn scan_exe_dir( } else if path .extension() .and_then(|e| e.to_str()) - .map(|e| e.eq_ignore_ascii_case("exe")) - .unwrap_or(false) + .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(); @@ -344,20 +335,16 @@ fn resolve_uncached(exe_path: &Path) -> String { /// - GOG: `goggame-.info` → `{ "gameName": "..." }` /// - Epic: `.egstore/.item` → `{ "DisplayName": "..." }` fn read_manifest_name(exe_path: &Path) -> Option { - let mut dir = exe_path.parent(); - while let Some(d) = dir { + 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); } - - dir = d.parent(); } None } @@ -438,8 +425,7 @@ fn nearest_dir_name(path: &Path) -> String { "launcher", "engine", "client", ]; - let mut dir = path.parent(); - while let Some(d) = dir { + 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() @@ -448,7 +434,6 @@ fn nearest_dir_name(path: &Path) -> String { { return name.to_string(); } - dir = d.parent(); } // Nothing useful in the path — return the exe stem as-is. @@ -529,7 +514,7 @@ fn collect_prefixes(dir: &Path, depth: u32, out: &mut Vec) { return; }; for entry in entries.flatten() { - if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + if entry.file_type().is_ok_and(|t| t.is_dir()) { collect_prefixes(&entry.path(), depth + 1, out); } } diff --git a/src/diagnose.rs b/src/diagnose.rs index 99c5ce9..79d0766 100644 --- a/src/diagnose.rs +++ b/src/diagnose.rs @@ -80,8 +80,7 @@ fn global_vulkan_check() -> CheckResult { .stdout(Stdio::null()) .stderr(Stdio::null()) .status() - .map(|s| s.success()) - .unwrap_or(false); + .is_ok_and(|s| s.success()); if ok { CheckResult::pass("vulkan", "vulkaninfo OK") } else { @@ -218,8 +217,7 @@ fn count_ge_proton(dir: &Path) -> usize { .filter(|e| { e.file_name() .to_str() - .map(|s| s.starts_with("GE-Proton")) - .unwrap_or(false) + .is_some_and(|s| s.starts_with("GE-Proton")) }) .count() }) @@ -236,19 +234,21 @@ fn which(cmd: &str) -> Option { .map(|s| s.trim().to_string()) } +fn current_uid() -> Option { + std::fs::read_to_string("/proc/self/status") + .ok() + .and_then(|s| { + s.lines() + .find(|l| l.starts_with("Uid:")) + .and_then(|l| l.split_whitespace().nth(1)) + .and_then(|s| s.parse().ok()) + }) +} + fn is_owned_by_current_user(path: &Path) -> bool { let file_uid = match std::fs::metadata(path) { Ok(m) => m.uid(), Err(_) => return true, }; - let current_uid: Option = Command::new("id") - .arg("-u") - .output() - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .and_then(|s| s.trim().parse().ok()); - match current_uid { - Some(uid) => uid == file_uid, - None => true, - } + current_uid().map_or(true, |uid| uid == file_uid) } diff --git a/src/gui.rs b/src/gui.rs index 592f55c..016faff 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -171,8 +171,7 @@ fn update(state: &mut Dashboard, msg: Message) -> Task { .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() - .map(|s| s.success()) - .unwrap_or(false); + .is_ok_and(|s| s.success()); map.insert(name, running); } map @@ -183,7 +182,7 @@ fn update(state: &mut Dashboard, msg: Message) -> Task { Message::PollDone(snapshot) => { state.running = snapshot; // Clear launch_busy for launchers that are now running - state.launch_busy.retain(|n| !state.running.get(n).copied().unwrap_or(false)); + state.launch_busy.retain(|n| !state.running.get(n).copied().unwrap_or_default()); Task::none() } Message::ReloadConfig => { @@ -237,7 +236,7 @@ fn update(state: &mut Dashboard, msg: Message) -> Task { let name2 = name.clone(); state.running.insert(name, false); Task::perform( - async_blocking(move || launcher::kill(&l).map_err(|e| e.to_string())), + async_blocking(move || { launcher::kill(&l); Ok::<(), String>(()) }), move |res| Message::KillDone(name2.clone(), res), ) } @@ -700,8 +699,7 @@ fn toggle_flag( fn service_is_installed() -> bool { dirs::home_dir() - .map(|h| h.join(".config/autostart/umutray.desktop").exists()) - .unwrap_or(false) + .is_some_and(|h| h.join(".config/autostart/umutray.desktop").exists()) } fn subscription(_: &Dashboard) -> Subscription { @@ -1054,7 +1052,7 @@ fn launcher_card<'a>( // ── Games section ───────────────────────────────────────────────────── let has_games = !l.games.is_empty(); - let has_scan = scan_results.map(|r| !r.is_empty()).unwrap_or(false); + let has_scan = scan_results.is_some_and(|r| !r.is_empty()); if has_games || has_scan || scan_busy { let game_count = l.games.len(); diff --git a/src/launcher.rs b/src/launcher.rs index c0461b4..a18f8f7 100644 --- a/src/launcher.rs +++ b/src/launcher.rs @@ -25,9 +25,9 @@ pub fn launch(config: &Config, launcher: &Launcher) -> Result<()> { let exe = launcher.full_exe_path(); if !exe.exists() { bail!( - "launcher exe not found at {:?}\n\ + "launcher exe not found at {}\n\ Run `umutray setup {}` for setup instructions.", - exe, + exe.display(), launcher.name, ); } @@ -55,9 +55,9 @@ pub fn play_game(config: &Config, launcher: &Launcher, game: &Game) -> Result<() let exe = game.full_exe_path(launcher); if !exe.exists() { bail!( - "game exe not found at {:?}\n\ + "game exe not found at {}\n\ Check exe_path for '{}/{}' in config, or install the game via the launcher first.", - exe, + exe.display(), launcher.name, game.name, ); @@ -108,13 +108,12 @@ fn build_wrapped_argv(exe: &Path, game: &Game) -> (OsString, Vec) { } /// SIGTERM → wait 3 s → SIGKILL for a single launcher. -pub fn kill(launcher: &Launcher) -> Result<()> { +pub fn kill(launcher: &Launcher) { kill_pattern(&launcher.process_pattern); - Ok(()) } /// Kill every configured launcher's processes. -pub fn kill_all(config: &Config) -> Result<()> { +pub fn kill_all(config: &Config) { // Single SIGTERM pass across all launchers, then one sleep, then SIGKILL. // This keeps the total wait at 3 s instead of 3 s × N. for l in &config.launchers { @@ -124,7 +123,6 @@ pub fn kill_all(config: &Config) -> Result<()> { for l in &config.launchers { send_signal("-9", &l.process_pattern); } - Ok(()) } fn kill_pattern(pattern: &str) { @@ -149,6 +147,5 @@ pub fn is_running(launcher: &Launcher) -> bool { .stdout(Stdio::null()) .stderr(Stdio::null()) .status() - .map(|s| s.success()) - .unwrap_or(false) + .is_ok_and(|s| s.success()) } diff --git a/src/main.rs b/src/main.rs index df20ac3..76b7d15 100644 --- a/src/main.rs +++ b/src/main.rs @@ -254,9 +254,9 @@ fn main() -> Result<()> { let l = config.find(&n).ok_or_else(|| { anyhow::anyhow!("unknown launcher '{n}' — try `umutray launchers`") })?; - launcher::kill(l)?; + launcher::kill(l); } - None => launcher::kill_all(&config)?, + None => launcher::kill_all(&config), }, Commands::Diagnose { name } => { @@ -414,15 +414,14 @@ fn main() -> Result<()> { gamescope, no_gamescope, } => { - // gs_update is Option>> where: - // None = leave gamescope unchanged - // Some(None) = disable gamescope - // Some(Some(args)) = enable gamescope with these CLI args let gs_update = if no_gamescope { - Some(None) + config::GamescopeUpdate::Disable + } else if let Some(s) = gamescope { + config::GamescopeUpdate::Enable( + s.split_whitespace().map(String::from).collect(), + ) } else { - gamescope - .map(|s| Some(s.split_whitespace().map(String::from).collect::>())) + config::GamescopeUpdate::Unchanged }; let mut c = config; c.set_game_flags(&launcher, &name, gamemode, mangohud, gs_update)?; diff --git a/src/proton.rs b/src/proton.rs index 7b6d814..3eac106 100644 --- a/src/proton.rs +++ b/src/proton.rs @@ -4,7 +4,7 @@ use owo_colors::OwoColorize; use serde::Deserialize; use std::collections::HashSet; use std::io::Write; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; const GITHUB_API: &str = "https://api.github.com/repos/GloriousEggroll/proton-ge-custom/releases"; @@ -57,7 +57,7 @@ fn fetch_release(tag: &str) -> Result { fn install_version(config: &Config, tag: &str) -> Result<()> { let install_path = config.proton_compat_dir.join(tag); if install_path.exists() { - println!("{tag} is already installed at {install_path:?}"); + println!("{tag} is already installed at {}", install_path.display()); return Ok(()); } @@ -83,13 +83,13 @@ fn install_version(config: &Config, tag: &str) -> Result<()> { .context("Download returned an error status")?; let total = resp.content_length(); let f = std::fs::File::create(&tmp_path) - .with_context(|| format!("Failed to create temp file {tmp_path:?}"))?; + .with_context(|| format!("Failed to create temp file {}", tmp_path.display()))?; let mut progress = ProgressWriter::new(f, total); std::io::copy(&mut resp, &mut progress).context("Failed to stream archive to disk")?; progress.finish(); } - println!("Extracting to {:?}...", config.proton_compat_dir); + println!("Extracting to {}...", config.proton_compat_dir.display()); std::fs::create_dir_all(&config.proton_compat_dir)?; let status = std::process::Command::new("tar") @@ -110,10 +110,10 @@ fn install_version(config: &Config, tag: &str) -> Result<()> { } /// Return all GE-Proton* directories found in `dir`. -fn scan_ge_proton_in(dir: &PathBuf, seen: &mut HashSet, out: &mut Vec) { +fn scan_ge_proton_in(dir: &Path, seen: &mut HashSet, out: &mut Vec) { let Ok(entries) = std::fs::read_dir(dir) else { return }; for entry in entries.flatten() { - if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + if !entry.file_type().is_ok_and(|t| t.is_dir()) { continue; } let name = entry.file_name().to_string_lossy().to_string(); diff --git a/src/service.rs b/src/service.rs index 8be44d4..e549b9c 100644 --- a/src/service.rs +++ b/src/service.rs @@ -17,27 +17,26 @@ fn desktop_path() -> Result { } fn render_desktop(exe: &std::path::Path, autostart: bool) -> String { + let exec = if autostart { + format!("{}", exe.display()) + } else { + format!("{} gui", exe.display()) + }; let mut s = format!( "[Desktop Entry]\n\ Name=umutray\n\ Comment=Wine launcher manager for Windows game launchers\n\ - Exec={exe}\n\ + Exec={exec}\n\ Icon=applications-games\n\ Type=Application\n\ Categories=Game;\n\ Keywords=wine;proton;gaming;launcher;\n\ StartupNotify=false\n", - exe = exe.display(), ); if autostart { s.push_str("X-GNOME-Autostart-enabled=true\n"); s.push_str("Hidden=false\n"); } else { - // App-menu entry launches the GUI - s = s.replace( - &format!("Exec={}", exe.display()), - &format!("Exec={} gui", exe.display()), - ); s.push_str("StartupNotify=true\n"); } s @@ -49,10 +48,10 @@ pub fn install_desktop() -> Result<()> { let exe = std::env::current_exe().context("Cannot determine path to own executable")?; let desktop = desktop_path()?; if let Some(p) = desktop.parent() { - std::fs::create_dir_all(p).with_context(|| format!("Failed to create {p:?}"))?; + std::fs::create_dir_all(p).with_context(|| format!("Failed to create {}", p.display()))?; } std::fs::write(&desktop, render_desktop(&exe, false)) - .with_context(|| format!("Failed to write desktop file {desktop:?}"))?; + .with_context(|| format!("Failed to write desktop file {}", desktop.display()))?; println!("{} App menu entry written: {}", "✓".green().bold(), desktop.display()); Ok(()) } @@ -62,7 +61,7 @@ pub fn uninstall_desktop() -> Result<()> { let desktop = desktop_path()?; if desktop.exists() { std::fs::remove_file(&desktop) - .with_context(|| format!("Failed to remove {desktop:?}"))?; + .with_context(|| format!("Failed to remove {}", desktop.display()))?; println!("Removed {}", desktop.display()); } else { println!("No desktop file at {}", desktop.display()); @@ -77,10 +76,10 @@ pub fn install() -> Result<()> { // XDG autostart let autostart = autostart_path()?; if let Some(p) = autostart.parent() { - std::fs::create_dir_all(p).with_context(|| format!("Failed to create {p:?}"))?; + std::fs::create_dir_all(p).with_context(|| format!("Failed to create {}", p.display()))?; } std::fs::write(&autostart, render_desktop(&exe, true)) - .with_context(|| format!("Failed to write autostart file {autostart:?}"))?; + .with_context(|| format!("Failed to write autostart file {}", autostart.display()))?; println!("Wrote autostart: {}", autostart.display()); // App-menu entry @@ -98,7 +97,7 @@ pub fn uninstall() -> Result<()> { let autostart = autostart_path()?; if autostart.exists() { std::fs::remove_file(&autostart) - .with_context(|| format!("Failed to remove {autostart:?}"))?; + .with_context(|| format!("Failed to remove {}", autostart.display()))?; println!("Removed {}", autostart.display()); } else { println!("No autostart file at {}", autostart.display()); diff --git a/src/tray.rs b/src/tray.rs index 37504c0..7f5f9d5 100644 --- a/src/tray.rs +++ b/src/tray.rs @@ -144,7 +144,7 @@ impl ksni::Tray for UmuTray { icon_name: "process-stop".into(), activate: Box::new(move |this: &mut Self| { if let Some(l) = this.config.find(&kill_name) { - let _ = launcher::kill(l); + launcher::kill(l); } }), ..Default::default() diff --git a/src/util.rs b/src/util.rs index 3bd7433..0b0b758 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,6 +1,4 @@ -use iced::futures::channel::oneshot; - -/// Run a blocking closure on a thread pool thread and await its result. +/// Run a blocking closure on the tokio blocking thread pool and await its result. /// Used to offload blocking work (HTTP, disk, process spawning) without /// stalling the iced event loop. pub async fn async_blocking(f: F) -> T @@ -8,11 +6,9 @@ where T: Send + 'static, F: FnOnce() -> T + Send + 'static, { - let (tx, rx) = oneshot::channel(); - std::thread::spawn(move || { - let _ = tx.send(f()); - }); - rx.await.expect("blocking task panicked") + tokio::task::spawn_blocking(f) + .await + .expect("blocking task panicked") } /// Open a native folder picker dialog and return the chosen path, or None if