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 {
|
||||
// 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)
|
||||
.unwrap_or_else(|_| PathBuf::from("/tmp"))
|
||||
.expect("$HOME is not set; cannot determine default paths")
|
||||
}
|
||||
|
||||
impl Config {
|
||||
|
||||
+20
-14
@@ -150,7 +150,9 @@ pub fn run(config: &Config) {
|
||||
|
||||
// ── Running processes ────────────────────────────────────────────────────
|
||||
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()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
@@ -209,19 +211,23 @@ fn count_ge_proton(dir: &Path) -> usize {
|
||||
}
|
||||
|
||||
fn is_owned_by_current_user(path: &Path) -> bool {
|
||||
// Compare stat uid with current euid via id command (avoids libc dependency)
|
||||
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();
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
match (uid_output, stat_output) {
|
||||
(Some(u), Some(s)) => {
|
||||
let uid = String::from_utf8_lossy(&u.stdout).trim().to_string();
|
||||
let owner = String::from_utf8_lossy(&s.stdout).trim().to_string();
|
||||
uid == owner
|
||||
}
|
||||
_ => true, // assume OK if we can't check
|
||||
let file_uid = match std::fs::metadata(path) {
|
||||
Ok(m) => m.uid(),
|
||||
Err(_) => return true, // assume OK if we can't check
|
||||
};
|
||||
|
||||
// No std API for the current process uid; shell out once to `id -u`.
|
||||
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")
|
||||
.env("WINEPREFIX", &config.prefix_dir)
|
||||
.env("GAMEID", &config.gameid)
|
||||
.env("PROTONPATH", &config.proton_version)
|
||||
.env("PROTONPATH", &proton_path)
|
||||
.arg(&exe)
|
||||
.spawn()
|
||||
.context(
|
||||
@@ -35,6 +43,8 @@ pub fn kill() -> Result<()> {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -43,6 +53,8 @@ pub fn kill() -> Result<()> {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -51,8 +63,12 @@ pub fn kill() -> Result<()> {
|
||||
|
||||
/// Returns true if any Battle.net process is currently running.
|
||||
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")
|
||||
.args(["-fi", "battle.net"])
|
||||
.args(["-fi", "Battle\\.net"])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
|
||||
+17
-9
@@ -31,6 +31,8 @@ fn fetch_releases(count: usize) -> Result<Vec<Release>> {
|
||||
.get(&url)
|
||||
.send()
|
||||
.context("GitHub API request failed")?
|
||||
.error_for_status()
|
||||
.context("GitHub API returned an error (rate limited?)")?
|
||||
.json()
|
||||
.context("Failed to parse GitHub releases JSON")?;
|
||||
Ok(releases)
|
||||
@@ -42,6 +44,8 @@ fn fetch_release(tag: &str) -> Result<Release> {
|
||||
.get(&url)
|
||||
.send()
|
||||
.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()
|
||||
.context("Failed to parse release JSON")?;
|
||||
Ok(release)
|
||||
@@ -65,26 +69,27 @@ fn install_version(config: &Config, tag: &str) -> Result<()> {
|
||||
.with_context(|| format!("No .tar.gz asset found for {tag}"))?;
|
||||
|
||||
println!("Downloading {}...", asset.name);
|
||||
let bytes = http_client()?
|
||||
// Stream straight to disk — the tarballs are ~600 MB and would otherwise
|
||||
// balloon resident memory before extraction even starts.
|
||||
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")?
|
||||
.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"));
|
||||
{
|
||||
.error_for_status()
|
||||
.context("Download returned an error status")?;
|
||||
let mut f = std::fs::File::create(&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);
|
||||
std::fs::create_dir_all(&config.proton_compat_dir)?;
|
||||
|
||||
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)
|
||||
.status()
|
||||
.context("Failed to run tar")?;
|
||||
@@ -153,6 +158,9 @@ fn print_list(config: &Config) -> Result<()> {
|
||||
|
||||
fn pick_interactively(config: &Config) -> Result<String> {
|
||||
let releases = fetch_releases(10)?;
|
||||
if releases.is_empty() {
|
||||
anyhow::bail!("GitHub returned no GE-Proton releases");
|
||||
}
|
||||
|
||||
println!("Recent GE-Proton releases:");
|
||||
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 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.
|
||||
let poller_handle = handle.clone();
|
||||
thread::spawn(move || loop {
|
||||
let running = launcher::is_running();
|
||||
poller_handle.update(|tray: &mut BattlenetTray| {
|
||||
handle.update(|tray: &mut BattlenetTray| {
|
||||
tray.running = running;
|
||||
});
|
||||
thread::sleep(Duration::from_secs(2));
|
||||
|
||||
Reference in New Issue
Block a user