Initial commit: battlenet-manager tray daemon and CLI

This commit is contained in:
funman300
2026-04-16 13:28:17 -07:00
commit 1559ee5f2b
7 changed files with 791 additions and 0 deletions
+72
View File
@@ -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<PathBuf> {
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<Self> {
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")
}
}
+227
View File
@@ -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<String>) -> Self {
Self { label, pass: true, detail: detail.into() }
}
fn fail(label: &'static str, detail: impl Into<String>) -> Self {
Self { label, pass: false, detail: detail.into() }
}
}
pub fn run(config: &Config) {
let mut checks: Vec<Check> = 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<String> {
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
}
}
+59
View File
@@ -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)
}
+66
View File
@@ -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<Commands>,
}
#[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<String>,
/// 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(())
}
+183
View File
@@ -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<Asset>,
}
#[derive(Deserialize)]
struct Asset {
name: String,
browser_download_url: String,
}
fn http_client() -> Result<reqwest::blocking::Client> {
reqwest::blocking::Client::builder()
.user_agent("battlenet-manager/0.1")
.build()
.context("Failed to build HTTP client")
}
fn fetch_releases(count: usize) -> Result<Vec<Release>> {
let url = format!("{GITHUB_API}?per_page={count}");
let releases: Vec<Release> = 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<Release> {
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<String>, 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<String> {
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::<usize>() {
if n >= 1 && n <= releases.len() {
return Ok(releases[n - 1].tag_name.clone());
}
}
// Allow typing a version tag directly
Ok(input.to_string())
}
+152
View File
@@ -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<ksni::MenuItem<Self>> {
use ksni::menu::*;
let mut items: Vec<ksni::MenuItem<Self>> = 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));
}
}