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:
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
Generated
+2135
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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));
|
||||||
|
|||||||
Reference in New Issue
Block a user