Initial commit: battlenet-manager tray daemon and CLI
This commit is contained in:
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user