Compare commits

...

6 Commits

Author SHA1 Message Date
funman300 7e5ed3d447 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
2026-04-16 21:43:58 -07:00
funman300 336c5d908e Add MIT LICENSE and crates.io metadata
Sets license, readme, repository, keywords and categories so cargo
publish / cargo install pick up the right info. Repo URL points at
the real git.aleshym.co remote.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 17:33:12 -07:00
funman300 6fe558ad00 Rename project: battlenet-manager → umutray
Binary, crate, clap app name, ksni tray id, HTTP user-agent, systemd unit,
XDG config dir (co.aleshym/umutray), README, and all log prefixes.

Config path changes from ~/.config/battlenet-manager/ to ~/.config/umutray/.
Existing users should `mv` the old directory if they've customised it;
otherwise defaults get rewritten on next run.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 17:28:13 -07:00
funman300 f7738d215b Add config and service subcommands
config subcommand — show, path, edit ($EDITOR), and a non-interactive
set that takes --prefix / --compat-dir / --gameid. Lets users retarget
the Wine prefix without hand-editing TOML.

service subcommand — install / uninstall / status for a systemd --user
unit that autostarts the tray. install writes ~/.config/systemd/user/
battlenet-manager.service with ExecStart pointing at the current binary,
then daemon-reloads and enable --now's the unit. uninstall tears it back
down.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 17:25:48 -07:00
funman300 8908c15974 Add download progress and graceful tray shutdown
- proton: wrap the download sink in a small ProgressWriter that prints
  percent / MiB to stderr every 1 MiB so the ~600 MB GE-Proton pull isn't
  silent for minutes. No extra deps.
- tray: store the ksni Handle on the tray itself so Quit can call
  shutdown() before exit(), unregistering the SNI item from D-Bus instead
  of leaving a stale entry until the session bus notices the PID is gone.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 17:21:15 -07:00
funman300 7de6f6d938 Fix launcher/download bugs, add README and Cargo.lock
- launcher: set PROTONPATH to full install path for pinned Proton versions;
  the raw tag name doesn't resolve when umu-run looks it up.
- proton: stream GE-Proton tarballs straight to disk instead of buffering
  ~600 MB in RAM via .bytes(); add error_for_status() on all HTTP calls so
  rate limits and 404s surface clearly; avoid UTF-8 trap on tar args.
- config: fail loudly when $HOME is unset instead of silently writing a Wine
  prefix under /tmp.
- diagnose: replace stat+id shell-out with MetadataExt::uid().
- tray: grab handle() before spawn() consumes the service (the repo didn't
  compile against ksni 0.2 as shipped).
- launcher/diagnose: escape the dot in "battle.net" pgrep patterns so the
  match doesn't false-positive on our own "battlenet-manager" binary; pipe
  pgrep/pkill stdio to /dev/null so PID lists don't leak into our output.
