Compare commits
2 Commits
f2f584febf
...
f170171895
| Author | SHA1 | Date | |
|---|---|---|---|
| f170171895 | |||
| 9b7e474e80 |
@@ -0,0 +1,22 @@
|
|||||||
|
## umutray refactor tasks
|
||||||
|
|
||||||
|
Work through these issues identified in code review. Address them one at a time and confirm before moving on.
|
||||||
|
|
||||||
|
### Packaging
|
||||||
|
- [ ] Remove the Makefile and replace with a proper Arch PKGBUILD following https://wiki.archlinux.org/title/Rust_package_guidelines
|
||||||
|
- [ ] Create a separate repo for the PKGBUILD (keeps packaging out of source repo and makes it AUR-uploadable). Reference the local repo path in the PKGBUILD so it always builds the latest version without pushing/pulling.
|
||||||
|
|
||||||
|
### Systemd / tray architecture
|
||||||
|
- [ ] Remove runtime systemd unit file generation from Rust code — unit files should be static files shipped in the AUR package, not generated at runtime by the app.
|
||||||
|
- [ ] Reconsider the `service install` command — the tray icon should use the StatusNotifierItem/AppIndicator XDG protocol (https://www.freedesktop.org/wiki/Specifications/StatusNotifierItem) rather than a systemd service.
|
||||||
|
|
||||||
|
### Code quality
|
||||||
|
- [ ] Add `#![forbid(unsafe_code)]` to the top of main.rs to enforce safe Rust project-wide.
|
||||||
|
- [ ] Replace manual terminal color escape codes (in main.rs and detect.rs) with a crate like `colored` or `owo-colors`.
|
||||||
|
- [ ] Replace manual home directory path construction in config.rs (~L88) with the `dirs` crate.
|
||||||
|
|
||||||
|
### UX / GUI
|
||||||
|
- [ ] Fix blocking UI on long-running button actions (launch, kill, download) — use iced Command/async tasks so the UI keeps rendering and shows a loading state.
|
||||||
|
|
||||||
|
### Misc
|
||||||
|
- [ ] Audit and document or refactor the unclear code at main.rs:307.
|
||||||
Generated
+18
-1
@@ -866,7 +866,7 @@ checksum = "2a76fa97167fa740dcdbfe18e8895601e1bc36525f09b044e00916e717c03a3c"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"dconf_rs",
|
"dconf_rs",
|
||||||
"detect-desktop-environment",
|
"detect-desktop-environment",
|
||||||
"dirs",
|
"dirs 4.0.0",
|
||||||
"objc",
|
"objc",
|
||||||
"rust-ini",
|
"rust-ini",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
@@ -945,6 +945,15 @@ dependencies = [
|
|||||||
"dirs-sys 0.3.7",
|
"dirs-sys 0.3.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dirs"
|
||||||
|
version = "5.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
|
||||||
|
dependencies = [
|
||||||
|
"dirs-sys 0.4.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dirs-sys"
|
name = "dirs-sys"
|
||||||
version = "0.3.7"
|
version = "0.3.7"
|
||||||
@@ -2928,6 +2937,12 @@ dependencies = [
|
|||||||
"ttf-parser 0.25.1",
|
"ttf-parser 0.25.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "owo-colors"
|
||||||
|
version = "4.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "palette"
|
name = "palette"
|
||||||
version = "0.7.6"
|
version = "0.7.6"
|
||||||
@@ -4407,8 +4422,10 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"clap 4.6.1",
|
"clap 4.6.1",
|
||||||
"directories",
|
"directories",
|
||||||
|
"dirs 5.0.1",
|
||||||
"iced",
|
"iced",
|
||||||
"ksni",
|
"ksni",
|
||||||
|
"owo-colors",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ anyhow = "1"
|
|||||||
# XDG config / data paths
|
# XDG config / data paths
|
||||||
directories = "5"
|
directories = "5"
|
||||||
|
|
||||||
|
# Home directory lookup
|
||||||
|
dirs = "5"
|
||||||
|
|
||||||
|
# Terminal colour output
|
||||||
|
owo-colors = "4"
|
||||||
|
|
||||||
# System tray via D-Bus StatusNotifierItem (KDE, GNOME+AppIndicator, Xfce, etc.)
|
# System tray via D-Bus StatusNotifierItem (KDE, GNOME+AppIndicator, Xfce, etc.)
|
||||||
ksni = "0.2"
|
ksni = "0.2"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Maintainer: funman300 <funman300@gmail.com>
|
||||||
|
#
|
||||||
|
# AUR NOTE: This PKGBUILD is kept in the source repo for convenience during
|
||||||
|
# development. For AUR submission, move it to a separate repository and update
|
||||||
|
# the `source` array to point to the upstream git URL instead of a local path.
|
||||||
|
#
|
||||||
|
# To build locally:
|
||||||
|
# cd packaging && makepkg -si
|
||||||
|
#
|
||||||
|
# To update for AUR, replace the source line with:
|
||||||
|
# source=("$pkgname::git+https://git.aleshym.co/funman300/umutray.git#tag=v$pkgver")
|
||||||
|
|
||||||
|
pkgname=umutray
|
||||||
|
pkgver=0.1.0
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc='Tray-based Wine launcher manager for Linux via umu/Proton-GE'
|
||||||
|
arch=('x86_64')
|
||||||
|
url='https://git.aleshym.co/funman300/umutray'
|
||||||
|
license=('MIT')
|
||||||
|
depends=('umu-launcher')
|
||||||
|
makedepends=('rust' 'cargo')
|
||||||
|
optdepends=(
|
||||||
|
'zenity: folder picker in setup wizard (GNOME/GTK)'
|
||||||
|
'kdialog: folder picker in setup wizard (KDE)'
|
||||||
|
'gamemode: per-game GameMode support'
|
||||||
|
'mangohud: per-game MangoHud overlay'
|
||||||
|
'gamescope: per-game Gamescope compositor'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build from the local repository. Change to the upstream URL for AUR.
|
||||||
|
_localrepo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
source=("$pkgname::git+file://${_localrepo}")
|
||||||
|
sha256sums=('SKIP')
|
||||||
|
|
||||||
|
prepare() {
|
||||||
|
cd "$pkgname"
|
||||||
|
export RUSTUP_TOOLCHAIN=stable
|
||||||
|
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
cd "$pkgname"
|
||||||
|
export RUSTUP_TOOLCHAIN=stable
|
||||||
|
export CARGO_TARGET_DIR=target
|
||||||
|
cargo build --release --locked --offline
|
||||||
|
}
|
||||||
|
|
||||||
|
package() {
|
||||||
|
cd "$pkgname"
|
||||||
|
|
||||||
|
# Binary
|
||||||
|
install -Dm755 target/release/umutray "$pkgdir/usr/bin/umutray"
|
||||||
|
|
||||||
|
# App menu entry (.desktop uses /usr/bin/umutray as the exec path)
|
||||||
|
install -Dm644 umutray.desktop "$pkgdir/usr/share/applications/umutray.desktop"
|
||||||
|
sed -i "s|Exec=umutray|Exec=/usr/bin/umutray|" \
|
||||||
|
"$pkgdir/usr/share/applications/umutray.desktop"
|
||||||
|
|
||||||
|
# Systemd user service (static file — no runtime generation needed)
|
||||||
|
install -Dm644 umutray.service \
|
||||||
|
"$pkgdir/usr/lib/systemd/user/umutray.service"
|
||||||
|
|
||||||
|
# License
|
||||||
|
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
|
}
|
||||||
+27
-16
@@ -1,5 +1,6 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use directories::ProjectDirs;
|
use directories::ProjectDirs;
|
||||||
|
use owo_colors::OwoColorize;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
@@ -86,9 +87,7 @@ pub struct Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn home_dir() -> PathBuf {
|
fn home_dir() -> PathBuf {
|
||||||
std::env::var("HOME")
|
dirs::home_dir().expect("Cannot determine home directory")
|
||||||
.map(PathBuf::from)
|
|
||||||
.expect("$HOME is not set; cannot determine default paths")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_compat_dir() -> PathBuf {
|
fn default_compat_dir() -> PathBuf {
|
||||||
@@ -128,7 +127,9 @@ pub fn presets() -> Vec<Launcher> {
|
|||||||
),
|
),
|
||||||
gameid: "umu-battlenet".into(),
|
gameid: "umu-battlenet".into(),
|
||||||
process_pattern: r"Battle\.net".into(),
|
process_pattern: r"Battle\.net".into(),
|
||||||
installer_url: None,
|
installer_url: Some(
|
||||||
|
"https://www.battle.net/download/getInstallerForGame?os=win&gameProgram=BATTLENET_APP&version=Live".into(),
|
||||||
|
),
|
||||||
proton_version: None,
|
proton_version: None,
|
||||||
games: vec![],
|
games: vec![],
|
||||||
},
|
},
|
||||||
@@ -141,7 +142,9 @@ pub fn presets() -> Vec<Launcher> {
|
|||||||
),
|
),
|
||||||
gameid: "umu-eaapp".into(),
|
gameid: "umu-eaapp".into(),
|
||||||
process_pattern: r"EADesktop\.exe".into(),
|
process_pattern: r"EADesktop\.exe".into(),
|
||||||
installer_url: None,
|
installer_url: Some(
|
||||||
|
"https://origin-a.akamaihd.net/EA-Desktop-Client-Download/installer-releases/EAappInstaller.exe".into(),
|
||||||
|
),
|
||||||
proton_version: None,
|
proton_version: None,
|
||||||
games: vec![],
|
games: vec![],
|
||||||
},
|
},
|
||||||
@@ -154,7 +157,9 @@ pub fn presets() -> Vec<Launcher> {
|
|||||||
),
|
),
|
||||||
gameid: "umu-epicgameslauncher".into(),
|
gameid: "umu-epicgameslauncher".into(),
|
||||||
process_pattern: r"EpicGamesLauncher\.exe".into(),
|
process_pattern: r"EpicGamesLauncher\.exe".into(),
|
||||||
installer_url: None,
|
installer_url: Some(
|
||||||
|
"https://launcher-public-service-prod06.ol.epicgames.com/launcher/api/installer/download/EpicGamesLauncherInstaller.msi".into(),
|
||||||
|
),
|
||||||
proton_version: None,
|
proton_version: None,
|
||||||
games: vec![],
|
games: vec![],
|
||||||
},
|
},
|
||||||
@@ -167,7 +172,9 @@ pub fn presets() -> Vec<Launcher> {
|
|||||||
),
|
),
|
||||||
gameid: "umu-uplay".into(),
|
gameid: "umu-uplay".into(),
|
||||||
process_pattern: r"UbisoftConnect\.exe|upc\.exe".into(),
|
process_pattern: r"UbisoftConnect\.exe|upc\.exe".into(),
|
||||||
installer_url: None,
|
installer_url: Some(
|
||||||
|
"https://ubistatic3-a.akamaihd.net/orbit/launcher_installer/UbisoftConnectInstaller.exe".into(),
|
||||||
|
),
|
||||||
proton_version: None,
|
proton_version: None,
|
||||||
games: vec![],
|
games: vec![],
|
||||||
},
|
},
|
||||||
@@ -180,7 +187,9 @@ pub fn presets() -> Vec<Launcher> {
|
|||||||
),
|
),
|
||||||
gameid: "umu-gog".into(),
|
gameid: "umu-gog".into(),
|
||||||
process_pattern: r"GalaxyClient\.exe".into(),
|
process_pattern: r"GalaxyClient\.exe".into(),
|
||||||
installer_url: None,
|
installer_url: Some(
|
||||||
|
"https://webinstallers.gog-statics.com/download/GOG_Galaxy_2.0.exe".into(),
|
||||||
|
),
|
||||||
proton_version: None,
|
proton_version: None,
|
||||||
games: vec![],
|
games: vec![],
|
||||||
},
|
},
|
||||||
@@ -193,7 +202,9 @@ pub fn presets() -> Vec<Launcher> {
|
|||||||
),
|
),
|
||||||
gameid: "umu-rockstar".into(),
|
gameid: "umu-rockstar".into(),
|
||||||
process_pattern: r"Rockstar Games.*Launcher\.exe".into(),
|
process_pattern: r"Rockstar Games.*Launcher\.exe".into(),
|
||||||
installer_url: None,
|
installer_url: Some(
|
||||||
|
"https://gamedownloads.rockstargames.com/public/installer/Rockstar-Games-Launcher.exe".into(),
|
||||||
|
),
|
||||||
proton_version: None,
|
proton_version: None,
|
||||||
games: vec![],
|
games: vec![],
|
||||||
},
|
},
|
||||||
@@ -320,7 +331,7 @@ impl Config {
|
|||||||
games: vec![],
|
games: vec![],
|
||||||
});
|
});
|
||||||
self.save()?;
|
self.save()?;
|
||||||
println!("\x1b[1;32m✓\x1b[0m Added launcher '{name}'.");
|
println!("{} Added launcher '{name}'.", "✓".green().bold());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,7 +365,7 @@ impl Config {
|
|||||||
gamescope,
|
gamescope,
|
||||||
});
|
});
|
||||||
self.save()?;
|
self.save()?;
|
||||||
println!("\x1b[1;32m✓\x1b[0m Added game '{name}' under launcher '{launcher}'.");
|
println!("{} Added game '{name}' under launcher '{launcher}'.", "✓".green().bold());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,7 +381,7 @@ impl Config {
|
|||||||
anyhow::bail!("launcher '{launcher}' has no game named '{name}'");
|
anyhow::bail!("launcher '{launcher}' has no game named '{name}'");
|
||||||
}
|
}
|
||||||
self.save()?;
|
self.save()?;
|
||||||
println!("\x1b[1;32m✓\x1b[0m Removed game '{name}' from '{launcher}'.");
|
println!("{} Removed game '{name}' from '{launcher}'.", "✓".green().bold());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,7 +419,7 @@ impl Config {
|
|||||||
g.gamescope = v;
|
g.gamescope = v;
|
||||||
}
|
}
|
||||||
self.save()?;
|
self.save()?;
|
||||||
println!("\x1b[1;32m✓\x1b[0m Updated flags for '{launcher}/{name}'.");
|
println!("{} Updated flags for '{launcher}/{name}'.", "✓".green().bold());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,8 +431,8 @@ impl Config {
|
|||||||
}
|
}
|
||||||
self.save()?;
|
self.save()?;
|
||||||
println!(
|
println!(
|
||||||
"\x1b[1;32m✓\x1b[0m Removed '{name}'. \
|
"{} Removed '{name}'. The Wine prefix on disk was left untouched.",
|
||||||
The Wine prefix on disk was left untouched."
|
"✓".green().bold()
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -443,7 +454,7 @@ impl Config {
|
|||||||
self.proton_compat_dir = d;
|
self.proton_compat_dir = d;
|
||||||
}
|
}
|
||||||
self.save()?;
|
self.save()?;
|
||||||
println!("\x1b[1;32m✓\x1b[0m Config saved.");
|
println!("{} Config saved.", "✓".green().bold());
|
||||||
println!();
|
println!();
|
||||||
self.show()
|
self.show()
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-9
@@ -1,5 +1,6 @@
|
|||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use owo_colors::OwoColorize;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
@@ -135,7 +136,7 @@ fn print_findings(config: &Config, by_launcher: &HashMap<String, Vec<PathBuf>>)
|
|||||||
println!(" · {:12} not found", l.name);
|
println!(" · {:12} not found", l.name);
|
||||||
}
|
}
|
||||||
Some(matches) if matches.len() > 1 => {
|
Some(matches) if matches.len() > 1 => {
|
||||||
println!(" \x1b[33m⚠\x1b[0m {:12} multiple prefixes:", l.name);
|
println!(" {} {:12} multiple prefixes:", "⚠".yellow(), l.name);
|
||||||
for p in matches {
|
for p in matches {
|
||||||
println!(" {}", p.display());
|
println!(" {}", p.display());
|
||||||
}
|
}
|
||||||
@@ -143,11 +144,12 @@ fn print_findings(config: &Config, by_launcher: &HashMap<String, Vec<PathBuf>>)
|
|||||||
Some(matches) => {
|
Some(matches) => {
|
||||||
let detected = &matches[0];
|
let detected = &matches[0];
|
||||||
if *detected == l.prefix_dir {
|
if *detected == l.prefix_dir {
|
||||||
println!(" \x1b[1;32m✓\x1b[0m {:12} {}", l.name, detected.display());
|
println!(" {} {:12} {}", "✓".green().bold(), l.name, detected.display());
|
||||||
} else {
|
} else {
|
||||||
any_divergent = true;
|
any_divergent = true;
|
||||||
println!(
|
println!(
|
||||||
" \x1b[36m→\x1b[0m {:12} {} (was {})",
|
" {} {:12} {} (was {})",
|
||||||
|
"→".cyan(),
|
||||||
l.name,
|
l.name,
|
||||||
detected.display(),
|
detected.display(),
|
||||||
l.prefix_dir.display()
|
l.prefix_dir.display()
|
||||||
@@ -171,19 +173,17 @@ fn apply_findings(config: &Config, by_launcher: &HashMap<String, Vec<PathBuf>>)
|
|||||||
};
|
};
|
||||||
if matches.len() > 1 {
|
if matches.len() > 1 {
|
||||||
ambiguous += 1;
|
ambiguous += 1;
|
||||||
println!(
|
println!(" {} {:12} ambiguous — update via `config edit`", "⚠".yellow(), l.name);
|
||||||
" \x1b[33m⚠\x1b[0m {:12} ambiguous — update via `config edit`",
|
|
||||||
l.name
|
|
||||||
);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let detected = &matches[0];
|
let detected = &matches[0];
|
||||||
if *detected == l.prefix_dir {
|
if *detected == l.prefix_dir {
|
||||||
println!(" \x1b[1;32m✓\x1b[0m {:12} unchanged", l.name);
|
println!(" {} {:12} unchanged", "✓".green().bold(), l.name);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
println!(
|
println!(
|
||||||
" \x1b[1;32m→\x1b[0m {:12} {} → {}",
|
" {} {:12} {} → {}",
|
||||||
|
"→".green().bold(),
|
||||||
l.name,
|
l.name,
|
||||||
l.prefix_dir.display(),
|
l.prefix_dir.display(),
|
||||||
detected.display()
|
detected.display()
|
||||||
|
|||||||
+7
-7
@@ -1,5 +1,6 @@
|
|||||||
use crate::config::{Config, Launcher};
|
use crate::config::{Config, Launcher};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use owo_colors::OwoColorize;
|
||||||
use std::os::unix::fs::MetadataExt;
|
use std::os::unix::fs::MetadataExt;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
@@ -50,18 +51,17 @@ pub fn run(config: &Config, name: Option<&str>) -> Result<()> {
|
|||||||
if !c.pass {
|
if !c.pass {
|
||||||
issues += 1;
|
issues += 1;
|
||||||
}
|
}
|
||||||
let (sym, col, rst) = if c.pass {
|
if c.pass {
|
||||||
("✓", "\x1b[1;32m", "\x1b[0m")
|
println!(" {} {:24} {}", "✓".green().bold(), c.label, c.detail);
|
||||||
} else {
|
} else {
|
||||||
("✗", "\x1b[1;31m", "\x1b[0m")
|
println!(" {} {:24} {}", "✗".red().bold(), c.label, c.detail);
|
||||||
};
|
}
|
||||||
println!(" {col}{sym}{rst} {:24} {}", c.label, c.detail);
|
|
||||||
}
|
}
|
||||||
println!();
|
println!();
|
||||||
if issues == 0 {
|
if issues == 0 {
|
||||||
println!(" \x1b[1;32mAll checks passed.\x1b[0m");
|
println!(" {}", "All checks passed.".green().bold());
|
||||||
} else {
|
} else {
|
||||||
println!(" \x1b[1;31m{issues} issue(s) found — see ✗ items above.\x1b[0m");
|
println!(" {}", format!("{issues} issue(s) found — see ✗ items above.").red().bold());
|
||||||
}
|
}
|
||||||
println!();
|
println!();
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
+91
-27
@@ -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,17 +153,22 @@ 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 {
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
let l = l.clone();
|
let l = l.clone();
|
||||||
match launcher::kill(&l) {
|
let name2 = name.clone();
|
||||||
Ok(()) => {
|
|
||||||
state.running.insert(name, false);
|
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}"));
|
state.last_error = Some(format!("Kill failed: {e}"));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::Play(lname, gname) => {
|
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_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),
|
||||||
|
|||||||
+14
-15
@@ -1,3 +1,5 @@
|
|||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
mod config;
|
mod config;
|
||||||
mod detect;
|
mod detect;
|
||||||
mod diagnose;
|
mod diagnose;
|
||||||
@@ -11,6 +13,7 @@ mod util;
|
|||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
|
use owo_colors::OwoColorize;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
/// Tray-based Wine launcher manager for Linux via umu/Proton-GE.
|
/// Tray-based Wine launcher manager for Linux via umu/Proton-GE.
|
||||||
@@ -105,7 +108,7 @@ enum Commands {
|
|||||||
action: ConfigAction,
|
action: ConfigAction,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Manage the systemd --user service that autostarts the tray
|
/// Manage the XDG autostart entry that starts the tray on login
|
||||||
Service {
|
Service {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
action: ServiceAction,
|
action: ServiceAction,
|
||||||
@@ -219,13 +222,13 @@ enum ConfigAction {
|
|||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum ServiceAction {
|
enum ServiceAction {
|
||||||
/// Write the unit, daemon-reload, and enable+start the service (includes app menu entry)
|
/// Write ~/.config/autostart/umutray.desktop so the tray starts on login (includes app menu entry)
|
||||||
Install,
|
Install,
|
||||||
/// Stop, disable, and remove the unit file (includes app menu entry)
|
/// Remove the autostart entry and app menu entry
|
||||||
Uninstall,
|
Uninstall,
|
||||||
/// Show `systemctl --user status` for the service
|
/// Show whether the XDG autostart entry is present
|
||||||
Status,
|
Status,
|
||||||
/// Install only the app menu entry — no systemd service required
|
/// Install only the app menu entry
|
||||||
InstallDesktop,
|
InstallDesktop,
|
||||||
/// Remove the app menu entry
|
/// Remove the app menu entry
|
||||||
UninstallDesktop,
|
UninstallDesktop,
|
||||||
@@ -263,11 +266,7 @@ fn main() -> Result<()> {
|
|||||||
for l in &config.launchers {
|
for l in &config.launchers {
|
||||||
let installed = l.full_exe_path().exists();
|
let installed = l.full_exe_path().exists();
|
||||||
let running = launcher::is_running(l);
|
let running = launcher::is_running(l);
|
||||||
let marker = if installed {
|
let marker = if installed { "✓".green().bold().to_string() } else { "·".to_string() };
|
||||||
"\x1b[1;32m✓\x1b[0m"
|
|
||||||
} else {
|
|
||||||
"·"
|
|
||||||
};
|
|
||||||
let state = if running { " (running)" } else { "" };
|
let state = if running { " (running)" } else { "" };
|
||||||
println!(" {marker} {:12} {}{}", l.name, l.display, state);
|
println!(" {marker} {:12} {}{}", l.name, l.display, state);
|
||||||
}
|
}
|
||||||
@@ -303,11 +302,7 @@ fn main() -> Result<()> {
|
|||||||
println!(" {}:", l.display);
|
println!(" {}:", l.display);
|
||||||
for g in &l.games {
|
for g in &l.games {
|
||||||
let installed = g.full_exe_path(l).exists();
|
let installed = g.full_exe_path(l).exists();
|
||||||
let marker = if installed {
|
let marker = if installed { "✓".green().bold().to_string() } else { "·".to_string() };
|
||||||
"\x1b[1;32m✓\x1b[0m"
|
|
||||||
} else {
|
|
||||||
"·"
|
|
||||||
};
|
|
||||||
let flags = format_game_flags(g);
|
let flags = format_game_flags(g);
|
||||||
println!(" {marker} {:14} {}{}", g.name, g.display, flags);
|
println!(" {marker} {:14} {}{}", g.name, g.display, flags);
|
||||||
}
|
}
|
||||||
@@ -403,6 +398,10 @@ fn main() -> Result<()> {
|
|||||||
gamescope,
|
gamescope,
|
||||||
no_gamescope,
|
no_gamescope,
|
||||||
} => {
|
} => {
|
||||||
|
// gs_update is Option<Option<Vec<String>>> where:
|
||||||
|
// None = leave gamescope unchanged
|
||||||
|
// Some(None) = disable gamescope
|
||||||
|
// Some(Some(args)) = enable gamescope with these CLI args
|
||||||
let gs_update = if no_gamescope {
|
let gs_update = if no_gamescope {
|
||||||
Some(None)
|
Some(None)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+53
-10
@@ -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
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-9
@@ -1,5 +1,4 @@
|
|||||||
use crate::{config::{self, Config, Launcher}, util::async_blocking};
|
use crate::{config::{self, Config, Launcher}, util::{async_blocking, pick_folder}};
|
||||||
use rfd;
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use iced::widget::{
|
use iced::widget::{
|
||||||
button, column, container, pick_list, progress_bar, row, scrollable, text, text_input, Column,
|
button, column, container, pick_list, progress_bar, row, scrollable, text, text_input, Column,
|
||||||
@@ -141,13 +140,7 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
|
|||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::BrowsePrefix => Task::perform(
|
Message::BrowsePrefix => Task::perform(
|
||||||
async {
|
async_blocking(|| pick_folder("Choose install location (Wine prefix)")),
|
||||||
rfd::AsyncFileDialog::new()
|
|
||||||
.set_title("Choose install location (Wine prefix)")
|
|
||||||
.pick_folder()
|
|
||||||
.await
|
|
||||||
.map(|h| h.path().to_string_lossy().into_owned())
|
|
||||||
},
|
|
||||||
Message::BrowsePrefixDone,
|
Message::BrowsePrefixDone,
|
||||||
),
|
),
|
||||||
Message::BrowsePrefixDone(path) => {
|
Message::BrowsePrefixDone(path) => {
|
||||||
@@ -174,6 +167,28 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
|
|||||||
};
|
};
|
||||||
preset.prefix_dir = PathBuf::from(&prefix);
|
preset.prefix_dir = PathBuf::from(&prefix);
|
||||||
|
|
||||||
|
// If we know the official installer URL, pre-fill source and
|
||||||
|
// kick off the download automatically so the user doesn't have
|
||||||
|
// to find or paste anything.
|
||||||
|
if let Some(url) = preset.installer_url.clone() {
|
||||||
|
state.source = url.clone();
|
||||||
|
state.stage = Stage::Busy;
|
||||||
|
state.status = format!(
|
||||||
|
"Found official installer — downloading to {}…",
|
||||||
|
preset.prefix_dir.display()
|
||||||
|
);
|
||||||
|
if let Ok(mut p) = state.download.lock() {
|
||||||
|
*p = DownloadProgress::default();
|
||||||
|
}
|
||||||
|
let name = preset.name.clone();
|
||||||
|
let progress = state.download.clone();
|
||||||
|
state.launcher = Some(preset);
|
||||||
|
return Task::perform(
|
||||||
|
async_blocking(move || download_blocking(&url, &name, progress)),
|
||||||
|
Message::PrepareDone,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
state.status = format!(
|
state.status = format!(
|
||||||
"Paste an installer URL or a local .exe path. It will install into {}.",
|
"Paste an installer URL or a local .exe path. It will install into {}.",
|
||||||
preset.prefix_dir.display()
|
preset.prefix_dir.display()
|
||||||
@@ -398,6 +413,14 @@ fn view_install(state: &State) -> Element<'_, Message> {
|
|||||||
))
|
))
|
||||||
.size(13);
|
.size(13);
|
||||||
|
|
||||||
|
let source_label = if launcher.installer_url.is_some()
|
||||||
|
&& state.source == launcher.installer_url.as_deref().unwrap_or("")
|
||||||
|
{
|
||||||
|
"Official installer URL (auto-filled — or paste your own):"
|
||||||
|
} else {
|
||||||
|
"Installer URL or local .exe path:"
|
||||||
|
};
|
||||||
|
|
||||||
let input = text_input("https://… or /path/to/installer.exe", &state.source)
|
let input = text_input("https://… or /path/to/installer.exe", &state.source)
|
||||||
.on_input(Message::SourceChanged)
|
.on_input(Message::SourceChanged)
|
||||||
.padding(8);
|
.padding(8);
|
||||||
@@ -454,6 +477,7 @@ fn view_install(state: &State) -> Element<'_, Message> {
|
|||||||
header,
|
header,
|
||||||
prefix,
|
prefix,
|
||||||
expected,
|
expected,
|
||||||
|
text(source_label).size(12),
|
||||||
input,
|
input,
|
||||||
row![prepare_btn, install_btn].spacing(12),
|
row![prepare_btn, install_btn].spacing(12),
|
||||||
progress_row,
|
progress_row,
|
||||||
|
|||||||
+20
@@ -14,3 +14,23 @@ where
|
|||||||
});
|
});
|
||||||
rx.await.expect("blocking task panicked")
|
rx.await.expect("blocking task panicked")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open a native folder picker dialog and return the chosen path, or None if
|
||||||
|
/// the user cancelled. Tries zenity (GNOME/GTK) then kdialog (KDE) in order.
|
||||||
|
pub fn pick_folder(title: &str) -> Option<String> {
|
||||||
|
for (cmd, args) in [
|
||||||
|
("zenity", vec!["--file-selection", "--directory", "--title", title]),
|
||||||
|
("kdialog", vec!["--getexistingdirectory", "/home", "--title", title]),
|
||||||
|
] {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=umutray Wine launcher manager
|
||||||
|
After=graphical-session.target
|
||||||
|
PartOf=graphical-session.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/bin/umutray
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=graphical-session.target
|
||||||
Reference in New Issue
Block a user