modified: README.md

modified:   src/config.rs
	modified:   src/diagnose.rs
	modified:   src/launcher.rs
	modified:   src/main.rs
	new file:   src/setup.rs
	modified:   src/tray.rs
This commit is contained in:
funman300
2026-04-16 21:43:58 -07:00
parent 336c5d908e
commit 7e5ed3d447
7 changed files with 690 additions and 414 deletions
+67 -36
View File
@@ -1,18 +1,33 @@
# umutray # umutray
A small system-tray daemon and CLI for running the Battle.net launcher on A small system-tray daemon and CLI for running Windows game launchers on
Linux via [umu-launcher](https://github.com/Open-Wine-Components/umu-launcher) Linux via [umu-launcher](https://github.com/Open-Wine-Components/umu-launcher)
and [GE-Proton](https://github.com/GloriousEggroll/proton-ge-custom). and [GE-Proton](https://github.com/GloriousEggroll/proton-ge-custom).
Ships with presets for six launchers out of the box:
- Battle.net
- EA App
- Epic Games
- Ubisoft Connect
- GOG Galaxy
- Rockstar Games
Each lives in its own Wine prefix and shows up in the tray with per-launcher
Launch / Kill entries. Users can add or remove launchers in `config edit`.
## Features ## Features
- Tray icon with Launch / Kill / Update-Proton menu (KDE, GNOME+AppIndicator, - Tray icon on any SNI-capable desktop (KDE, GNOME+AppIndicator, Xfce …).
Xfce, any SNI-capable desktop). - Per-launcher running state reflected in the tray via a 2 s poller.
- Background poller that reflects Battle.net's running state in the tray. - `update-proton` — streams GE-Proton releases straight to disk from GitHub
- `update-proton` subcommand that downloads GE-Proton releases directly from (no ~600 MB in-memory buffering), with a progress indicator.
GitHub and installs them under the Steam compat tools directory. - `diagnose` — sanity-checks umu-run, Vulkan, display server, per-launcher
- `diagnose` subcommand that sanity-checks the environment (umu-run, prefix, prefix / exe / ownership / running state.
Proton install, Vulkan, display server, stale agent.lock). - `service` — installs a `systemd --user` unit so the tray autostarts with
the graphical session.
- `setup` — prints the manual setup steps for a launcher (a graphical
wizard via iced is planned).
## Install ## Install
@@ -21,46 +36,62 @@ cargo build --release
install -Dm755 target/release/umutray ~/.local/bin/umutray install -Dm755 target/release/umutray ~/.local/bin/umutray
``` ```
Requires `umu-launcher` and `tar` on PATH. On Arch: Requires `umu-launcher`, `tar`, and `vulkan-tools` on PATH. On Arch:
```sh ```sh
sudo pacman -S umu-launcher vulkan-tools sudo pacman -S umu-launcher vulkan-tools
``` ```
The Battle.net Launcher.exe itself is not bundled — run your existing Then enable autostart:
`battlenet-umu-setup.sh` (or install it manually into the prefix) before
first launch. ```sh
umutray service install
```
## Usage ## Usage
| Command | What it does | | Command | What it does |
| ------------------------------ | -------------------------------------------------------- | | -------------------------------- | ------------------------------------------------------- |
| `umutray` | Start the tray daemon (default) | | `umutray` | Start the tray daemon (default) |
| `umutray launch` | Launch Battle.net and return (for `.desktop` shortcuts) | | `umutray launchers` | List configured launchers and their state |
| `umutray kill` | SIGTERM → wait 3 s → SIGKILL on all Battle.net procs | | `umutray launch <name>` | Launch a specific launcher (e.g. `umutray launch epic`) |
| `umutray diagnose` | Run environment health checks | | `umutray kill [<name>]` | Kill one launcher, or all if no name is given |
| `umutray update-proton` | Interactive GE-Proton picker | | `umutray diagnose [<name>]` | Health checks (one launcher or all) |
| `update-proton --latest` | Install newest GE-Proton release | | `umutray setup <name>` | Print setup steps for a launcher |
| `update-proton --version X` | Install a specific tag (e.g. `GE-Proton10-34`) | | `umutray update-proton --latest` | Install newest GE-Proton release |
| `update-proton --list` | Show recent releases without installing | | `umutray update-proton --list` | Show recent releases without installing |
| `config show` / `config path` | Print current config or its path | | `umutray update-proton` | Interactive version picker |
| `config edit` | Open config in `$EDITOR` | | `umutray config show` / `path` | Print current config or its file path |
| `config set --prefix PATH` | Change the Wine prefix (also `--compat-dir`, `--gameid`) | | `umutray config edit` | Open config in `$EDITOR` |
| `service install` | Write + enable a `systemd --user` unit for autostart | | `umutray config set …` | Update globals (`--proton-version`, `--compat-dir`) |
| `service uninstall` / `status` | Remove the unit / show its status | | `umutray service install` | Write + enable a `systemd --user` unit |
| `umutray service uninstall` | Stop, disable, and remove the unit |
| `umutray service status` | `systemctl --user status umutray.service` |
## Config ## Config
Lives at `~/.config/umutray/config.toml`, written with defaults on Lives at `~/.config/umutray/config.toml`. A full config looks like:
first run:
```toml ```toml
prefix_dir = "~/Games/battlenet-umu" proton_compat_dir = "/home/you/.local/share/Steam/compatibilitytools.d"
proton_version = "GE-Proton" # or a pinned tag like "GE-Proton10-34" proton_version = "GE-Proton"
gameid = "umu-battlenet"
proton_compat_dir = "~/.local/share/Steam/compatibilitytools.d" [[launchers]]
name = "battlenet"
display = "Battle.net"
prefix_dir = "/home/you/Games/battlenet"
exe_path = "Program Files (x86)/Battle.net/Battle.net Launcher.exe"
gameid = "umu-battlenet"
process_pattern = "Battle\\.net"
# …one [[launchers]] block per launcher
``` ```
`proton_version = "GE-Proton"` tells umu-launcher to auto-fetch the latest `proton_version = "GE-Proton"` tells umu-launcher to auto-fetch the latest.
on each run. Setting it to a specific tag (done automatically by Setting it to a pinned tag (done automatically by `update-proton`) uses
`update-proton`) pins that version. that specific version. Each launcher may override the global `proton_version`
with its own.
## License
MIT. See [LICENSE](LICENSE).
+198 -68
View File
@@ -4,37 +4,154 @@ use serde::{Deserialize, Serialize};
use std::path::PathBuf; use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config { pub struct Launcher {
/// Wine prefix directory /// Short CLI name (e.g. "battlenet").
pub name: String,
/// Display name for tray / menus.
pub display: String,
/// Wine prefix directory for this launcher.
pub prefix_dir: PathBuf, pub prefix_dir: PathBuf,
/// GE-Proton version string passed to PROTONPATH ("GE-Proton" tracks latest) /// Path to the launcher exe, relative to the prefix's drive_c/.
pub proton_version: String, pub exe_path: PathBuf,
/// umu GAMEID used to look up protonfixes /// umu GAMEID (used to look up protonfixes).
pub gameid: String, pub gameid: String,
/// Directory where GE-Proton versions are installed /// pgrep/pkill -f regex matching this launcher's running processes.
pub process_pattern: String,
/// Optional URL of the Windows installer (consumed by `setup`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub installer_url: Option<String>,
/// Optional per-launcher Proton version override (falls back to
/// Config::proton_version).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub proton_version: Option<String>,
}
impl Launcher {
/// Absolute path to the launcher exe inside the prefix.
pub fn full_exe_path(&self) -> PathBuf {
self.prefix_dir.join("drive_c").join(&self.exe_path)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
/// Directory where GE-Proton versions are installed.
#[serde(default = "default_compat_dir")]
pub proton_compat_dir: PathBuf, pub proton_compat_dir: PathBuf,
/// Default Proton version passed to PROTONPATH.
/// The literal "GE-Proton" makes umu-run fetch/use the latest.
#[serde(default = "default_proton_version")]
pub proton_version: String,
/// Configured launchers.
#[serde(default)]
pub launchers: Vec<Launcher>,
}
fn home_dir() -> PathBuf {
std::env::var("HOME")
.map(PathBuf::from)
.expect("$HOME is not set; cannot determine default paths")
}
fn default_compat_dir() -> PathBuf {
home_dir().join(".local/share/Steam/compatibilitytools.d")
}
fn default_proton_version() -> String {
"GE-Proton".into()
}
/// The six launchers umutray ships out of the box. `exe_path`, `gameid`,
/// and `process_pattern` are best-effort defaults for typical installs —
/// users can adjust per-launcher via `umutray config edit`.
pub fn presets() -> Vec<Launcher> {
let games = home_dir().join("Games");
vec![
Launcher {
name: "battlenet".into(),
display: "Battle.net".into(),
prefix_dir: games.join("battlenet"),
exe_path: PathBuf::from(
"Program Files (x86)/Battle.net/Battle.net Launcher.exe",
),
gameid: "umu-battlenet".into(),
process_pattern: r"Battle\.net".into(),
installer_url: None,
proton_version: None,
},
Launcher {
name: "eaapp".into(),
display: "EA App".into(),
prefix_dir: games.join("eaapp"),
exe_path: PathBuf::from(
"Program Files/Electronic Arts/EA Desktop/EA Desktop/EADesktop.exe",
),
gameid: "umu-eaapp".into(),
process_pattern: r"EADesktop\.exe".into(),
installer_url: None,
proton_version: None,
},
Launcher {
name: "epic".into(),
display: "Epic Games".into(),
prefix_dir: games.join("epic"),
exe_path: PathBuf::from(
"Program Files (x86)/Epic Games/Launcher/Portal/Binaries/Win32/EpicGamesLauncher.exe",
),
gameid: "umu-epicgameslauncher".into(),
process_pattern: r"EpicGamesLauncher\.exe".into(),
installer_url: None,
proton_version: None,
},
Launcher {
name: "ubisoft".into(),
display: "Ubisoft Connect".into(),
prefix_dir: games.join("ubisoft"),
exe_path: PathBuf::from(
"Program Files (x86)/Ubisoft/Ubisoft Game Launcher/UbisoftConnect.exe",
),
gameid: "umu-uplay".into(),
process_pattern: r"UbisoftConnect\.exe|upc\.exe".into(),
installer_url: None,
proton_version: None,
},
Launcher {
name: "gog".into(),
display: "GOG Galaxy".into(),
prefix_dir: games.join("gog"),
exe_path: PathBuf::from(
"Program Files (x86)/GOG Galaxy/GalaxyClient.exe",
),
gameid: "umu-gog".into(),
process_pattern: r"GalaxyClient\.exe".into(),
installer_url: None,
proton_version: None,
},
Launcher {
name: "rockstar".into(),
display: "Rockstar Games".into(),
prefix_dir: games.join("rockstar"),
exe_path: PathBuf::from(
"Program Files/Rockstar Games/Launcher/Launcher.exe",
),
gameid: "umu-rockstar".into(),
process_pattern: r"Rockstar Games.*Launcher\.exe".into(),
installer_url: None,
proton_version: None,
},
]
} }
impl Default for Config { impl Default for Config {
fn default() -> Self { fn default() -> Self {
let home = home_dir();
Self { Self {
prefix_dir: home.join("Games/battlenet-umu"), proton_compat_dir: default_compat_dir(),
proton_version: "GE-Proton".into(), proton_version: default_proton_version(),
gameid: "umu-battlenet".into(), launchers: presets(),
proton_compat_dir: home.join(".local/share/Steam/compatibilitytools.d"),
} }
} }
} }
fn home_dir() -> PathBuf {
// No sensible fallback — writing a Wine prefix to /tmp would be data-loss
// waiting to happen, so surface the misconfiguration instead.
std::env::var("HOME")
.map(PathBuf::from)
.expect("$HOME is not set; cannot determine default paths")
}
impl Config { impl Config {
pub fn config_path() -> Result<PathBuf> { pub fn config_path() -> Result<PathBuf> {
let dirs = ProjectDirs::from("co.aleshym", "", "umutray") let dirs = ProjectDirs::from("co.aleshym", "", "umutray")
@@ -42,7 +159,54 @@ impl Config {
Ok(dirs.config_dir().join("config.toml")) Ok(dirs.config_dir().join("config.toml"))
} }
/// Print the config path followed by the serialised current config. pub fn load() -> Result<Self> {
let path = Self::config_path()?;
if !path.exists() {
let c = Self::default();
c.save().context("Failed to write default config")?;
return Ok(c);
}
let content = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read config from {path:?}"))?;
match toml::from_str::<Self>(&content) {
Ok(mut c) => {
if c.launchers.is_empty() {
c.launchers = presets();
c.save().context("Failed to write presets")?;
}
Ok(c)
}
Err(e) => {
let bak = path.with_extension("toml.bak");
std::fs::rename(&path, &bak).with_context(|| {
format!("Failed to back up stale config to {bak:?}")
})?;
eprintln!("warning: couldn't parse {}: {e}", path.display());
eprintln!(
" backed up to {} — writing fresh config with presets",
bak.display()
);
let c = Self::default();
c.save()?;
Ok(c)
}
}
}
pub fn save(&self) -> Result<()> {
let path = Self::config_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = toml::to_string_pretty(self)?;
std::fs::write(&path, content)
.with_context(|| format!("Failed to write config to {path:?}"))
}
pub fn find(&self, name: &str) -> Option<&Launcher> {
self.launchers.iter().find(|l| l.name == name)
}
pub fn show(&self) -> Result<()> { pub fn show(&self) -> Result<()> {
let path = Self::config_path()?; let path = Self::config_path()?;
println!("# {}", path.display()); println!("# {}", path.display());
@@ -51,10 +215,8 @@ impl Config {
Ok(()) Ok(())
} }
/// Open the config file in $EDITOR (falling back to nano).
/// Ensures the file exists first so the editor isn't launched on nothing.
pub fn edit() -> Result<()> { pub fn edit() -> Result<()> {
let _ = Self::load()?; // writes defaults if missing let _ = Self::load()?;
let path = Self::config_path()?; let path = Self::config_path()?;
let editor = std::env::var("EDITOR") let editor = std::env::var("EDITOR")
.or_else(|_| std::env::var("VISUAL")) .or_else(|_| std::env::var("VISUAL"))
@@ -69,59 +231,27 @@ impl Config {
Ok(()) Ok(())
} }
/// Update individual fields non-interactively, then save. /// Update global fields non-interactively, then save.
pub fn set_fields( /// Use `config edit` for per-launcher changes.
pub fn set_globals(
&mut self, &mut self,
prefix: Option<PathBuf>, proton_version: Option<String>,
compat_dir: Option<PathBuf>, compat_dir: Option<PathBuf>,
gameid: Option<String>,
) -> Result<()> { ) -> Result<()> {
if prefix.is_none() && compat_dir.is_none() && gameid.is_none() { if proton_version.is_none() && compat_dir.is_none() {
anyhow::bail!("nothing to set — pass at least one of --prefix / --compat-dir / --gameid"); anyhow::bail!(
"nothing to set — pass --proton-version or --compat-dir"
);
} }
if let Some(p) = prefix { if let Some(v) = proton_version {
self.prefix_dir = p; self.proton_version = v;
} }
if let Some(p) = compat_dir { if let Some(d) = compat_dir {
self.proton_compat_dir = p; self.proton_compat_dir = d;
}
if let Some(g) = gameid {
self.gameid = g;
} }
self.save()?; self.save()?;
println!("\x1b[1;32m✓\x1b[0m Config saved."); println!("\x1b[1;32m✓\x1b[0m Config saved.");
println!(); println!();
self.show()?; self.show()
Ok(())
}
/// Load config from disk, creating a default one if it doesn't exist.
pub fn load() -> Result<Self> {
let path = Self::config_path()?;
if !path.exists() {
let config = Self::default();
config.save().context("Failed to write default config")?;
return Ok(config);
}
let content = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read config from {path:?}"))?;
toml::from_str(&content).context("Failed to parse config.toml")
}
/// Write config to disk.
pub fn save(&self) -> Result<()> {
let path = Self::config_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = toml::to_string_pretty(self)?;
std::fs::write(&path, content)
.with_context(|| format!("Failed to write config to {path:?}"))
}
/// Absolute path to the Battle.net Launcher.exe inside the prefix.
pub fn launcher_exe(&self) -> PathBuf {
self.prefix_dir
.join("drive_c/Program Files (x86)/Battle.net/Battle.net Launcher.exe")
} }
} }
+156 -169
View File
@@ -1,176 +1,56 @@
use crate::config::Config; use crate::config::{Config, Launcher};
use anyhow::Result;
use std::os::unix::fs::MetadataExt;
use std::path::Path; use std::path::Path;
use std::process::Command; use std::process::{Command, Stdio};
struct Check { struct Check {
label: &'static str, label: String,
pass: bool, pass: bool,
detail: String, detail: String,
} }
impl Check { impl Check {
fn pass(label: &'static str, detail: impl Into<String>) -> Self { fn pass(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self { label, pass: true, detail: detail.into() } Self { label: label.into(), pass: true, detail: detail.into() }
} }
fn fail(label: &'static str, detail: impl Into<String>) -> Self { fn fail(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self { label, pass: false, detail: detail.into() } Self { label: label.into(), pass: false, detail: detail.into() }
} }
} }
pub fn run(config: &Config) { pub fn run(config: &Config, name: Option<&str>) -> Result<()> {
let mut checks: Vec<Check> = Vec::new(); let mut checks: Vec<Check> = vec![
global_umu_check(),
global_vulkan_check(),
global_display_check(),
compat_dir_check(config),
];
let launchers: Vec<&Launcher> = if let Some(n) = name {
let l = config
.find(n)
.ok_or_else(|| anyhow::anyhow!("unknown launcher '{n}'"))?;
vec![l]
} else {
config.launchers.iter().collect()
};
for l in launchers {
checks.extend(launcher_checks(l));
}
let mut issues = 0u32; let mut issues = 0u32;
// ── umu-run ──────────────────────────────────────────────────────────────
match which("umu-run") {
Some(p) => checks.push(Check::pass("umu-run", format!("found at {p}"))),
None => {
checks.push(Check::fail("umu-run", "not found — sudo pacman -S umu-launcher"));
issues += 1;
}
}
// ── Wine prefix ──────────────────────────────────────────────────────────
if config.prefix_dir.exists() {
checks.push(Check::pass("prefix", format!("{:?}", config.prefix_dir)));
let exe = config.launcher_exe();
if exe.exists() {
checks.push(Check::pass("launcher", "Battle.net Launcher.exe present"));
} else {
checks.push(Check::fail(
"launcher",
format!("not found at {exe:?} — run battlenet-umu-setup.sh"),
));
issues += 1;
}
// Stale agent lock
let lock = config.prefix_dir
.join("drive_c/ProgramData/Battle.net/Agent/agent.lock");
if lock.exists() {
if let Ok(meta) = std::fs::metadata(&lock) {
if let Ok(modified) = meta.modified() {
let age = std::time::SystemTime::now()
.duration_since(modified)
.unwrap_or_default()
.as_secs();
if age > 300 {
checks.push(Check::fail(
"agent.lock",
format!("stale lock ({age}s old) — may cause BLZBNTBNA00000005; run: umutray kill"),
));
issues += 1;
} else {
checks.push(Check::pass("agent.lock", format!("fresh ({age}s) — launcher may be running")));
}
}
}
}
// Prefix ownership
if is_owned_by_current_user(&config.prefix_dir) {
checks.push(Check::pass("prefix owner", "owned by current user"));
} else {
checks.push(Check::fail(
"prefix owner",
"not owned by current user — Wine will misbehave",
));
issues += 1;
}
} else {
checks.push(Check::fail(
"prefix",
format!("{:?} does not exist — run battlenet-umu-setup.sh", config.prefix_dir),
));
issues += 1;
}
// ── GE-Proton ────────────────────────────────────────────────────────────
let installed_count = count_ge_proton(&config.proton_compat_dir);
if config.proton_version == "GE-Proton" {
if installed_count > 0 {
checks.push(Check::pass(
"proton",
format!("tracking latest; {installed_count} version(s) in {:?}", config.proton_compat_dir),
));
} else {
checks.push(Check::pass(
"proton",
"tracking latest — will auto-download on first launch",
));
}
} else {
let vpath = config.proton_compat_dir.join(&config.proton_version);
if vpath.exists() {
checks.push(Check::pass("proton", format!("{} installed", config.proton_version)));
} else {
checks.push(Check::fail(
"proton",
format!(
"{} not found — run: umutray update-proton --version={}",
config.proton_version, config.proton_version
),
));
issues += 1;
}
}
// ── Vulkan ───────────────────────────────────────────────────────────────
let vulkan_ok = Command::new("vulkaninfo")
.arg("--summary")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if vulkan_ok {
checks.push(Check::pass("vulkan", "vulkaninfo OK"));
} else {
checks.push(Check::fail("vulkan", "vulkaninfo failed or not installed — check GPU drivers and vulkan-tools"));
issues += 1;
}
// ── Display / XWayland ───────────────────────────────────────────────────
let display = std::env::var("DISPLAY").ok();
let wayland = std::env::var("WAYLAND_DISPLAY").ok();
match (display, wayland) {
(Some(d), Some(_)) => checks.push(Check::pass("display", format!("XWayland (DISPLAY={d})"))),
(Some(d), None) => checks.push(Check::pass("display", format!("X11 (DISPLAY={d})"))),
(None, Some(_)) => {
checks.push(Check::fail(
"display",
"Wayland session but DISPLAY not set — XWayland not running; Battle.net needs it",
));
issues += 1;
}
(None, None) => {
checks.push(Check::fail("display", "neither DISPLAY nor WAYLAND_DISPLAY is set"));
issues += 1;
}
}
// ── Running processes ────────────────────────────────────────────────────
let bnet_running = Command::new("pgrep")
.args(["-fi", "Battle\\.net"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if bnet_running {
checks.push(Check::pass("process", "Battle.net is currently running"));
} else {
checks.push(Check::pass("process", "Battle.net is not running"));
}
// ── Print ────────────────────────────────────────────────────────────────
println!(); println!();
for c in &checks { for c in &checks {
let (symbol, colour, reset) = if c.pass { if !c.pass {
issues += 1;
}
let (sym, col, rst) = if c.pass {
("", "\x1b[1;32m", "\x1b[0m") ("", "\x1b[1;32m", "\x1b[0m")
} else { } else {
("", "\x1b[1;31m", "\x1b[0m") ("", "\x1b[1;31m", "\x1b[0m")
}; };
println!(" {colour}{symbol}{reset} {:16} {}", c.label, c.detail); println!(" {col}{sym}{rst} {:24} {}", c.label, c.detail);
} }
println!(); println!();
if issues == 0 { if issues == 0 {
@@ -179,18 +59,120 @@ pub fn run(config: &Config) {
println!(" \x1b[1;31m{issues} issue(s) found — see ✗ items above.\x1b[0m"); println!(" \x1b[1;31m{issues} issue(s) found — see ✗ items above.\x1b[0m");
} }
println!(); println!();
Ok(())
} }
// ── helpers ────────────────────────────────────────────────────────────────── fn global_umu_check() -> Check {
match which("umu-run") {
Some(p) => Check::pass("umu-run", format!("found at {p}")),
None => Check::fail("umu-run", "not found — install umu-launcher"),
}
}
fn which(cmd: &str) -> Option<String> { fn global_vulkan_check() -> Check {
Command::new("which") let ok = Command::new("vulkaninfo")
.arg(cmd) .arg("--summary")
.output() .stdout(Stdio::null())
.ok() .stderr(Stdio::null())
.filter(|o| o.status.success()) .status()
.and_then(|o| String::from_utf8(o.stdout).ok()) .map(|s| s.success())
.map(|s| s.trim().to_string()) .unwrap_or(false);
if ok {
Check::pass("vulkan", "vulkaninfo OK")
} else {
Check::fail("vulkan", "vulkaninfo failed — check GPU drivers / vulkan-tools")
}
}
fn global_display_check() -> Check {
let display = std::env::var("DISPLAY").ok();
let wayland = std::env::var("WAYLAND_DISPLAY").ok();
match (display, wayland) {
(Some(d), Some(_)) => Check::pass("display", format!("XWayland (DISPLAY={d})")),
(Some(d), None) => Check::pass("display", format!("X11 (DISPLAY={d})")),
(None, Some(_)) => Check::fail(
"display",
"Wayland session but DISPLAY unset; XWayland needed",
),
(None, None) => Check::fail("display", "neither DISPLAY nor WAYLAND_DISPLAY set"),
}
}
fn compat_dir_check(config: &Config) -> Check {
let n = count_ge_proton(&config.proton_compat_dir);
if config.proton_version == "GE-Proton" {
Check::pass(
"proton",
format!(
"tracking latest; {n} version(s) in {}",
config.proton_compat_dir.display()
),
)
} else {
let path = config.proton_compat_dir.join(&config.proton_version);
if path.exists() {
Check::pass("proton", format!("{} installed", config.proton_version))
} else {
Check::fail(
"proton",
format!(
"{} missing — run: umutray update-proton --version={}",
config.proton_version, config.proton_version
),
)
}
}
}
fn launcher_checks(l: &Launcher) -> Vec<Check> {
let mut out = Vec::new();
let tag = format!("[{}]", l.name);
if !l.prefix_dir.exists() {
out.push(Check::fail(
format!("{tag} prefix"),
format!(
"{} missing — run: umutray setup {}",
l.prefix_dir.display(),
l.name
),
));
return out;
}
out.push(Check::pass(
format!("{tag} prefix"),
l.prefix_dir.display().to_string(),
));
let exe = l.full_exe_path();
if exe.exists() {
out.push(Check::pass(format!("{tag} exe"), "installed"));
} else {
out.push(Check::fail(
format!("{tag} exe"),
format!("missing — run: umutray setup {}", l.name),
));
}
if is_owned_by_current_user(&l.prefix_dir) {
out.push(Check::pass(
format!("{tag} owner"),
"owned by current user",
));
} else {
out.push(Check::fail(
format!("{tag} owner"),
"not owned by current user",
));
}
if crate::launcher::is_running(l) {
out.push(Check::pass(format!("{tag} process"), "currently running"));
} else {
out.push(Check::pass(format!("{tag} process"), "not running"));
}
out
} }
fn count_ge_proton(dir: &Path) -> usize { fn count_ge_proton(dir: &Path) -> usize {
@@ -210,22 +192,27 @@ fn count_ge_proton(dir: &Path) -> usize {
.unwrap_or(0) .unwrap_or(0)
} }
fn is_owned_by_current_user(path: &Path) -> bool { fn which(cmd: &str) -> Option<String> {
use std::os::unix::fs::MetadataExt; Command::new("which")
.arg(cmd)
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
}
fn is_owned_by_current_user(path: &Path) -> bool {
let file_uid = match std::fs::metadata(path) { let file_uid = match std::fs::metadata(path) {
Ok(m) => m.uid(), Ok(m) => m.uid(),
Err(_) => return true, // assume OK if we can't check Err(_) => return true,
}; };
// No std API for the current process uid; shell out once to `id -u`.
let current_uid: Option<u32> = Command::new("id") let current_uid: Option<u32> = Command::new("id")
.arg("-u") .arg("-u")
.output() .output()
.ok() .ok()
.and_then(|o| String::from_utf8(o.stdout).ok()) .and_then(|o| String::from_utf8(o.stdout).ok())
.and_then(|s| s.trim().parse().ok()); .and_then(|s| s.trim().parse().ok());
match current_uid { match current_uid {
Some(uid) => uid == file_uid, Some(uid) => uid == file_uid,
None => true, None => true,
+57 -44
View File
@@ -1,30 +1,36 @@
use crate::config::Config; use crate::config::{Config, Launcher};
use anyhow::{Context, Result}; use anyhow::{bail, Context, Result};
use std::process::Stdio;
use std::thread; use std::thread;
use std::time::Duration; use std::time::Duration;
/// Spawn Battle.net via umu-run and return immediately. /// Spawn the launcher via umu-run and return immediately.
pub fn launch(config: &Config) -> Result<()> { pub fn launch(config: &Config, launcher: &Launcher) -> Result<()> {
let exe = config.launcher_exe(); let exe = launcher.full_exe_path();
if !exe.exists() { if !exe.exists() {
anyhow::bail!( bail!(
"Battle.net Launcher.exe not found at {exe:?}\n\ "launcher exe not found at {:?}\n\
Run 'battlenet-umu-setup.sh' to install it first." Run `umutray setup {}` for setup instructions.",
exe,
launcher.name,
); );
} }
// PROTONPATH: umu-run accepts the literal "GE-Proton" to auto-fetch the // PROTONPATH: umu-run accepts the literal "GE-Proton" to auto-fetch the
// latest; for any pinned version it expects a full path to the install dir. // latest; for any pinned version it expects a full path to the install dir.
let proton_path: std::ffi::OsString = if config.proton_version == "GE-Proton" { let version = launcher
config.proton_version.clone().into() .proton_version
.as_deref()
.unwrap_or(&config.proton_version);
let proton_path: std::ffi::OsString = if version == "GE-Proton" {
version.to_string().into()
} else { } else {
config.proton_compat_dir.join(&config.proton_version).into_os_string() config.proton_compat_dir.join(version).into_os_string()
}; };
std::process::Command::new("umu-run") std::process::Command::new("umu-run")
.env("WINEPREFIX", &config.prefix_dir) .env("WINEPREFIX", &launcher.prefix_dir)
.env("GAMEID", &config.gameid) .env("GAMEID", &launcher.gameid)
.env("PROTONPATH", &proton_path) .env("PROTONPATH", &proton_path)
.arg(&exe) .arg(&exe)
.spawn() .spawn()
@@ -36,40 +42,47 @@ pub fn launch(config: &Config) -> Result<()> {
Ok(()) Ok(())
} }
/// Gracefully stop Battle.net: SIGTERM → wait 3 s → SIGKILL. /// SIGTERM → wait 3 s → SIGKILL for a single launcher.
pub fn kill() -> Result<()> { pub fn kill(launcher: &Launcher) -> Result<()> {
let patterns = ["Battle\\.net", "Agent\\.exe", "Blizzard"]; kill_pattern(&launcher.process_pattern);
for pattern in &patterns {
let _ = std::process::Command::new("pkill")
.args(["-15", "-f", pattern])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
thread::sleep(Duration::from_secs(3));
for pattern in &patterns {
let _ = std::process::Command::new("pkill")
.args(["-9", "-f", pattern])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
Ok(()) Ok(())
} }
/// Returns true if any Battle.net process is currently running. /// Kill every configured launcher's processes.
pub fn is_running() -> bool { pub fn kill_all(config: &Config) -> Result<()> {
// Escape the dot — unescaped, "battle.net" would regex-match literally // Single SIGTERM pass across all launchers, then one sleep, then SIGKILL.
// any process with "battlenet" in its cmdline (including this binary if // This keeps the total wait at 3 s instead of 3 s × N.
// it were ever renamed back to battlenet-*). for l in &config.launchers {
send_signal("-15", &l.process_pattern);
}
thread::sleep(Duration::from_secs(3));
for l in &config.launchers {
send_signal("-9", &l.process_pattern);
}
Ok(())
}
fn kill_pattern(pattern: &str) {
send_signal("-15", pattern);
thread::sleep(Duration::from_secs(3));
send_signal("-9", pattern);
}
fn send_signal(sig: &str, pattern: &str) {
let _ = std::process::Command::new("pkill")
.args([sig, "-f", pattern])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
/// Returns true if at least one process matching the launcher's pattern
/// is currently alive.
pub fn is_running(launcher: &Launcher) -> bool {
std::process::Command::new("pgrep") std::process::Command::new("pgrep")
.args(["-fi", "Battle\\.net"]) .args(["-f", &launcher.process_pattern])
.stdout(std::process::Stdio::null()) .stdout(Stdio::null())
.stderr(std::process::Stdio::null()) .stderr(Stdio::null())
.status() .status()
.map(|s| s.success()) .map(|s| s.success())
.unwrap_or(false) .unwrap_or(false)
+78 -22
View File
@@ -3,16 +3,16 @@ mod diagnose;
mod launcher; mod launcher;
mod proton; mod proton;
mod service; mod service;
mod setup;
mod tray; mod tray;
use anyhow::Result; use anyhow::Result;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use std::path::PathBuf; use std::path::PathBuf;
/// Battle.net launcher manager for Linux via umu/Proton-GE. /// Tray-based Wine launcher manager for Linux via umu/Proton-GE.
/// ///
/// Running without a subcommand starts the system tray daemon. /// Running without a subcommand starts the system tray daemon.
/// Use `launch` in your .desktop shortcut for a direct, no-UI launch.
#[derive(Parser)] #[derive(Parser)]
#[command(name = "umutray", version, about)] #[command(name = "umutray", version, about)]
struct Cli { struct Cli {
@@ -25,14 +25,32 @@ enum Commands {
/// Start the system tray daemon (default when no subcommand given) /// Start the system tray daemon (default when no subcommand given)
Tray, Tray,
/// Launch Battle.net immediately and return — use this in .desktop shortcuts /// Launch a configured launcher
Launch, Launch {
/// Launcher name (e.g. battlenet, eaapp, epic)
name: String,
},
/// Gracefully kill all Battle.net / Wine processes /// Kill a specific launcher, or every configured one if no name is given
Kill, Kill {
/// Launcher name (omit to kill all)
name: Option<String>,
},
/// Check setup health and report any problems /// Run health checks on a specific launcher, or all of them
Diagnose, Diagnose {
/// Launcher name (omit to check all)
name: Option<String>,
},
/// List configured launchers and whether they're installed / running
Launchers,
/// Print setup instructions for a launcher (automated wizard coming soon)
Setup {
/// Launcher name
name: String,
},
/// Download and switch GE-Proton versions /// Download and switch GE-Proton versions
UpdateProton { UpdateProton {
@@ -40,7 +58,7 @@ enum Commands {
#[arg(long)] #[arg(long)]
latest: bool, latest: bool,
/// Install a specific version (e.g. GE-Proton9-20) /// Install a specific version (e.g. GE-Proton10-34)
#[arg(long, value_name = "VERSION")] #[arg(long, value_name = "VERSION")]
version: Option<String>, version: Option<String>,
@@ -70,19 +88,15 @@ enum ConfigAction {
Path, Path,
/// Open the config file in $EDITOR /// Open the config file in $EDITOR
Edit, Edit,
/// Update one or more fields non-interactively /// Update global fields. Use `config edit` for per-launcher changes.
Set { Set {
/// Wine prefix directory /// Default Proton version (e.g. GE-Proton, GE-Proton10-34)
#[arg(long, value_name = "PATH")] #[arg(long, value_name = "VERSION")]
prefix: Option<PathBuf>, proton_version: Option<String>,
/// GE-Proton install directory /// GE-Proton install directory
#[arg(long, value_name = "PATH")] #[arg(long, value_name = "PATH")]
compat_dir: Option<PathBuf>, compat_dir: Option<PathBuf>,
/// umu GAMEID (used for protonfixes lookup)
#[arg(long, value_name = "ID")]
gameid: Option<String>,
}, },
} }
@@ -102,23 +116,65 @@ fn main() -> Result<()> {
match cli.command.unwrap_or(Commands::Tray) { match cli.command.unwrap_or(Commands::Tray) {
Commands::Tray => tray::run(&config)?, Commands::Tray => tray::run(&config)?,
Commands::Launch => launcher::launch(&config)?,
Commands::Kill => launcher::kill()?, Commands::Launch { name } => {
Commands::Diagnose => diagnose::run(&config), let l = config.find(&name).ok_or_else(|| {
anyhow::anyhow!("unknown launcher '{name}' — try `umutray launchers`")
})?;
launcher::launch(&config, l)?;
}
Commands::Kill { name } => match name {
Some(n) => {
let l = config.find(&n).ok_or_else(|| {
anyhow::anyhow!("unknown launcher '{n}' — try `umutray launchers`")
})?;
launcher::kill(l)?;
}
None => launcher::kill_all(&config)?,
},
Commands::Diagnose { name } => {
diagnose::run(&config, name.as_deref())?;
}
Commands::Launchers => {
for l in &config.launchers {
let installed = l.full_exe_path().exists();
let running = launcher::is_running(l);
let marker = if installed {
"\x1b[1;32m✓\x1b[0m"
} else {
"·"
};
let state = if running { " (running)" } else { "" };
println!(" {marker} {:12} {}{}", l.name, l.display, state);
}
}
Commands::Setup { name } => {
let l = config.find(&name).ok_or_else(|| {
anyhow::anyhow!("unknown launcher '{name}' — try `umutray launchers`")
})?;
setup::run(&config, l)?;
}
Commands::UpdateProton { latest, version, list } => { Commands::UpdateProton { latest, version, list } => {
proton::run(&config, latest, version, list)?; proton::run(&config, latest, version, list)?;
} }
Commands::Config { action } => match action { Commands::Config { action } => match action {
ConfigAction::Show => config.show()?, ConfigAction::Show => config.show()?,
ConfigAction::Path => { ConfigAction::Path => {
println!("{}", config::Config::config_path()?.display()); println!("{}", config::Config::config_path()?.display());
} }
ConfigAction::Edit => config::Config::edit()?, ConfigAction::Edit => config::Config::edit()?,
ConfigAction::Set { prefix, compat_dir, gameid } => { ConfigAction::Set { proton_version, compat_dir } => {
let mut c = config; let mut c = config;
c.set_fields(prefix, compat_dir, gameid)?; c.set_globals(proton_version, compat_dir)?;
} }
}, },
Commands::Service { action } => match action { Commands::Service { action } => match action {
ServiceAction::Install => service::install()?, ServiceAction::Install => service::install()?,
ServiceAction::Uninstall => service::uninstall()?, ServiceAction::Uninstall => service::uninstall()?,
+60
View File
@@ -0,0 +1,60 @@
use crate::config::{Config, Launcher};
use anyhow::Result;
/// Print manual setup steps for a launcher.
///
/// This is a stub until the iced-based setup wizard lands. It walks the
/// user through creating the prefix directory, obtaining the installer,
/// and running it through umu.
pub fn run(config: &Config, launcher: &Launcher) -> Result<()> {
let version = launcher
.proton_version
.as_deref()
.unwrap_or(&config.proton_version);
let proton_path: String = if version == "GE-Proton" {
version.to_string()
} else {
config
.proton_compat_dir
.join(version)
.display()
.to_string()
};
println!("Setup steps for \x1b[1m{}\x1b[0m ({})", launcher.display, launcher.name);
println!();
println!("1. Create the prefix directory:");
println!(" mkdir -p {}", launcher.prefix_dir.display());
println!();
println!("2. Obtain the Windows installer for {}.", launcher.display);
if let Some(url) = &launcher.installer_url {
println!(" (configured source: {url})");
} else {
println!(
" No installer URL is configured for '{}'.",
launcher.name
);
println!(" Download the installer from the vendor and save it locally.");
}
println!();
println!("3. Run the installer under umu (replace INSTALLER.EXE with the path):");
println!(
" WINEPREFIX={} \\",
launcher.prefix_dir.display()
);
println!(" GAMEID={} \\", launcher.gameid);
println!(" PROTONPATH={proton_path} \\");
println!(" umu-run INSTALLER.EXE");
println!();
println!("4. After the installer finishes, verify it placed:");
println!(" {}", launcher.full_exe_path().display());
println!();
println!("5. Then: umutray launch {}", launcher.name);
println!();
println!(
"(A graphical setup wizard via iced is planned — this stub prints the manual\n \
steps in the meantime.)"
);
Ok(())
}
+74 -75
View File
@@ -1,97 +1,92 @@
use crate::{config::Config, launcher}; use crate::{config::Config, launcher};
use anyhow::Result; use anyhow::Result;
use std::collections::HashMap;
use std::thread; use std::thread;
use std::time::Duration; use std::time::Duration;
pub struct BattlenetTray { pub struct UmuTray {
pub config: Config, pub config: Config,
/// Whether Battle.net is currently running; updated by background poller. /// Per-launcher running state keyed by launcher.name
pub running: bool, pub running: HashMap<String, bool>,
/// Set after the service spawns so Quit can shut down the SNI item /// Set after the service spawns so Quit can shut down the SNI item
/// cleanly instead of yanking it off the bus via exit(). /// cleanly instead of yanking it off the bus via exit().
pub handle: Option<ksni::Handle<BattlenetTray>>, pub handle: Option<ksni::Handle<UmuTray>>,
} }
impl ksni::Tray for BattlenetTray { impl ksni::Tray for UmuTray {
fn id(&self) -> String { fn id(&self) -> String {
"umutray".into() "umutray".into()
} }
fn icon_name(&self) -> String { fn icon_name(&self) -> String {
// Use the extracted Battle.net icon if available, fall back to a generic one. "applications-games".into()
let icon_path = std::env::var("HOME")
.map(std::path::PathBuf::from)
.unwrap_or_default()
.join(".local/share/icons/hicolor/256x256/apps/battlenet.png");
if icon_path.exists() {
"battlenet".into()
} else {
"applications-games".into()
}
} }
fn title(&self) -> String { fn title(&self) -> String {
if self.running { if self.running.values().any(|&v| v) {
"Battle.net (running)".into() "umutray (launcher running)".into()
} else { } else {
"Battle.net".into() "umutray".into()
} }
} }
fn menu(&self) -> Vec<ksni::MenuItem<Self>> { fn menu(&self) -> Vec<ksni::MenuItem<Self>> {
use ksni::menu::*; use ksni::menu::*;
let mut items: Vec<ksni::MenuItem<Self>> = vec![]; let mut items: Vec<ksni::MenuItem<Self>> = vec![];
// Status label (non-interactive) for l in &self.config.launchers {
items.push( let installed = l.full_exe_path().exists();
StandardItem { let running = *self.running.get(&l.name).unwrap_or(&false);
label: if self.running { let name = l.name.clone();
"● Battle.net is running".into() let display = l.display.clone();
} else {
"Battle.net".into() if !installed {
}, items.push(
enabled: false, StandardItem {
..Default::default() label: format!("{display} (not installed)"),
enabled: false,
..Default::default()
}
.into(),
);
continue;
} }
.into(),
);
items.push(ksni::MenuItem::Separator); if running {
items.push(
// Launch / Kill StandardItem {
if self.running { label: format!("Kill {display}"),
items.push( icon_name: "process-stop".into(),
StandardItem { activate: Box::new(move |this: &mut Self| {
label: "Kill Battle.net".into(), if let Some(l) = this.config.find(&name) {
icon_name: "process-stop".into(), let _ = launcher::kill(l);
activate: Box::new(|_this: &mut Self| { }
let _ = launcher::kill(); }),
}), ..Default::default()
..Default::default() }
} .into(),
.into(), );
); } else {
} else { items.push(
items.push( StandardItem {
StandardItem { label: format!("Launch {display}"),
label: "Launch Battle.net".into(), icon_name: "media-playback-start".into(),
icon_name: "media-playback-start".into(), activate: Box::new(move |this: &mut Self| {
activate: Box::new(|this: &mut Self| { if let Some(l) = this.config.find(&name) {
if let Err(e) = launcher::launch(&this.config) { if let Err(e) = launcher::launch(&this.config, l) {
eprintln!("umutray: launch failed: {e}"); eprintln!("umutray: launch {} failed: {e}", l.name);
} }
}), }
..Default::default() }),
} ..Default::default()
.into(), }
); .into(),
);
}
} }
items.push(ksni::MenuItem::Separator); items.push(ksni::MenuItem::Separator);
// Update Proton (latest, background)
items.push( items.push(
StandardItem { StandardItem {
label: "Update GE-Proton (latest)".into(), label: "Update GE-Proton (latest)".into(),
@@ -111,16 +106,12 @@ impl ksni::Tray for BattlenetTray {
items.push(ksni::MenuItem::Separator); items.push(ksni::MenuItem::Separator);
// Quit
items.push( items.push(
StandardItem { StandardItem {
label: "Quit".into(), label: "Quit".into(),
icon_name: "application-exit".into(), icon_name: "application-exit".into(),
activate: Box::new(|this: &mut Self| { activate: Box::new(|this: &mut Self| {
if let Some(h) = this.handle.clone() { if let Some(h) = this.handle.clone() {
// Run shutdown off-thread: we're currently holding
// the tray lock inside update(), and shutdown wants
// the service loop to turn.
thread::spawn(move || { thread::spawn(move || {
h.shutdown(); h.shutdown();
std::process::exit(0); std::process::exit(0);
@@ -140,34 +131,42 @@ impl ksni::Tray for BattlenetTray {
/// Start the system tray daemon. Blocks until the process is killed. /// Start the system tray daemon. Blocks until the process is killed.
pub fn run(config: &Config) -> Result<()> { pub fn run(config: &Config) -> Result<()> {
let tray = BattlenetTray { let mut running = HashMap::new();
for l in &config.launchers {
running.insert(l.name.clone(), launcher::is_running(l));
}
let tray = UmuTray {
config: config.clone(), config: config.clone(),
running: launcher::is_running(), running,
handle: None, handle: None,
}; };
let service = ksni::TrayService::new(tray); let service = ksni::TrayService::new(tray);
// Grab a handle before spawn() consumes the service.
let handle = service.handle(); let handle = service.handle();
service.spawn(); service.spawn();
// Hand the tray a clone of its own handle so Quit can shut down cleanly. // Hand the tray a clone of its own handle so Quit can shut down cleanly.
let handle_for_self = handle.clone(); let handle_for_self = handle.clone();
handle.update(move |t: &mut BattlenetTray| { handle.update(move |t: &mut UmuTray| {
t.handle = Some(handle_for_self); t.handle = Some(handle_for_self);
}); });
// Background thread: poll Battle.net process state every 2 s and update the tray. // Background thread: poll every configured launcher's state every 2 s
// and push the snapshot to the tray.
let poll_handle = handle; let poll_handle = handle;
let launchers = config.launchers.clone();
thread::spawn(move || loop { thread::spawn(move || loop {
let running = launcher::is_running(); let mut snapshot: HashMap<String, bool> = HashMap::new();
poll_handle.update(|tray: &mut BattlenetTray| { for l in &launchers {
tray.running = running; snapshot.insert(l.name.clone(), launcher::is_running(l));
}
poll_handle.update(move |t: &mut UmuTray| {
t.running = snapshot;
}); });
thread::sleep(Duration::from_secs(2)); thread::sleep(Duration::from_secs(2));
}); });
// Keep the main thread alive.
loop { loop {
thread::sleep(Duration::from_secs(60)); thread::sleep(Duration::from_secs(60));
} }