Initial commit: battlenet-manager tray daemon and CLI
This commit is contained in:
+32
@@ -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"] }
|
||||||
@@ -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