diff --git a/src/gui.rs b/src/gui.rs index 55c1917..35a4117 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -1,7 +1,7 @@ -use crate::{config::Config, detect, diagnose, launcher, service, util::async_blocking}; +use crate::{config::Config, detect, diagnose, launcher, proton, service, util::{async_blocking, pick_folder}}; use anyhow::Result; use iced::widget::{ - button, column, container, mouse_area, row, scrollable, text, text_input, Column, + button, column, container, mouse_area, pick_list, row, scrollable, text, text_input, Column, }; use iced::{ Alignment, Background, Border, Color, Element, Length, Padding, Subscription, Task, Theme, @@ -24,6 +24,7 @@ pub enum Message { ToggleGamescope(String, String), UpdateProton, ProtonDone(Result<(), String>), + KillDone(String, Result<(), String>), // Context menu ShowContextMenu(String), HideContextMenu, @@ -48,6 +49,8 @@ pub enum Message { HideSettings, SettingsProtonVersionChanged(String), SettingsCompatDirChanged(String), + BrowseCompatDir, + BrowseCompatDirDone(Option), SaveSettings, ServiceInstall, ServiceUninstall, @@ -73,6 +76,7 @@ struct Dashboard { settings_open: bool, settings_proton_version: String, settings_compat_dir: String, + proton_versions: Vec, service_busy: bool, service_status: String, } @@ -85,6 +89,7 @@ impl Dashboard { } let settings_proton_version = config.proton_version.clone(); let settings_compat_dir = config.proton_compat_dir.to_string_lossy().into_owned(); + let proton_versions = proton::list_installed(&config); Self { config, running, @@ -100,6 +105,7 @@ impl Dashboard { settings_open: false, settings_proton_version, settings_compat_dir, + proton_versions, service_busy: false, service_status: String::new(), } @@ -119,6 +125,7 @@ fn update(state: &mut Dashboard, msg: Message) -> Task { state .running .retain(|k, _| fresh.launchers.iter().any(|l| &l.name == k)); + state.proton_versions = proton::list_installed(&fresh); state.config = fresh; } Task::none() @@ -146,16 +153,21 @@ fn update(state: &mut Dashboard, msg: Message) -> Task { } Message::Kill(name) => { state.last_error = None; - if let Some(l) = state.config.find(&name) { - let l = l.clone(); - match launcher::kill(&l) { - Ok(()) => { - state.running.insert(name, false); - } - Err(e) => { - state.last_error = Some(format!("Kill failed: {e}")); - } - } + let Some(l) = state.config.find(&name) else { + return Task::none(); + }; + let l = l.clone(); + let name2 = name.clone(); + state.running.insert(name, false); + Task::perform( + async_blocking(move || launcher::kill(&l).map_err(|e| e.to_string())), + move |res| Message::KillDone(name2.clone(), res), + ) + } + Message::KillDone(name, res) => { + if let Err(e) = res { + state.running.insert(name, true); + state.last_error = Some(format!("Kill failed: {e}")); } Task::none() } @@ -379,6 +391,7 @@ fn update(state: &mut Dashboard, msg: Message) -> Task { state.settings_proton_version = state.config.proton_version.clone(); state.settings_compat_dir = state.config.proton_compat_dir.to_string_lossy().into_owned(); + state.proton_versions = proton::list_installed(&state.config); state.service_status = String::new(); Task::none() } @@ -394,12 +407,45 @@ fn update(state: &mut Dashboard, msg: Message) -> Task { state.settings_compat_dir = v; Task::none() } + Message::BrowseCompatDir => Task::perform( + async_blocking(|| pick_folder("Choose GE-Proton compat directory")), + Message::BrowseCompatDirDone, + ), + Message::BrowseCompatDirDone(path) => { + if let Some(p) = path { + state.settings_compat_dir = p; + state.proton_versions = + proton::list_installed_from(PathBuf::from(&state.settings_compat_dir)); + } + Task::none() + } Message::SaveSettings => { state.last_error = None; let version = state.settings_proton_version.trim().to_string(); let compat = PathBuf::from(state.settings_compat_dir.trim()); - match state.config.set_globals(Some(version), Some(compat)) { + + // Validate compat dir + if !compat.is_absolute() { + state.last_error = Some("Compat directory must be an absolute path.".into()); + return Task::none(); + } + // Validate proton version format (allow "GE-Proton (latest)" or "GE-Proton\d+-\d+") + let version_key = if version == "GE-Proton (latest)" { + "GE-Proton".to_string() + } else { + version.clone() + }; + let valid_version = version_key == "GE-Proton" + || version_key.starts_with("GE-Proton"); + if !valid_version { + state.last_error = + Some(format!("Unknown Proton version \"{version_key}\". Expected \"GE-Proton\" or a specific version like \"GE-Proton10-1\".")); + return Task::none(); + } + + match state.config.set_globals(Some(version_key), Some(compat)) { Ok(()) => { + state.proton_versions = proton::list_installed(&state.config); state.service_status = "Settings saved.".into(); } Err(e) => { @@ -410,7 +456,7 @@ fn update(state: &mut Dashboard, msg: Message) -> Task { } Message::ServiceInstall => { state.service_busy = true; - state.service_status = "Installing service…".into(); + state.service_status = "Installing autostart…".into(); Task::perform( async_blocking(|| service::install().map_err(|e| e.to_string())), Message::ServiceActionDone, @@ -418,7 +464,7 @@ fn update(state: &mut Dashboard, msg: Message) -> Task { } Message::ServiceUninstall => { state.service_busy = true; - state.service_status = "Removing service…".into(); + state.service_status = "Removing autostart…".into(); Task::perform( async_blocking(|| service::uninstall().map_err(|e| e.to_string())), Message::ServiceActionDone, @@ -429,9 +475,9 @@ fn update(state: &mut Dashboard, msg: Message) -> Task { match res { Ok(()) => { state.service_status = if service_is_installed() { - "Service installed — autostarts on login.".into() + "Autostart enabled — starts on next login.".into() } else { - "Service removed.".into() + "Autostart removed.".into() }; } Err(e) => { @@ -464,13 +510,8 @@ fn toggle_flag( } fn service_is_installed() -> bool { - std::env::var("HOME") - .ok() - .map(|h| { - PathBuf::from(h) - .join(".config/systemd/user/umutray.service") - .exists() - }) + dirs::home_dir() + .map(|h| h.join(".config/autostart/umutray.desktop").exists()) .unwrap_or(false) } @@ -671,10 +712,22 @@ fn launcher_card<'a>( } }; + let version_badge: Element = if let Some(v) = &l.proton_version { + text(format!(" [{v}]")) + .size(11) + .style(|_: &Theme| text::Style { + color: Some(Color::from_rgb(0.55, 0.75, 1.0)), + }) + .into() + } else { + text("").into() + }; + let header = row![ text(&l.display).size(15), text(" — ").size(12), text(status_label).size(12), + version_badge, iced::widget::horizontal_space(), action, ] @@ -870,17 +923,28 @@ fn view_settings(state: &Dashboard) -> Element<'_, Message> { ] .align_y(Alignment::Center); - let proton_version_input = - text_input("e.g. GE-Proton or GE-Proton10-34", &state.settings_proton_version) - .on_input(Message::SettingsProtonVersionChanged) - .padding(8); + let proton_version_picker = pick_list( + state.proton_versions.as_slice(), + Some(if state.settings_proton_version == "GE-Proton" { + "GE-Proton (latest)".to_string() + } else { + state.settings_proton_version.clone() + }), + Message::SettingsProtonVersionChanged, + ) + .width(Length::Fill); let compat_dir_input = text_input( "e.g. ~/.local/share/Steam/compatibilitytools.d", &state.settings_compat_dir, ) .on_input(Message::SettingsCompatDirChanged) - .padding(8); + .padding(8) + .width(Length::Fill); + + let browse_compat_btn = button(text("Browse…").size(13)) + .on_press(Message::BrowseCompatDir) + .style(button::secondary); let save_btn = button(text("Save").size(13)) .on_press(Message::SaveSettings) @@ -909,9 +973,9 @@ fn view_settings(state: &Dashboard) -> Element<'_, Message> { header, iced::widget::horizontal_rule(1), text("Proton version").size(13), - proton_version_input, + proton_version_picker, text("GE-Proton compat directory").size(13), - compat_dir_input, + row![compat_dir_input, browse_compat_btn].spacing(8).align_y(Alignment::Center), save_btn, iced::widget::horizontal_rule(1), text(svc_status_label).size(13), diff --git a/src/proton.rs b/src/proton.rs index f5223aa..7b6d814 100644 --- a/src/proton.rs +++ b/src/proton.rs @@ -1,7 +1,10 @@ use crate::config::Config; use anyhow::{Context, Result}; +use owo_colors::OwoColorize; use serde::Deserialize; +use std::collections::HashSet; use std::io::Write; +use std::path::PathBuf; const GITHUB_API: &str = "https://api.github.com/repos/GloriousEggroll/proton-ge-custom/releases"; @@ -106,6 +109,54 @@ fn install_version(config: &Config, tag: &str) -> Result<()> { Ok(()) } +/// Return all GE-Proton* directories found in `dir`. +fn scan_ge_proton_in(dir: &PathBuf, 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) { + continue; + } + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with("GE-Proton") && seen.insert(name.clone()) { + out.push(name); + } + } +} + +/// Return all GE-Proton versions found in `dir`, newest-first, +/// prepended with "GE-Proton (latest)". +pub fn list_installed_from(dir: PathBuf) -> Vec { + let mut seen = HashSet::new(); + let mut versions = Vec::new(); + scan_ge_proton_in(&dir, &mut seen, &mut versions); + versions.sort_by(|a, b| b.cmp(a)); + let mut out = vec!["GE-Proton (latest)".to_string()]; + out.extend(versions); + out +} + +/// Return all GE-Proton versions found across the configured compat dir and +/// common system Proton locations (Steam, ProtonUp-Qt, /usr/share/steam). +pub fn list_installed(config: &Config) -> Vec { + let mut dirs = vec![config.proton_compat_dir.clone()]; + if let Some(home) = dirs::home_dir() { + // ProtonUp-Qt and manual installs land here by default + dirs.push(home.join(".steam/root/compatibilitytools.d")); + dirs.push(home.join(".local/share/Steam/compatibilitytools.d")); + } + dirs.push(PathBuf::from("/usr/share/steam/compatibilitytools.d")); + + let mut seen = HashSet::new(); + let mut versions = Vec::new(); + for dir in &dirs { + scan_ge_proton_in(dir, &mut seen, &mut versions); + } + versions.sort_by(|a, b| b.cmp(a)); + let mut out = vec!["GE-Proton (latest)".to_string()]; + out.extend(versions); + out +} + /// Install the latest GE-Proton release (called from tray menu). pub fn install_latest(config: &Config) -> Result<()> { println!("Checking for latest GE-Proton..."); @@ -152,11 +203,7 @@ fn print_list(config: &Config) -> Result<()> { let releases = fetch_releases(10)?; for r in &releases { let installed = config.proton_compat_dir.join(&r.tag_name).exists(); - let marker = if installed { - " \x1b[1;32m✓ installed\x1b[0m" - } else { - "" - }; + let marker = if installed { format!(" {}", "✓ installed".green().bold()) } else { String::new() }; println!(" {}{}", r.tag_name, marker); } Ok(()) @@ -220,11 +267,7 @@ fn pick_interactively(config: &Config) -> Result { println!("Recent GE-Proton releases:"); for (i, r) in releases.iter().enumerate() { let installed = config.proton_compat_dir.join(&r.tag_name).exists(); - let marker = if installed { - " \x1b[1;32m✓\x1b[0m" - } else { - "" - }; + let marker = if installed { format!(" {}", "✓".green().bold()) } else { String::new() }; println!(" {:2}) {}{}", i + 1, r.tag_name, marker); } diff --git a/src/service.rs b/src/service.rs index 934ab23..8be44d4 100644 --- a/src/service.rs +++ b/src/service.rs @@ -1,69 +1,46 @@ -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result}; +use owo_colors::OwoColorize; use std::path::PathBuf; -use std::process::Command; -const UNIT_NAME: &str = "umutray.service"; const DESKTOP_NAME: &str = "umutray.desktop"; fn home() -> Result { - Ok(PathBuf::from( - std::env::var("HOME").context("$HOME is not set")?, - )) + dirs::home_dir().context("Cannot determine home directory") } -fn unit_path() -> Result { - Ok(home()?.join(".config/systemd/user").join(UNIT_NAME)) +fn autostart_path() -> Result { + Ok(home()?.join(".config/autostart").join(DESKTOP_NAME)) } fn desktop_path() -> Result { - Ok(home()? - .join(".local/share/applications") - .join(DESKTOP_NAME)) + Ok(home()?.join(".local/share/applications").join(DESKTOP_NAME)) } -fn render_unit(exe: &std::path::Path) -> String { - format!( - "[Unit]\n\ - Description=umutray Wine launcher manager\n\ - After=graphical-session.target\n\ - PartOf=graphical-session.target\n\ - \n\ - [Service]\n\ - ExecStart={exe}\n\ - Restart=on-failure\n\ - RestartSec=5\n\ - \n\ - [Install]\n\ - WantedBy=graphical-session.target\n", - exe = exe.display(), - ) -} - -fn render_desktop(exe: &std::path::Path) -> String { - format!( +fn render_desktop(exe: &std::path::Path, autostart: bool) -> String { + let mut s = format!( "[Desktop Entry]\n\ Name=umutray\n\ Comment=Wine launcher manager for Windows game launchers\n\ - Exec={exe} gui\n\ + Exec={exe}\n\ Icon=applications-games\n\ Type=Application\n\ Categories=Game;\n\ Keywords=wine;proton;gaming;launcher;\n\ - StartupNotify=true\n", + StartupNotify=false\n", exe = exe.display(), - ) -} - -fn systemctl(args: &[&str]) -> Result<()> { - let status = Command::new("systemctl") - .arg("--user") - .args(args) - .status() - .context("Failed to invoke systemctl --user (is systemd installed?)")?; - if !status.success() { - bail!("systemctl --user {} exited non-zero", args.join(" ")); + ); + 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"); } - Ok(()) + s } /// Install only the .desktop file so umutray appears in the app menu. @@ -74,9 +51,9 @@ pub fn install_desktop() -> Result<()> { if let Some(p) = desktop.parent() { std::fs::create_dir_all(p).with_context(|| format!("Failed to create {p:?}"))?; } - std::fs::write(&desktop, render_desktop(&exe)) + std::fs::write(&desktop, render_desktop(&exe, false)) .with_context(|| format!("Failed to write desktop file {desktop:?}"))?; - println!("\x1b[1;32m✓\x1b[0m App menu entry written: {}", desktop.display()); + println!("{} App menu entry written: {}", "✓".green().bold(), desktop.display()); Ok(()) } @@ -93,58 +70,53 @@ pub fn uninstall_desktop() -> Result<()> { Ok(()) } -/// Write the unit + desktop file, reload systemd, and enable+start the service. +/// Write an XDG autostart entry and the app-menu .desktop file. pub fn install() -> Result<()> { let exe = std::env::current_exe().context("Cannot determine path to own executable")?; - // systemd unit - let unit = unit_path()?; - if let Some(p) = unit.parent() { + // 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::write(&unit, render_unit(&exe)) - .with_context(|| format!("Failed to write unit file {unit:?}"))?; - println!("Wrote unit: {}", unit.display()); + std::fs::write(&autostart, render_desktop(&exe, true)) + .with_context(|| format!("Failed to write autostart file {autostart:?}"))?; + println!("Wrote autostart: {}", autostart.display()); - // .desktop file + // App-menu entry install_desktop()?; - println!("Exec: {} gui", exe.display()); - println!(); - - systemctl(&["daemon-reload"])?; - systemctl(&["enable", "--now", UNIT_NAME])?; println!(); - println!("\x1b[1;32m✓\x1b[0m Service installed and started."); - println!(" umutray autostarts with your session and is in the app menu."); - println!(" Status: systemctl --user status {UNIT_NAME}"); - println!(" Logs: journalctl --user -u {UNIT_NAME} -f"); + println!("{} Autostart installed.", "✓".green().bold()); + println!(" umutray will start with your next graphical session."); + println!(" To start now: {}", exe.display()); Ok(()) } -/// Stop, disable, and remove the unit + desktop files. +/// Remove the XDG autostart entry and the app-menu .desktop file. pub fn uninstall() -> Result<()> { - let _ = systemctl(&["disable", "--now", UNIT_NAME]); - - let unit = unit_path()?; - if unit.exists() { - std::fs::remove_file(&unit).with_context(|| format!("Failed to remove {unit:?}"))?; - println!("Removed {}", unit.display()); + let autostart = autostart_path()?; + if autostart.exists() { + std::fs::remove_file(&autostart) + .with_context(|| format!("Failed to remove {autostart:?}"))?; + println!("Removed {}", autostart.display()); } else { - println!("No unit file at {}", unit.display()); + println!("No autostart file at {}", autostart.display()); } uninstall_desktop()?; - let _ = systemctl(&["daemon-reload"]); - println!("\x1b[1;32m✓\x1b[0m Service removed."); + println!("{} Autostart removed.", "✓".green().bold()); Ok(()) } -/// Pass through `systemctl --user status`. +/// Show whether the XDG autostart entry is present. pub fn status() -> Result<()> { - let _ = Command::new("systemctl") - .args(["--user", "status", UNIT_NAME]) - .status(); + let autostart = autostart_path()?; + if autostart.exists() { + println!("{} Autostart enabled: {}", "✓".green().bold(), autostart.display()); + } else { + println!("{} Autostart not installed ({})", "✗".red().bold(), autostart.display()); + } Ok(()) }