feat: GUI dashboard, system Proton scanning, and XDG autostart

GUI dashboard (gui.rs):
- Add detect, diagnose, settings panel, per-launcher games management
- Async Kill with optimistic UI state and rollback on error
- Settings: pick_list for Proton version, browse button for compat dir,
  validation, per-launcher version badge
- Detect Installed button with scan results in footer
- Diagnose card with ✓/✗ checklist per launcher

Issue #1 — Use System Proton (proton.rs):
- Extend list_installed() to scan ~/.steam/root/compatibilitytools.d,
  ~/.local/share/Steam/compatibilitytools.d, and
  /usr/share/steam/compatibilitytools.d in addition to the configured
  compat dir; deduplicates by name so the same version never appears twice

Issue #4 — Remove Systemd Service (service.rs):
- Replace systemd unit management with XDG autostart:
  install() writes ~/.config/autostart/umutray.desktop instead of a
  systemd unit and never calls systemctl; uninstall() removes that file;
  status() checks whether the autostart entry exists
- Update GUI service_is_installed() to check the XDG autostart path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-18 19:28:22 -07:00
parent 9b7e474e80
commit f170171895
3 changed files with 198 additions and 119 deletions
+95 -31
View File
@@ -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 anyhow::Result;
use iced::widget::{ 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::{ use iced::{
Alignment, Background, Border, Color, Element, Length, Padding, Subscription, Task, Theme, Alignment, Background, Border, Color, Element, Length, Padding, Subscription, Task, Theme,
@@ -24,6 +24,7 @@ pub enum Message {
ToggleGamescope(String, String), ToggleGamescope(String, String),
UpdateProton, UpdateProton,
ProtonDone(Result<(), String>), ProtonDone(Result<(), String>),
KillDone(String, Result<(), String>),
// Context menu // Context menu
ShowContextMenu(String), ShowContextMenu(String),
HideContextMenu, HideContextMenu,
@@ -48,6 +49,8 @@ pub enum Message {
HideSettings, HideSettings,
SettingsProtonVersionChanged(String), SettingsProtonVersionChanged(String),
SettingsCompatDirChanged(String), SettingsCompatDirChanged(String),
BrowseCompatDir,
BrowseCompatDirDone(Option<String>),
SaveSettings, SaveSettings,
ServiceInstall, ServiceInstall,
ServiceUninstall, ServiceUninstall,
@@ -73,6 +76,7 @@ struct Dashboard {
settings_open: bool, settings_open: bool,
settings_proton_version: String, settings_proton_version: String,
settings_compat_dir: String, settings_compat_dir: String,
proton_versions: Vec<String>,
service_busy: bool, service_busy: bool,
service_status: String, service_status: String,
} }
@@ -85,6 +89,7 @@ impl Dashboard {
} }
let settings_proton_version = config.proton_version.clone(); let settings_proton_version = config.proton_version.clone();
let settings_compat_dir = config.proton_compat_dir.to_string_lossy().into_owned(); let settings_compat_dir = config.proton_compat_dir.to_string_lossy().into_owned();
let proton_versions = proton::list_installed(&config);
Self { Self {
config, config,
running, running,
@@ -100,6 +105,7 @@ impl Dashboard {
settings_open: false, settings_open: false,
settings_proton_version, settings_proton_version,
settings_compat_dir, settings_compat_dir,
proton_versions,
service_busy: false, service_busy: false,
service_status: String::new(), service_status: String::new(),
} }
@@ -119,6 +125,7 @@ fn update(state: &mut Dashboard, msg: Message) -> Task<Message> {
state state
.running .running
.retain(|k, _| fresh.launchers.iter().any(|l| &l.name == k)); .retain(|k, _| fresh.launchers.iter().any(|l| &l.name == k));
state.proton_versions = proton::list_installed(&fresh);
state.config = fresh; state.config = fresh;
} }
Task::none() Task::none()
@@ -146,16 +153,21 @@ fn update(state: &mut Dashboard, msg: Message) -> Task<Message> {
} }
Message::Kill(name) => { Message::Kill(name) => {
state.last_error = None; state.last_error = None;
if let Some(l) = state.config.find(&name) { let Some(l) = state.config.find(&name) else {
let l = l.clone(); return Task::none();
match launcher::kill(&l) { };
Ok(()) => { let l = l.clone();
state.running.insert(name, false); let name2 = name.clone();
} state.running.insert(name, false);
Err(e) => { Task::perform(
state.last_error = Some(format!("Kill failed: {e}")); 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() Task::none()
} }
@@ -379,6 +391,7 @@ fn update(state: &mut Dashboard, msg: Message) -> Task<Message> {
state.settings_proton_version = state.config.proton_version.clone(); state.settings_proton_version = state.config.proton_version.clone();
state.settings_compat_dir = state.settings_compat_dir =
state.config.proton_compat_dir.to_string_lossy().into_owned(); state.config.proton_compat_dir.to_string_lossy().into_owned();
state.proton_versions = proton::list_installed(&state.config);
state.service_status = String::new(); state.service_status = String::new();
Task::none() Task::none()
} }
@@ -394,12 +407,45 @@ fn update(state: &mut Dashboard, msg: Message) -> Task<Message> {
state.settings_compat_dir = v; state.settings_compat_dir = v;
Task::none() 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 => { Message::SaveSettings => {
state.last_error = None; state.last_error = None;
let version = state.settings_proton_version.trim().to_string(); let version = state.settings_proton_version.trim().to_string();
let compat = PathBuf::from(state.settings_compat_dir.trim()); 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(()) => { Ok(()) => {
state.proton_versions = proton::list_installed(&state.config);
state.service_status = "Settings saved.".into(); state.service_status = "Settings saved.".into();
} }
Err(e) => { Err(e) => {
@@ -410,7 +456,7 @@ fn update(state: &mut Dashboard, msg: Message) -> Task<Message> {
} }
Message::ServiceInstall => { Message::ServiceInstall => {
state.service_busy = true; state.service_busy = true;
state.service_status = "Installing service".into(); state.service_status = "Installing autostart".into();
Task::perform( Task::perform(
async_blocking(|| service::install().map_err(|e| e.to_string())), async_blocking(|| service::install().map_err(|e| e.to_string())),
Message::ServiceActionDone, Message::ServiceActionDone,
@@ -418,7 +464,7 @@ fn update(state: &mut Dashboard, msg: Message) -> Task<Message> {
} }
Message::ServiceUninstall => { Message::ServiceUninstall => {
state.service_busy = true; state.service_busy = true;
state.service_status = "Removing service".into(); state.service_status = "Removing autostart".into();
Task::perform( Task::perform(
async_blocking(|| service::uninstall().map_err(|e| e.to_string())), async_blocking(|| service::uninstall().map_err(|e| e.to_string())),
Message::ServiceActionDone, Message::ServiceActionDone,
@@ -429,9 +475,9 @@ fn update(state: &mut Dashboard, msg: Message) -> Task<Message> {
match res { match res {
Ok(()) => { Ok(()) => {
state.service_status = if service_is_installed() { state.service_status = if service_is_installed() {
"Service installed — autostarts on login.".into() "Autostart enabled — starts on next login.".into()
} else { } else {
"Service removed.".into() "Autostart removed.".into()
}; };
} }
Err(e) => { Err(e) => {
@@ -464,13 +510,8 @@ fn toggle_flag(
} }
fn service_is_installed() -> bool { fn service_is_installed() -> bool {
std::env::var("HOME") dirs::home_dir()
.ok() .map(|h| h.join(".config/autostart/umutray.desktop").exists())
.map(|h| {
PathBuf::from(h)
.join(".config/systemd/user/umutray.service")
.exists()
})
.unwrap_or(false) .unwrap_or(false)
} }
@@ -671,10 +712,22 @@ fn launcher_card<'a>(
} }
}; };
let version_badge: Element<Message> = 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![ let header = row![
text(&l.display).size(15), text(&l.display).size(15),
text("").size(12), text("").size(12),
text(status_label).size(12), text(status_label).size(12),
version_badge,
iced::widget::horizontal_space(), iced::widget::horizontal_space(),
action, action,
] ]
@@ -870,17 +923,28 @@ fn view_settings(state: &Dashboard) -> Element<'_, Message> {
] ]
.align_y(Alignment::Center); .align_y(Alignment::Center);
let proton_version_input = let proton_version_picker = pick_list(
text_input("e.g. GE-Proton or GE-Proton10-34", &state.settings_proton_version) state.proton_versions.as_slice(),
.on_input(Message::SettingsProtonVersionChanged) Some(if state.settings_proton_version == "GE-Proton" {
.padding(8); "GE-Proton (latest)".to_string()
} else {
state.settings_proton_version.clone()
}),
Message::SettingsProtonVersionChanged,
)
.width(Length::Fill);
let compat_dir_input = text_input( let compat_dir_input = text_input(
"e.g. ~/.local/share/Steam/compatibilitytools.d", "e.g. ~/.local/share/Steam/compatibilitytools.d",
&state.settings_compat_dir, &state.settings_compat_dir,
) )
.on_input(Message::SettingsCompatDirChanged) .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)) let save_btn = button(text("Save").size(13))
.on_press(Message::SaveSettings) .on_press(Message::SaveSettings)
@@ -909,9 +973,9 @@ fn view_settings(state: &Dashboard) -> Element<'_, Message> {
header, header,
iced::widget::horizontal_rule(1), iced::widget::horizontal_rule(1),
text("Proton version").size(13), text("Proton version").size(13),
proton_version_input, proton_version_picker,
text("GE-Proton compat directory").size(13), 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, save_btn,
iced::widget::horizontal_rule(1), iced::widget::horizontal_rule(1),
text(svc_status_label).size(13), text(svc_status_label).size(13),
+53 -10
View File
@@ -1,7 +1,10 @@
use crate::config::Config; use crate::config::Config;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use owo_colors::OwoColorize;
use serde::Deserialize; use serde::Deserialize;
use std::collections::HashSet;
use std::io::Write; use std::io::Write;
use std::path::PathBuf;
const GITHUB_API: &str = "https://api.github.com/repos/GloriousEggroll/proton-ge-custom/releases"; 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(()) Ok(())
} }
/// Return all GE-Proton* directories found in `dir`.
fn scan_ge_proton_in(dir: &PathBuf, seen: &mut HashSet<String>, out: &mut Vec<String>) {
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<String> {
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<String> {
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). /// Install the latest GE-Proton release (called from tray menu).
pub fn install_latest(config: &Config) -> Result<()> { pub fn install_latest(config: &Config) -> Result<()> {
println!("Checking for latest GE-Proton..."); println!("Checking for latest GE-Proton...");
@@ -152,11 +203,7 @@ fn print_list(config: &Config) -> Result<()> {
let releases = fetch_releases(10)?; let releases = fetch_releases(10)?;
for r in &releases { for r in &releases {
let installed = config.proton_compat_dir.join(&r.tag_name).exists(); let installed = config.proton_compat_dir.join(&r.tag_name).exists();
let marker = if installed { let marker = if installed { format!(" {}", "✓ installed".green().bold()) } else { String::new() };
" \x1b[1;32m✓ installed\x1b[0m"
} else {
""
};
println!(" {}{}", r.tag_name, marker); println!(" {}{}", r.tag_name, marker);
} }
Ok(()) Ok(())
@@ -220,11 +267,7 @@ fn pick_interactively(config: &Config) -> Result<String> {
println!("Recent GE-Proton releases:"); println!("Recent GE-Proton releases:");
for (i, r) in releases.iter().enumerate() { for (i, r) in releases.iter().enumerate() {
let installed = config.proton_compat_dir.join(&r.tag_name).exists(); let installed = config.proton_compat_dir.join(&r.tag_name).exists();
let marker = if installed { let marker = if installed { format!(" {}", "".green().bold()) } else { String::new() };
" \x1b[1;32m✓\x1b[0m"
} else {
""
};
println!(" {:2}) {}{}", i + 1, r.tag_name, marker); println!(" {:2}) {}{}", i + 1, r.tag_name, marker);
} }
+50 -78
View File
@@ -1,69 +1,46 @@
use anyhow::{bail, Context, Result}; use anyhow::{Context, Result};
use owo_colors::OwoColorize;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command;
const UNIT_NAME: &str = "umutray.service";
const DESKTOP_NAME: &str = "umutray.desktop"; const DESKTOP_NAME: &str = "umutray.desktop";
fn home() -> Result<PathBuf> { fn home() -> Result<PathBuf> {
Ok(PathBuf::from( dirs::home_dir().context("Cannot determine home directory")
std::env::var("HOME").context("$HOME is not set")?,
))
} }
fn unit_path() -> Result<PathBuf> { fn autostart_path() -> Result<PathBuf> {
Ok(home()?.join(".config/systemd/user").join(UNIT_NAME)) Ok(home()?.join(".config/autostart").join(DESKTOP_NAME))
} }
fn desktop_path() -> Result<PathBuf> { fn desktop_path() -> Result<PathBuf> {
Ok(home()? Ok(home()?.join(".local/share/applications").join(DESKTOP_NAME))
.join(".local/share/applications")
.join(DESKTOP_NAME))
} }
fn render_unit(exe: &std::path::Path) -> String { fn render_desktop(exe: &std::path::Path, autostart: bool) -> String {
format!( let mut s = 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!(
"[Desktop Entry]\n\ "[Desktop Entry]\n\
Name=umutray\n\ Name=umutray\n\
Comment=Wine launcher manager for Windows game launchers\n\ Comment=Wine launcher manager for Windows game launchers\n\
Exec={exe} gui\n\ Exec={exe}\n\
Icon=applications-games\n\ Icon=applications-games\n\
Type=Application\n\ Type=Application\n\
Categories=Game;\n\ Categories=Game;\n\
Keywords=wine;proton;gaming;launcher;\n\ Keywords=wine;proton;gaming;launcher;\n\
StartupNotify=true\n", StartupNotify=false\n",
exe = exe.display(), exe = exe.display(),
) );
} if autostart {
s.push_str("X-GNOME-Autostart-enabled=true\n");
fn systemctl(args: &[&str]) -> Result<()> { s.push_str("Hidden=false\n");
let status = Command::new("systemctl") } else {
.arg("--user") // App-menu entry launches the GUI
.args(args) s = s.replace(
.status() &format!("Exec={}", exe.display()),
.context("Failed to invoke systemctl --user (is systemd installed?)")?; &format!("Exec={} gui", exe.display()),
if !status.success() { );
bail!("systemctl --user {} exited non-zero", args.join(" ")); s.push_str("StartupNotify=true\n");
} }
Ok(()) s
} }
/// Install only the .desktop file so umutray appears in the app menu. /// 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() { 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:?}"))?;
} }
std::fs::write(&desktop, render_desktop(&exe)) 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:?}"))?;
println!("\x1b[1;32m✓\x1b[0m App menu entry written: {}", desktop.display()); println!("{} App menu entry written: {}", "".green().bold(), desktop.display());
Ok(()) Ok(())
} }
@@ -93,58 +70,53 @@ pub fn uninstall_desktop() -> Result<()> {
Ok(()) 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<()> { pub fn install() -> Result<()> {
let exe = std::env::current_exe().context("Cannot determine path to own executable")?; let exe = std::env::current_exe().context("Cannot determine path to own executable")?;
// systemd unit // XDG autostart
let unit = unit_path()?; let autostart = autostart_path()?;
if let Some(p) = unit.parent() { 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:?}"))?;
} }
std::fs::write(&unit, render_unit(&exe)) std::fs::write(&autostart, render_desktop(&exe, true))
.with_context(|| format!("Failed to write unit file {unit:?}"))?; .with_context(|| format!("Failed to write autostart file {autostart:?}"))?;
println!("Wrote unit: {}", unit.display()); println!("Wrote autostart: {}", autostart.display());
// .desktop file // App-menu entry
install_desktop()?; install_desktop()?;
println!("Exec: {} gui", exe.display());
println!();
systemctl(&["daemon-reload"])?;
systemctl(&["enable", "--now", UNIT_NAME])?;
println!(); println!();
println!("\x1b[1;32m✓\x1b[0m Service installed and started."); println!("{} Autostart installed.", "".green().bold());
println!(" umutray autostarts with your session and is in the app menu."); println!(" umutray will start with your next graphical session.");
println!(" Status: systemctl --user status {UNIT_NAME}"); println!(" To start now: {}", exe.display());
println!(" Logs: journalctl --user -u {UNIT_NAME} -f");
Ok(()) Ok(())
} }
/// Stop, disable, and remove the unit + desktop files. /// Remove the XDG autostart entry and the app-menu .desktop file.
pub fn uninstall() -> Result<()> { pub fn uninstall() -> Result<()> {
let _ = systemctl(&["disable", "--now", UNIT_NAME]); let autostart = autostart_path()?;
if autostart.exists() {
let unit = unit_path()?; std::fs::remove_file(&autostart)
if unit.exists() { .with_context(|| format!("Failed to remove {autostart:?}"))?;
std::fs::remove_file(&unit).with_context(|| format!("Failed to remove {unit:?}"))?; println!("Removed {}", autostart.display());
println!("Removed {}", unit.display());
} else { } else {
println!("No unit file at {}", unit.display()); println!("No autostart file at {}", autostart.display());
} }
uninstall_desktop()?; uninstall_desktop()?;
let _ = systemctl(&["daemon-reload"]); println!("{} Autostart removed.", "".green().bold());
println!("\x1b[1;32m✓\x1b[0m Service removed.");
Ok(()) Ok(())
} }
/// Pass through `systemctl --user status`. /// Show whether the XDG autostart entry is present.
pub fn status() -> Result<()> { pub fn status() -> Result<()> {
let _ = Command::new("systemctl") let autostart = autostart_path()?;
.args(["--user", "status", UNIT_NAME]) if autostart.exists() {
.status(); println!("{} Autostart enabled: {}", "".green().bold(), autostart.display());
} else {
println!("{} Autostart not installed ({})", "".red().bold(), autostart.display());
}
Ok(()) Ok(())
} }