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>
This commit is contained in:
funman300
2026-04-16 17:18:54 -07:00
parent 246ad03266
commit 7de6f6d938
8 changed files with 2260 additions and 30 deletions
+1
View File
@@ -0,0 +1 @@
/target
Generated
+2135
View File
File diff suppressed because it is too large Load Diff
+61
View File
@@ -0,0 +1,61 @@
# battlenet-manager
A small system-tray daemon and CLI for running the Battle.net launcher on
Linux via [umu-launcher](https://github.com/Open-Wine-Components/umu-launcher)
and [GE-Proton](https://github.com/GloriousEggroll/proton-ge-custom).
## Features
- Tray icon with Launch / Kill / Update-Proton menu (KDE, GNOME+AppIndicator,
Xfce, any SNI-capable desktop).
- Background poller that reflects Battle.net's running state in the tray.
- `update-proton` subcommand that downloads GE-Proton releases directly from
GitHub and installs them under the Steam compat tools directory.
- `diagnose` subcommand that sanity-checks the environment (umu-run, prefix,
Proton install, Vulkan, display server, stale agent.lock).
## Install
```sh
cargo build --release
install -Dm755 target/release/battlenet-manager ~/.local/bin/battlenet-manager
```
Requires `umu-launcher` and `tar` on PATH. On Arch:
```sh
sudo pacman -S umu-launcher vulkan-tools
```
The Battle.net Launcher.exe itself is not bundled — run your existing
`battlenet-umu-setup.sh` (or install it manually into the prefix) before
first launch.
## Usage
| Command | What it does |
| --------------------------------- | ------------------------------------------------------- |
| `battlenet-manager` | Start the tray daemon (default) |
| `battlenet-manager launch` | Launch Battle.net and return (for `.desktop` shortcuts) |
| `battlenet-manager kill` | SIGTERM → wait 3 s → SIGKILL on all Battle.net procs |
| `battlenet-manager diagnose` | Run environment health checks |
| `battlenet-manager update-proton` | Interactive GE-Proton picker |
| `update-proton --latest` | Install newest GE-Proton release |
| `update-proton --version X` | Install a specific tag (e.g. `GE-Proton10-34`) |
| `update-proton --list` | Show recent releases without installing |
## Config
Lives at `~/.config/battlenet-manager/config.toml`, written with defaults on
first run:
```toml
prefix_dir = "~/Games/battlenet-umu"
proton_version = "GE-Proton" # or a pinned tag like "GE-Proton10-34"
gameid = "umu-battlenet"
proton_compat_dir = "~/.local/share/Steam/compatibilitytools.d"
```
`proton_version = "GE-Proton"` tells umu-launcher to auto-fetch the latest
on each run. Setting it to a specific tag (done automatically by
`update-proton`) pins that version.
+3 -1
View File
@@ -28,9 +28,11 @@ impl Default for Config {
} }
fn home_dir() -> PathBuf { 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") 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")
} }
impl Config { impl Config {
+20 -14
View File
@@ -150,7 +150,9 @@ pub fn run(config: &Config) {
// ── Running processes ──────────────────────────────────────────────────── // ── Running processes ────────────────────────────────────────────────────
let bnet_running = Command::new("pgrep") let bnet_running = Command::new("pgrep")
.args(["-fi", "battle.net"]) .args(["-fi", "Battle\\.net"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status() .status()
.map(|s| s.success()) .map(|s| s.success())
.unwrap_or(false); .unwrap_or(false);
@@ -209,19 +211,23 @@ fn count_ge_proton(dir: &Path) -> usize {
} }
fn is_owned_by_current_user(path: &Path) -> bool { fn is_owned_by_current_user(path: &Path) -> bool {
// Compare stat uid with current euid via id command (avoids libc dependency) use std::os::unix::fs::MetadataExt;
let uid_output = Command::new("id").arg("-u").output().ok();
let stat_output = Command::new("stat")
.args(["-c", "%u", path.to_str().unwrap_or("")])
.output()
.ok();
match (uid_output, stat_output) { let file_uid = match std::fs::metadata(path) {
(Some(u), Some(s)) => { Ok(m) => m.uid(),
let uid = String::from_utf8_lossy(&u.stdout).trim().to_string(); Err(_) => return true, // assume OK if we can't check
let owner = String::from_utf8_lossy(&s.stdout).trim().to_string(); };
uid == owner
} // No std API for the current process uid; shell out once to `id -u`.
_ => true, // assume OK if we can't check let current_uid: Option<u32> = Command::new("id")
.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,
} }
} }
+18 -2
View File
@@ -14,10 +14,18 @@ pub fn launch(config: &Config) -> Result<()> {
); );
} }
// 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 proton_path: std::ffi::OsString = if config.proton_version == "GE-Proton" {
config.proton_version.clone().into()
} else {
config.proton_compat_dir.join(&config.proton_version).into_os_string()
};
std::process::Command::new("umu-run") std::process::Command::new("umu-run")
.env("WINEPREFIX", &config.prefix_dir) .env("WINEPREFIX", &config.prefix_dir)
.env("GAMEID", &config.gameid) .env("GAMEID", &config.gameid)
.env("PROTONPATH", &config.proton_version) .env("PROTONPATH", &proton_path)
.arg(&exe) .arg(&exe)
.spawn() .spawn()
.context( .context(
@@ -35,6 +43,8 @@ pub fn kill() -> Result<()> {
for pattern in &patterns { for pattern in &patterns {
let _ = std::process::Command::new("pkill") let _ = std::process::Command::new("pkill")
.args(["-15", "-f", pattern]) .args(["-15", "-f", pattern])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status(); .status();
} }
@@ -43,6 +53,8 @@ pub fn kill() -> Result<()> {
for pattern in &patterns { for pattern in &patterns {
let _ = std::process::Command::new("pkill") let _ = std::process::Command::new("pkill")
.args(["-9", "-f", pattern]) .args(["-9", "-f", pattern])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status(); .status();
} }
@@ -51,8 +63,12 @@ pub fn kill() -> Result<()> {
/// Returns true if any Battle.net process is currently running. /// Returns true if any Battle.net process is currently running.
pub fn is_running() -> bool { pub fn is_running() -> bool {
// Escape the dot — unescaped, "battle.net" also matches our own
// "battlenet-manager" binary and reports a false positive.
std::process::Command::new("pgrep") std::process::Command::new("pgrep")
.args(["-fi", "battle.net"]) .args(["-fi", "Battle\\.net"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status() .status()
.map(|s| s.success()) .map(|s| s.success())
.unwrap_or(false) .unwrap_or(false)
+18 -10
View File
@@ -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,27 @@ 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 resp = http_client()?
.get(&asset.browser_download_url)
.send()
.context("Download failed")?
.error_for_status()
.context("Download returned an error status")?;
let mut f = std::fs::File::create(&tmp_path) let mut 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")?; std::io::copy(&mut resp, &mut f).context("Failed to stream archive to disk")?;
} }
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")?;
@@ -153,6 +158,9 @@ fn print_list(config: &Config) -> Result<()> {
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() {
+4 -3
View File
@@ -133,13 +133,14 @@ pub fn run(config: &Config) -> Result<()> {
}; };
let service = ksni::TrayService::new(tray); let service = ksni::TrayService::new(tray);
let handle = service.spawn(); // Grab a handle before spawn() consumes the service.
let handle = service.handle();
service.spawn();
// Background thread: poll Battle.net process state every 2 s and update the tray. // Background thread: poll Battle.net process state every 2 s and update the tray.
let poller_handle = handle.clone();
thread::spawn(move || loop { thread::spawn(move || loop {
let running = launcher::is_running(); let running = launcher::is_running();
poller_handle.update(|tray: &mut BattlenetTray| { handle.update(|tray: &mut BattlenetTray| {
tray.running = running; tray.running = running;
}); });
thread::sleep(Duration::from_secs(2)); thread::sleep(Duration::from_secs(2));