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
+91 -27
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 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<String>),
SaveSettings,
ServiceInstall,
ServiceUninstall,
@@ -73,6 +76,7 @@ struct Dashboard {
settings_open: bool,
settings_proton_version: String,
settings_compat_dir: String,
proton_versions: Vec<String>,
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<Message> {
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,17 +153,22 @@ fn update(state: &mut Dashboard, msg: Message) -> Task<Message> {
}
Message::Kill(name) => {
state.last_error = None;
if let Some(l) = state.config.find(&name) {
let Some(l) = state.config.find(&name) else {
return Task::none();
};
let l = l.clone();
match launcher::kill(&l) {
Ok(()) => {
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),
)
}
Err(e) => {
Message::KillDone(name, res) => {
if let Err(e) = res {
state.running.insert(name, true);
state.last_error = Some(format!("Kill failed: {e}"));
}
}
}
Task::none()
}
Message::Play(lname, gname) => {
@@ -379,6 +391,7 @@ fn update(state: &mut Dashboard, msg: Message) -> Task<Message> {
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<Message> {
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> {
}
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> {
}
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<Message> {
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<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![
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),
+53 -10
View File
@@ -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<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).
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<String> {
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);
}
+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::process::Command;
const UNIT_NAME: &str = "umutray.service";
const DESKTOP_NAME: &str = "umutray.desktop";
fn home() -> Result<PathBuf> {
Ok(PathBuf::from(
std::env::var("HOME").context("$HOME is not set")?,
))
dirs::home_dir().context("Cannot determine home directory")
}
fn unit_path() -> Result<PathBuf> {
Ok(home()?.join(".config/systemd/user").join(UNIT_NAME))
fn autostart_path() -> Result<PathBuf> {
Ok(home()?.join(".config/autostart").join(DESKTOP_NAME))
}
fn desktop_path() -> Result<PathBuf> {
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(())
}