- proton: handle empty release list in pick_interactively cleanly.
- Add README, .gitignore, and commit Cargo.lock for reproducible builds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 17:18:54 -07:00
13 changed files with 3165 additions and 339 deletions
+1
View File
@@ -0,0 +1 @@
/target
Generated
+2135
View File
File diff suppressed because it is too large Load Diff
+8 -3
View File
@@ -1,11 +1,16 @@
[package] [package]
name = "battlenet-manager" name = "umutray"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
description = "Battle.net launcher manager for Linux via umu/Proton-GE" description = "Tray-based Wine launcher manager for Linux via umu/Proton-GE"
license = "MIT"
readme = "README.md"
repository = "https://git.aleshym.co/funman300/umutray"
keywords = ["wine", "proton", "umu", "tray", "launcher"]
categories = ["command-line-utilities", "games"]
[[bin]] [[bin]]
name = "battlenet-manager" name = "umutray"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 umutray contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+97
View File
@@ -0,0 +1,97 @@
# umutray
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)
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
- Tray icon on any SNI-capable desktop (KDE, GNOME+AppIndicator, Xfce …).
- Per-launcher running state reflected in the tray via a 2 s poller.
- `update-proton` — streams GE-Proton releases straight to disk from GitHub
(no ~600 MB in-memory buffering), with a progress indicator.
- `diagnose` — sanity-checks umu-run, Vulkan, display server, per-launcher
prefix / exe / ownership / running state.
- `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
```sh
cargo build --release
install -Dm755 target/release/umutray ~/.local/bin/umutray
```
Requires `umu-launcher`, `tar`, and `vulkan-tools` on PATH. On Arch:
```sh
sudo pacman -S umu-launcher vulkan-tools
```
Then enable autostart:
```sh
umutray service install
```
## Usage
| Command | What it does |
| -------------------------------- | ------------------------------------------------------- |
| `umutray` | Start the tray daemon (default) |
| `umutray launchers` | List configured launchers and their state |
| `umutray launch <name>` | Launch a specific launcher (e.g. `umutray launch epic`) |
| `umutray kill [<name>]` | Kill one launcher, or all if no name is given |
| `umutray diagnose [<name>]` | Health checks (one launcher or all) |
| `umutray setup <name>` | Print setup steps for a launcher |
| `umutray update-proton --latest` | Install newest GE-Proton release |
| `umutray update-proton --list` | Show recent releases without installing |
| `umutray update-proton` | Interactive version picker |
| `umutray config show` / `path` | Print current config or its file path |
| `umutray config edit` | Open config in `$EDITOR` |
| `umutray config set …` | Update globals (`--proton-version`, `--compat-dir`) |
| `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
Lives at `~/.config/umutray/config.toml`. A full config looks like:
```toml
proton_compat_dir = "/home/you/.local/share/Steam/compatibilitytools.d"
proton_version = "GE-Proton"
[[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.
Setting it to a pinned tag (done automatically by `update-proton`) uses
that specific version. Each launcher may override the global `proton_version`
with its own.
## License
MIT. See [LICENSE](LICENSE).
+214 -29
View File
@@ -4,56 +4,195 @@ 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 proton_compat_dir: PathBuf, 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 Default for Config { impl Launcher {
fn default() -> Self { /// Absolute path to the launcher exe inside the prefix.
let home = home_dir(); pub fn full_exe_path(&self) -> PathBuf {
Self { self.prefix_dir.join("drive_c").join(&self.exe_path)
prefix_dir: home.join("Games/battlenet-umu"),
proton_version: "GE-Proton".into(),
gameid: "umu-battlenet".into(),
proton_compat_dir: home.join(".local/share/Steam/compatibilitytools.d"),
}
} }
} }
#[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,
/// 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 { fn home_dir() -> PathBuf {
std::env::var("HOME") std::env::var("HOME")
.map(PathBuf::from) .map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/tmp")) .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 {
fn default() -> Self {
Self {
proton_compat_dir: default_compat_dir(),
proton_version: default_proton_version(),
launchers: presets(),
}
}
} }
impl Config { impl Config {
fn config_path() -> Result<PathBuf> { pub fn config_path() -> Result<PathBuf> {
let dirs = ProjectDirs::from("co.aleshym", "", "battlenet-manager") let dirs = ProjectDirs::from("co.aleshym", "", "umutray")
.context("Could not determine config directory")?; .context("Could not determine config directory")?;
Ok(dirs.config_dir().join("config.toml")) Ok(dirs.config_dir().join("config.toml"))
} }
/// Load config from disk, creating a default one if it doesn't exist.
pub fn load() -> Result<Self> { pub fn load() -> Result<Self> {
let path = Self::config_path()?; let path = Self::config_path()?;
if !path.exists() { if !path.exists() {
let config = Self::default(); let c = Self::default();
config.save().context("Failed to write default config")?; c.save().context("Failed to write default config")?;
return Ok(config); return Ok(c);
} }
let content = std::fs::read_to_string(&path) let content = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read config from {path:?}"))?; .with_context(|| format!("Failed to read config from {path:?}"))?;
toml::from_str(&content).context("Failed to parse config.toml") 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)
}
}
} }
/// Write config to disk.
pub fn save(&self) -> Result<()> { pub fn save(&self) -> Result<()> {
let path = Self::config_path()?; let path = Self::config_path()?;
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
@@ -64,9 +203,55 @@ impl Config {
.with_context(|| format!("Failed to write config to {path:?}")) .with_context(|| format!("Failed to write config to {path:?}"))
} }
/// Absolute path to the Battle.net Launcher.exe inside the prefix. pub fn find(&self, name: &str) -> Option<&Launcher> {
pub fn launcher_exe(&self) -> PathBuf { self.launchers.iter().find(|l| l.name == name)
self.prefix_dir }
.join("drive_c/Program Files (x86)/Battle.net/Battle.net Launcher.exe")
pub fn show(&self) -> Result<()> {
let path = Self::config_path()?;
println!("# {}", path.display());
let s = toml::to_string_pretty(self)?;
print!("{s}");
Ok(())
}
pub fn edit() -> Result<()> {
let _ = Self::load()?;
let path = Self::config_path()?;
let editor = std::env::var("EDITOR")
.or_else(|_| std::env::var("VISUAL"))
.unwrap_or_else(|_| "nano".into());
let status = std::process::Command::new(&editor)
.arg(&path)
.status()
.with_context(|| format!("Failed to spawn editor '{editor}'"))?;
if !status.success() {
anyhow::bail!("Editor '{editor}' exited non-zero");
}
Ok(())
}
/// Update global fields non-interactively, then save.
/// Use `config edit` for per-launcher changes.
pub fn set_globals(
&mut self,
proton_version: Option<String>,
compat_dir: Option<PathBuf>,
) -> Result<()> {
if proton_version.is_none() && compat_dir.is_none() {
anyhow::bail!(
"nothing to set — pass --proton-version or --compat-dir"
);
}
if let Some(v) = proton_version {
self.proton_version = v;
}
if let Some(d) = compat_dir {
self.proton_compat_dir = d;
}
self.save()?;
println!("\x1b[1;32m✓\x1b[0m Config saved.");
println!();
self.show()
} }
} }
+167 -174
View File
@@ -1,174 +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: battlenet-manager 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: battlenet-manager 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"])
.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 {
@@ -177,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 {
@@ -208,20 +192,29 @@ 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> {
// Compare stat uid with current euid via id command (avoids libc dependency) Command::new("which")
let uid_output = Command::new("id").arg("-u").output().ok(); .arg(cmd)
let stat_output = Command::new("stat")
.args(["-c", "%u", path.to_str().unwrap_or("")])
.output() .output()
.ok(); .ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
}
match (uid_output, stat_output) { fn is_owned_by_current_user(path: &Path) -> bool {
(Some(u), Some(s)) => { let file_uid = match std::fs::metadata(path) {
let uid = String::from_utf8_lossy(&u.stdout).trim().to_string(); Ok(m) => m.uid(),
let owner = String::from_utf8_lossy(&s.stdout).trim().to_string(); Err(_) => return true,
uid == owner };
} let current_uid: Option<u32> = Command::new("id")
_ => true, // assume OK if we can't check .arg("-u")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.and_then(|s| s.trim().parse().ok());
match current_uid {
Some(uid) => uid == file_uid,
None => true,
} }
} }
+63 -33
View File
@@ -1,23 +1,37 @@
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
// latest; for any pinned version it expects a full path to the install dir.
let version = launcher
.proton_version
.as_deref()
.unwrap_or(&config.proton_version);
let proton_path: std::ffi::OsString = if version == "GE-Proton" {
version.to_string().into()
} else {
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", &config.proton_version) .env("PROTONPATH", &proton_path)
.arg(&exe) .arg(&exe)
.spawn() .spawn()
.context( .context(
@@ -28,31 +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])
.status();
}
thread::sleep(Duration::from_secs(3));
for pattern in &patterns {
let _ = std::process::Command::new("pkill")
.args(["-9", "-f", pattern])
.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<()> {
// Single SIGTERM pass across all launchers, then one sleep, then SIGKILL.
// This keeps the total wait at 3 s instead of 3 s × N.
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(Stdio::null())
.stderr(Stdio::null())
.status() .status()
.map(|s| s.success()) .map(|s| s.success())
.unwrap_or(false) .unwrap_or(false)
+133 -13
View File
@@ -2,17 +2,19 @@ mod config;
mod diagnose; mod diagnose;
mod launcher; mod launcher;
mod proton; mod proton;
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;
/// 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 = "battlenet-manager", version, about)] #[command(name = "umutray", version, about)]
struct Cli { struct Cli {
#[command(subcommand)] #[command(subcommand)]
command: Option<Commands>, command: Option<Commands>,
@@ -23,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 {
@@ -38,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>,
@@ -46,6 +66,48 @@ enum Commands {
#[arg(long)] #[arg(long)]
list: bool, list: bool,
}, },
/// Show or modify configuration
Config {
#[command(subcommand)]
action: ConfigAction,
},
/// Manage the systemd --user service that autostarts the tray
Service {
#[command(subcommand)]
action: ServiceAction,
},
}
#[derive(Subcommand)]
enum ConfigAction {
/// Print the config file path and current values
Show,
/// Print just the config file path
Path,
/// Open the config file in $EDITOR
Edit,
/// Update global fields. Use `config edit` for per-launcher changes.
Set {
/// Default Proton version (e.g. GE-Proton, GE-Proton10-34)
#[arg(long, value_name = "VERSION")]
proton_version: Option<String>,
/// GE-Proton install directory
#[arg(long, value_name = "PATH")]
compat_dir: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum ServiceAction {
/// Write the unit, daemon-reload, and enable+start the service
Install,
/// Stop, disable, and remove the unit file
Uninstall,
/// Show `systemctl --user status` for the service
Status,
} }
fn main() -> Result<()> { fn main() -> Result<()> {
@@ -54,12 +116,70 @@ 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 {
ConfigAction::Show => config.show()?,
ConfigAction::Path => {
println!("{}", config::Config::config_path()?.display());
}
ConfigAction::Edit => config::Config::edit()?,
ConfigAction::Set { proton_version, compat_dir } => {
let mut c = config;
c.set_globals(proton_version, compat_dir)?;
}
},
Commands::Service { action } => match action {
ServiceAction::Install => service::install()?,
ServiceAction::Uninstall => service::uninstall()?,
ServiceAction::Status => service::status()?,
},
} }
Ok(()) Ok(())
+72 -12
View File
@@ -20,7 +20,7 @@ struct Asset {
fn http_client() -> Result<reqwest::blocking::Client> { fn http_client() -> Result<reqwest::blocking::Client> {
reqwest::blocking::Client::builder() reqwest::blocking::Client::builder()
.user_agent("battlenet-manager/0.1") .user_agent("umutray/0.1")
.build() .build()
.context("Failed to build HTTP client") .context("Failed to build HTTP client")
} }
@@ -31,6 +31,8 @@ fn fetch_releases(count: usize) -> Result<Vec<Release>> {
.get(&url) .get(&url)
.send() .send()
.context("GitHub API request failed")? .context("GitHub API request failed")?
.error_for_status()
.context("GitHub API returned an error (rate limited?)")?
.json() .json()
.context("Failed to parse GitHub releases JSON")?; .context("Failed to parse GitHub releases JSON")?;
Ok(releases) Ok(releases)
@@ -42,6 +44,8 @@ fn fetch_release(tag: &str) -> Result<Release> {
.get(&url) .get(&url)
.send() .send()
.with_context(|| format!("GitHub API request failed for tag {tag}"))? .with_context(|| format!("GitHub API request failed for tag {tag}"))?
.error_for_status()
.with_context(|| format!("GitHub API returned an error for tag {tag}"))?
.json() .json()
.context("Failed to parse release JSON")?; .context("Failed to parse release JSON")?;
Ok(release) Ok(release)
@@ -65,26 +69,30 @@ fn install_version(config: &Config, tag: &str) -> Result<()> {
.with_context(|| format!("No .tar.gz asset found for {tag}"))?; .with_context(|| format!("No .tar.gz asset found for {tag}"))?;
println!("Downloading {}...", asset.name); println!("Downloading {}...", asset.name);
let bytes = http_client()? // Stream straight to disk — the tarballs are ~600 MB and would otherwise
.get(&asset.browser_download_url) // balloon resident memory before extraction even starts.
.send()
.context("Download failed")?
.bytes()
.context("Failed to read response bytes")?;
// Write to a temp file then extract with system tar (avoids flate2/tar deps)
let tmp_path = std::env::temp_dir().join(format!("{tag}.tar.gz")); let tmp_path = std::env::temp_dir().join(format!("{tag}.tar.gz"));
{ {
let mut f = std::fs::File::create(&tmp_path) let mut resp = http_client()?
.get(&asset.browser_download_url)
.send()
.context("Download failed")?
.error_for_status()
.context("Download returned an error status")?;
let total = resp.content_length();
let f = std::fs::File::create(&tmp_path)
.with_context(|| format!("Failed to create temp file {tmp_path:?}"))?; .with_context(|| format!("Failed to create temp file {tmp_path:?}"))?;
f.write_all(&bytes).context("Failed to write archive")?; let mut progress = ProgressWriter::new(f, total);
std::io::copy(&mut resp, &mut progress).context("Failed to stream archive to disk")?;
progress.finish();
} }
println!("Extracting to {:?}...", config.proton_compat_dir); println!("Extracting to {:?}...", config.proton_compat_dir);
std::fs::create_dir_all(&config.proton_compat_dir)?; std::fs::create_dir_all(&config.proton_compat_dir)?;
let status = std::process::Command::new("tar") let status = std::process::Command::new("tar")
.args(["-xzf", tmp_path.to_str().unwrap_or("")]) .arg("-xzf")
.arg(&tmp_path)
.current_dir(&config.proton_compat_dir) .current_dir(&config.proton_compat_dir)
.status() .status()
.context("Failed to run tar")?; .context("Failed to run tar")?;
@@ -151,8 +159,60 @@ fn print_list(config: &Config) -> Result<()> {
Ok(()) Ok(())
} }
/// Wraps a writer to print download progress to stderr, throttled to one
/// update per megabyte so we don't spam the terminal.
struct ProgressWriter<W: Write> {
inner: W,
total: Option<u64>,
written: u64,
last_print: u64,
}
impl<W: Write> ProgressWriter<W> {
fn new(inner: W, total: Option<u64>) -> Self {
Self { inner, total, written: 0, last_print: 0 }
}
fn finish(&mut self) {
let _ = self.inner.flush();
// Clear the progress line — the caller's next println! starts fresh.
eprintln!();
}
}
impl<W: Write> Write for ProgressWriter<W> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let n = self.inner.write(buf)?;
self.written += n as u64;
if self.written - self.last_print >= 1 << 20 {
self.last_print = self.written;
match self.total {
Some(t) => {
let pct = (self.written as f64 / t as f64) * 100.0;
eprint!(
"\r {:.1}% ({} / {} MiB)",
pct,
self.written >> 20,
t >> 20,
);
}
None => eprint!("\r {} MiB", self.written >> 20),
}
let _ = std::io::stderr().flush();
}
Ok(n)
}
fn flush(&mut self) -> std::io::Result<()> {
self.inner.flush()
}
}
fn pick_interactively(config: &Config) -> Result<String> { fn pick_interactively(config: &Config) -> Result<String> {
let releases = fetch_releases(10)?; let releases = fetch_releases(10)?;
if releases.is_empty() {
anyhow::bail!("GitHub returned no GE-Proton releases");
}
println!("Recent GE-Proton releases:"); println!("Recent GE-Proton releases:");
for (i, r) in releases.iter().enumerate() { for (i, r) in releases.iter().enumerate() {
+98
View File
@@ -0,0 +1,98 @@
use anyhow::{bail, Context, Result};
use std::path::PathBuf;
use std::process::Command;
const UNIT_NAME: &str = "umutray.service";
fn unit_path() -> Result<PathBuf> {
let home = std::env::var("HOME").context("$HOME is not set")?;
Ok(PathBuf::from(home)
.join(".config/systemd/user")
.join(UNIT_NAME))
}
fn render_unit(exe: &std::path::Path) -> String {
format!(
"[Unit]\n\
Description=Battle.net tray 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 systemctl(args: &[&str]) -> Result<()> {
let status = Command::new("systemctl")
.arg("--user")
.args(args)
.status()
.context("Failed to invoke systemctl --user (is systemd installed?)")?;
if !status.success() {
bail!("systemctl --user {} exited non-zero", args.join(" "));
}
Ok(())
}
/// Write the unit, reload systemd, and enable+start the service.
pub fn install() -> Result<()> {
let exe = std::env::current_exe()
.context("Cannot determine path to own executable")?;
let path = unit_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create {parent:?}"))?;
}
let contents = render_unit(&exe);
std::fs::write(&path, &contents)
.with_context(|| format!("Failed to write unit file {path:?}"))?;
println!("Wrote unit: {}", path.display());
println!("ExecStart: {}", exe.display());
println!();
systemctl(&["daemon-reload"])?;
systemctl(&["enable", "--now", UNIT_NAME])?;
println!();
println!("\x1b[1;32m✓\x1b[0m Service installed and started.");
println!(" Status: systemctl --user status {UNIT_NAME}");
println!(" Logs: journalctl --user -u {UNIT_NAME} -f");
Ok(())
}
/// Stop, disable, and remove the unit file.
pub fn uninstall() -> Result<()> {
let path = unit_path()?;
// Ignore failures: the unit may already be stopped or unknown to systemd.
let _ = systemctl(&["disable", "--now", UNIT_NAME]);
if path.exists() {
std::fs::remove_file(&path)
.with_context(|| format!("Failed to remove {path:?}"))?;
println!("Removed {}", path.display());
} else {
println!("No unit file at {}", path.display());
}
let _ = systemctl(&["daemon-reload"]);
println!("\x1b[1;32m✓\x1b[0m Service removed.");
Ok(())
}
/// Pass through `systemctl --user status`.
pub fn status() -> Result<()> {
let _ = Command::new("systemctl")
.args(["--user", "status", UNIT_NAME])
.status();
Ok(())
}
+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(())
}
+96 -75
View File
@@ -1,94 +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
/// cleanly instead of yanking it off the bus via exit().
pub handle: Option<ksni::Handle<UmuTray>>,
} }
impl ksni::Tray for BattlenetTray { impl ksni::Tray for UmuTray {
fn id(&self) -> String { fn id(&self) -> String {
"battlenet-manager".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!("battlenet-manager: 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(),
@@ -97,7 +95,7 @@ impl ksni::Tray for BattlenetTray {
let config = this.config.clone(); let config = this.config.clone();
thread::spawn(move || { thread::spawn(move || {
if let Err(e) = crate::proton::install_latest(&config) { if let Err(e) = crate::proton::install_latest(&config) {
eprintln!("battlenet-manager: proton update failed: {e}"); eprintln!("umutray: proton update failed: {e}");
} }
}); });
}), }),
@@ -108,13 +106,19 @@ 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| {
std::process::exit(0); if let Some(h) = this.handle.clone() {
thread::spawn(move || {
h.shutdown();
std::process::exit(0);
});
} else {
std::process::exit(0);
}
}), }),
..Default::default() ..Default::default()
} }
@@ -127,25 +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,
}; };
let service = ksni::TrayService::new(tray); let service = ksni::TrayService::new(tray);
let handle = service.spawn(); let handle = service.handle();
service.spawn();
// Background thread: poll Battle.net process state every 2 s and update the tray. // Hand the tray a clone of its own handle so Quit can shut down cleanly.
let poller_handle = handle.clone(); let handle_for_self = handle.clone();
handle.update(move |t: &mut UmuTray| {
t.handle = Some(handle_for_self);
});
// Background thread: poll every configured launcher's state every 2 s
// and push the snapshot to the tray.
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();
poller_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));
} }