commit 1559ee5f2b4211be76524af507daca604bc110a5 Author: funman300 Date: Thu Apr 16 13:28:17 2026 -0700 Initial commit: battlenet-manager tray daemon and CLI diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3400059 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "battlenet-manager" +version = "0.1.0" +edition = "2021" +description = "Battle.net launcher manager for Linux via umu/Proton-GE" + +[[bin]] +name = "battlenet-manager" +path = "src/main.rs" + +[dependencies] +# CLI argument parsing +clap = { version = "4", features = ["derive"] } + +# Config serialisation +serde = { version = "1", features = ["derive"] } +toml = "0.8" + +# GitHub API responses +serde_json = "1" + +# Error handling +anyhow = "1" + +# XDG config / data paths +directories = "5" + +# System tray via D-Bus StatusNotifierItem (KDE, GNOME+AppIndicator, Xfce, etc.) +ksni = "0.2" + +# HTTP for GE-Proton GitHub releases API +reqwest = { version = "0.12", features = ["blocking", "json"] } diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..8a45b48 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,72 @@ +use anyhow::{Context, Result}; +use directories::ProjectDirs; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + /// Wine prefix directory + pub prefix_dir: PathBuf, + /// GE-Proton version string passed to PROTONPATH ("GE-Proton" tracks latest) + pub proton_version: String, + /// umu GAMEID used to look up protonfixes + pub gameid: String, + /// Directory where GE-Proton versions are installed + pub proton_compat_dir: PathBuf, +} + +impl Default for Config { + fn default() -> Self { + let home = home_dir(); + Self { + 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"), + } + } +} + +fn home_dir() -> PathBuf { + std::env::var("HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("/tmp")) +} + +impl Config { + fn config_path() -> Result { + let dirs = ProjectDirs::from("co.aleshym", "", "battlenet-manager") + .context("Could not determine config directory")?; + Ok(dirs.config_dir().join("config.toml")) + } + + /// Load config from disk, creating a default one if it doesn't exist. + pub fn load() -> Result { + 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") + } +} diff --git a/src/diagnose.rs b/src/diagnose.rs new file mode 100644 index 0000000..185340f --- /dev/null +++ b/src/diagnose.rs @@ -0,0 +1,227 @@ +use crate::config::Config; +use std::path::Path; +use std::process::Command; + +struct Check { + label: &'static str, + pass: bool, + detail: String, +} + +impl Check { + fn pass(label: &'static str, detail: impl Into) -> Self { + Self { label, pass: true, detail: detail.into() } + } + fn fail(label: &'static str, detail: impl Into) -> Self { + Self { label, pass: false, detail: detail.into() } + } +} + +pub fn run(config: &Config) { + let mut checks: Vec = Vec::new(); + 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!(); + for c in &checks { + let (symbol, colour, reset) = if c.pass { + ("✓", "\x1b[1;32m", "\x1b[0m") + } else { + ("✗", "\x1b[1;31m", "\x1b[0m") + }; + println!(" {colour}{symbol}{reset} {:16} {}", c.label, c.detail); + } + println!(); + if issues == 0 { + println!(" \x1b[1;32mAll checks passed.\x1b[0m"); + } else { + println!(" \x1b[1;31m{issues} issue(s) found — see ✗ items above.\x1b[0m"); + } + println!(); +} + +// ── helpers ────────────────────────────────────────────────────────────────── + +fn which(cmd: &str) -> Option { + 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 count_ge_proton(dir: &Path) -> usize { + std::fs::read_dir(dir) + .ok() + .map(|entries| { + entries + .filter_map(|e| e.ok()) + .filter(|e| { + e.file_name() + .to_str() + .map(|s| s.starts_with("GE-Proton")) + .unwrap_or(false) + }) + .count() + }) + .unwrap_or(0) +} + +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(); + + 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 + } +} diff --git a/src/launcher.rs b/src/launcher.rs new file mode 100644 index 0000000..57a39d1 --- /dev/null +++ b/src/launcher.rs @@ -0,0 +1,59 @@ +use crate::config::Config; +use anyhow::{Context, Result}; +use std::thread; +use std::time::Duration; + +/// Spawn Battle.net via umu-run and return immediately. +pub fn launch(config: &Config) -> Result<()> { + let exe = config.launcher_exe(); + + if !exe.exists() { + anyhow::bail!( + "Battle.net Launcher.exe not found at {exe:?}\n\ + Run 'battlenet-umu-setup.sh' to install it first." + ); + } + + std::process::Command::new("umu-run") + .env("WINEPREFIX", &config.prefix_dir) + .env("GAMEID", &config.gameid) + .env("PROTONPATH", &config.proton_version) + .arg(&exe) + .spawn() + .context( + "Failed to spawn umu-run. Is it installed?\n\ + Run: sudo pacman -S umu-launcher", + )?; + + Ok(()) +} + +/// Gracefully stop Battle.net: SIGTERM → wait 3 s → SIGKILL. +pub fn kill() -> Result<()> { + let patterns = ["Battle\\.net", "Agent\\.exe", "Blizzard"]; + + 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(()) +} + +/// Returns true if any Battle.net process is currently running. +pub fn is_running() -> bool { + std::process::Command::new("pgrep") + .args(["-fi", "battle.net"]) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..be63123 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,66 @@ +mod config; +mod diagnose; +mod launcher; +mod proton; +mod tray; + +use anyhow::Result; +use clap::{Parser, Subcommand}; + +/// Battle.net launcher manager for Linux via umu/Proton-GE. +/// +/// Running without a subcommand starts the system tray daemon. +/// Use `launch` in your .desktop shortcut for a direct, no-UI launch. +#[derive(Parser)] +#[command(name = "battlenet-manager", version, about)] +struct Cli { + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum Commands { + /// Start the system tray daemon (default when no subcommand given) + Tray, + + /// Launch Battle.net immediately and return — use this in .desktop shortcuts + Launch, + + /// Gracefully kill all Battle.net / Wine processes + Kill, + + /// Check setup health and report any problems + Diagnose, + + /// Download and switch GE-Proton versions + UpdateProton { + /// Install the latest release automatically + #[arg(long)] + latest: bool, + + /// Install a specific version (e.g. GE-Proton9-20) + #[arg(long, value_name = "VERSION")] + version: Option, + + /// List recent releases and exit + #[arg(long)] + list: bool, + }, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let config = config::Config::load()?; + + match cli.command.unwrap_or(Commands::Tray) { + Commands::Tray => tray::run(&config)?, + Commands::Launch => launcher::launch(&config)?, + Commands::Kill => launcher::kill()?, + Commands::Diagnose => diagnose::run(&config), + Commands::UpdateProton { latest, version, list } => { + proton::run(&config, latest, version, list)?; + } + } + + Ok(()) +} diff --git a/src/proton.rs b/src/proton.rs new file mode 100644 index 0000000..061ccc5 --- /dev/null +++ b/src/proton.rs @@ -0,0 +1,183 @@ +use crate::config::Config; +use anyhow::{Context, Result}; +use serde::Deserialize; +use std::io::Write; + +const GITHUB_API: &str = + "https://api.github.com/repos/GloriousEggroll/proton-ge-custom/releases"; + +#[derive(Deserialize)] +struct Release { + tag_name: String, + assets: Vec, +} + +#[derive(Deserialize)] +struct Asset { + name: String, + browser_download_url: String, +} + +fn http_client() -> Result { + reqwest::blocking::Client::builder() + .user_agent("battlenet-manager/0.1") + .build() + .context("Failed to build HTTP client") +} + +fn fetch_releases(count: usize) -> Result> { + let url = format!("{GITHUB_API}?per_page={count}"); + let releases: Vec = http_client()? + .get(&url) + .send() + .context("GitHub API request failed")? + .json() + .context("Failed to parse GitHub releases JSON")?; + Ok(releases) +} + +fn fetch_release(tag: &str) -> Result { + let url = format!("{GITHUB_API}/tags/{tag}"); + let release: Release = http_client()? + .get(&url) + .send() + .with_context(|| format!("GitHub API request failed for tag {tag}"))? + .json() + .context("Failed to parse release JSON")?; + Ok(release) +} + +/// Download and extract a specific GE-Proton version. +fn install_version(config: &Config, tag: &str) -> Result<()> { + let install_path = config.proton_compat_dir.join(tag); + if install_path.exists() { + println!("{tag} is already installed at {install_path:?}"); + return Ok(()); + } + + println!("Fetching release metadata for {tag}..."); + let release = fetch_release(tag)?; + + let asset = release + .assets + .iter() + .find(|a| a.name.ends_with(".tar.gz")) + .with_context(|| format!("No .tar.gz asset found for {tag}"))?; + + println!("Downloading {}...", asset.name); + let bytes = 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")); + { + 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")?; + } + + 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("")]) + .current_dir(&config.proton_compat_dir) + .status() + .context("Failed to run tar")?; + + let _ = std::fs::remove_file(&tmp_path); + + if !status.success() { + anyhow::bail!("tar extraction failed for {tag}"); + } + + println!("✓ {tag} installed"); + Ok(()) +} + +/// Install the latest GE-Proton release (called from tray menu). +pub fn install_latest(config: &Config) -> Result<()> { + println!("Checking for latest GE-Proton..."); + let releases = fetch_releases(1)?; + let latest = releases + .into_iter() + .next() + .context("No releases returned from GitHub")?; + install_version(config, &latest.tag_name) +} + +/// Entry point for the `update-proton` subcommand. +pub fn run(config: &Config, latest: bool, version: Option, list: bool) -> Result<()> { + if list { + return print_list(config); + } + + let target = if latest { + let releases = fetch_releases(1)?; + releases + .into_iter() + .next() + .map(|r| r.tag_name) + .context("No releases found")? + } else if let Some(v) = version { + v + } else { + pick_interactively(config)? + }; + + install_version(config, &target)?; + + // Update config to track this version + let mut updated = config.clone(); + updated.proton_version = target.clone(); + updated.save()?; + println!("Config updated: proton_version = \"{target}\""); + + Ok(()) +} + +fn print_list(config: &Config) -> Result<()> { + println!("Recent GE-Proton releases:"); + let releases = fetch_releases(10)?; + for r in &releases { + let installed = config.proton_compat_dir.join(&r.tag_name).exists(); + let marker = if installed { " \x1b[1;32m✓ installed\x1b[0m" } else { "" }; + println!(" {}{}", r.tag_name, marker); + } + Ok(()) +} + +fn pick_interactively(config: &Config) -> Result { + let releases = fetch_releases(10)?; + + println!("Recent GE-Proton releases:"); + for (i, r) in releases.iter().enumerate() { + let installed = config.proton_compat_dir.join(&r.tag_name).exists(); + let marker = if installed { " \x1b[1;32m✓\x1b[0m" } else { "" }; + println!(" {:2}) {}{}", i + 1, r.tag_name, marker); + } + + print!("\nEnter number [default: 1 = {}]: ", releases[0].tag_name); + std::io::stdout().flush()?; + + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + let input = input.trim(); + + if input.is_empty() { + return Ok(releases[0].tag_name.clone()); + } + + if let Ok(n) = input.parse::() { + if n >= 1 && n <= releases.len() { + return Ok(releases[n - 1].tag_name.clone()); + } + } + + // Allow typing a version tag directly + Ok(input.to_string()) +} diff --git a/src/tray.rs b/src/tray.rs new file mode 100644 index 0000000..ad20fea --- /dev/null +++ b/src/tray.rs @@ -0,0 +1,152 @@ +use crate::{config::Config, launcher}; +use anyhow::Result; +use std::thread; +use std::time::Duration; + +pub struct BattlenetTray { + pub config: Config, + /// Whether Battle.net is currently running; updated by background poller. + pub running: bool, +} + +impl ksni::Tray for BattlenetTray { + fn id(&self) -> String { + "battlenet-manager".into() + } + + fn icon_name(&self) -> String { + // Use the extracted Battle.net icon if available, fall back to a generic one. + 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 { + if self.running { + "Battle.net (running)".into() + } else { + "Battle.net".into() + } + } + + fn menu(&self) -> Vec> { + use ksni::menu::*; + + let mut items: Vec> = vec![]; + + // Status label (non-interactive) + items.push( + StandardItem { + label: if self.running { + "● Battle.net is running".into() + } else { + "Battle.net".into() + }, + enabled: false, + ..Default::default() + } + .into(), + ); + + items.push(ksni::MenuItem::Separator); + + // Launch / Kill + if self.running { + items.push( + StandardItem { + label: "Kill Battle.net".into(), + icon_name: "process-stop".into(), + activate: Box::new(|_this: &mut Self| { + let _ = launcher::kill(); + }), + ..Default::default() + } + .into(), + ); + } else { + items.push( + StandardItem { + label: "Launch Battle.net".into(), + icon_name: "media-playback-start".into(), + activate: Box::new(|this: &mut Self| { + if let Err(e) = launcher::launch(&this.config) { + eprintln!("battlenet-manager: launch failed: {e}"); + } + }), + ..Default::default() + } + .into(), + ); + } + + items.push(ksni::MenuItem::Separator); + + // Update Proton (latest, background) + items.push( + StandardItem { + label: "Update GE-Proton (latest)".into(), + icon_name: "system-software-update".into(), + activate: Box::new(|this: &mut Self| { + let config = this.config.clone(); + thread::spawn(move || { + if let Err(e) = crate::proton::install_latest(&config) { + eprintln!("battlenet-manager: proton update failed: {e}"); + } + }); + }), + ..Default::default() + } + .into(), + ); + + items.push(ksni::MenuItem::Separator); + + // Quit + items.push( + StandardItem { + label: "Quit".into(), + icon_name: "application-exit".into(), + activate: Box::new(|_this: &mut Self| { + std::process::exit(0); + }), + ..Default::default() + } + .into(), + ); + + items + } +} + +/// Start the system tray daemon. Blocks until the process is killed. +pub fn run(config: &Config) -> Result<()> { + let tray = BattlenetTray { + config: config.clone(), + running: launcher::is_running(), + }; + + let service = ksni::TrayService::new(tray); + let 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| { + tray.running = running; + }); + thread::sleep(Duration::from_secs(2)); + }); + + // Keep the main thread alive. + loop { + thread::sleep(Duration::from_secs(60)); + } +}