Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e5ed3d447 | |||
| 336c5d908e | |||
| 6fe558ad00 | |||
| f7738d215b | |||
| 8908c15974 | |||
| 7de6f6d938 |
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
Generated
+2135
File diff suppressed because it is too large
Load Diff
+8
-3
@@ -1,11 +1,16 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "battlenet-manager"
|
name = "umutray"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Battle.net launcher manager for Linux via umu/Proton-GE"
|
description = "Tray-based Wine launcher manager for Linux via umu/Proton-GE"
|
||||||
|
license = "MIT"
|
||||||
|
readme = "README.md"
|
||||||
|
repository = "https://git.aleshym.co/funman300/umutray"
|
||||||
|
keywords = ["wine", "proton", "umu", "tray", "launcher"]
|
||||||
|
categories = ["command-line-utilities", "games"]
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "battlenet-manager"
|
name = "umutray"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 umutray contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# umutray
|
||||||
|
|
||||||
|
A small system-tray daemon and CLI for running Windows game launchers on
|
||||||
|
Linux via [umu-launcher](https://github.com/Open-Wine-Components/umu-launcher)
|
||||||
|
and [GE-Proton](https://github.com/GloriousEggroll/proton-ge-custom).
|
||||||
|
|
||||||
|
Ships with presets for six launchers out of the box:
|
||||||
|
|
||||||
|
- Battle.net
|
||||||
|
- EA App
|
||||||
|
- Epic Games
|
||||||
|
- Ubisoft Connect
|
||||||
|
- GOG Galaxy
|
||||||
|
- Rockstar Games
|
||||||
|
|
||||||
|
Each lives in its own Wine prefix and shows up in the tray with per-launcher
|
||||||
|
Launch / Kill entries. Users can add or remove launchers in `config edit`.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Tray icon on any SNI-capable desktop (KDE, GNOME+AppIndicator, Xfce …).
|
||||||
|
- Per-launcher running state reflected in the tray via a 2 s poller.
|
||||||
|
- `update-proton` — streams GE-Proton releases straight to disk from GitHub
|
||||||
|
(no ~600 MB in-memory buffering), with a progress indicator.
|
||||||
|
- `diagnose` — sanity-checks umu-run, Vulkan, display server, per-launcher
|
||||||
|
prefix / exe / ownership / running state.
|
||||||
|
- `service` — installs a `systemd --user` unit so the tray autostarts with
|
||||||
|
the graphical session.
|
||||||
|
- `setup` — prints the manual setup steps for a launcher (a graphical
|
||||||
|
wizard via iced is planned).
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo build --release
|
||||||
|
install -Dm755 target/release/umutray ~/.local/bin/umutray
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires `umu-launcher`, `tar`, and `vulkan-tools` on PATH. On Arch:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo pacman -S umu-launcher vulkan-tools
|
||||||
|
```
|
||||||
|
|
||||||
|
Then enable autostart:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
umutray service install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
| -------------------------------- | ------------------------------------------------------- |
|
||||||
|
| `umutray` | Start the tray daemon (default) |
|
||||||
|
| `umutray launchers` | List configured launchers and their state |
|
||||||
|
| `umutray launch <name>` | Launch a specific launcher (e.g. `umutray launch epic`) |
|
||||||
|
| `umutray kill [<name>]` | Kill one launcher, or all if no name is given |
|
||||||
|
| `umutray diagnose [<name>]` | Health checks (one launcher or all) |
|
||||||
|
| `umutray setup <name>` | Print setup steps for a launcher |
|
||||||
|
| `umutray update-proton --latest` | Install newest GE-Proton release |
|
||||||
|
| `umutray update-proton --list` | Show recent releases without installing |
|
||||||
|
| `umutray update-proton` | Interactive version picker |
|
||||||
|
| `umutray config show` / `path` | Print current config or its file path |
|
||||||
|
| `umutray config edit` | Open config in `$EDITOR` |
|
||||||
|
| `umutray config set …` | Update globals (`--proton-version`, `--compat-dir`) |
|
||||||
|
| `umutray service install` | Write + enable a `systemd --user` unit |
|
||||||
|
| `umutray service uninstall` | Stop, disable, and remove the unit |
|
||||||
|
| `umutray service status` | `systemctl --user status umutray.service` |
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
Lives at `~/.config/umutray/config.toml`. A full config looks like:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
proton_compat_dir = "/home/you/.local/share/Steam/compatibilitytools.d"
|
||||||
|
proton_version = "GE-Proton"
|
||||||
|
|
||||||
|
[[launchers]]
|
||||||
|
name = "battlenet"
|
||||||
|
display = "Battle.net"
|
||||||
|
prefix_dir = "/home/you/Games/battlenet"
|
||||||
|
exe_path = "Program Files (x86)/Battle.net/Battle.net Launcher.exe"
|
||||||
|
gameid = "umu-battlenet"
|
||||||
|
process_pattern = "Battle\\.net"
|
||||||
|
|
||||||
|
# …one [[launchers]] block per launcher
|
||||||
|
```
|
||||||
|
|
||||||
|
`proton_version = "GE-Proton"` tells umu-launcher to auto-fetch the latest.
|
||||||
|
Setting it to a pinned tag (done automatically by `update-proton`) uses
|
||||||
|
that specific version. Each launcher may override the global `proton_version`
|
||||||
|
with its own.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT. See [LICENSE](LICENSE).
|
||||||
|
|||||||
+213
-28
@@ -4,56 +4,195 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Config {
|
pub struct Launcher {
|
||||||
/// Wine prefix directory
|
/// Short CLI name (e.g. "battlenet").
|
||||||
|
pub name: String,
|
||||||
|
/// Display name for tray / menus.
|
||||||
|
pub display: String,
|
||||||
|
/// Wine prefix directory for this launcher.
|
||||||
pub prefix_dir: PathBuf,
|
pub prefix_dir: PathBuf,
|
||||||
/// GE-Proton version string passed to PROTONPATH ("GE-Proton" tracks latest)
|
/// Path to the launcher exe, relative to the prefix's drive_c/.
|
||||||
pub proton_version: String,
|
pub exe_path: PathBuf,
|
||||||
/// umu GAMEID used to look up protonfixes
|
/// umu GAMEID (used to look up protonfixes).
|
||||||
pub gameid: String,
|
pub gameid: String,
|
||||||
/// Directory where GE-Proton versions are installed
|
/// pgrep/pkill -f regex matching this launcher's running processes.
|
||||||
pub proton_compat_dir: PathBuf,
|
pub process_pattern: String,
|
||||||
|
/// Optional URL of the Windows installer (consumed by `setup`).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub installer_url: Option<String>,
|
||||||
|
/// Optional per-launcher Proton version override (falls back to
|
||||||
|
/// Config::proton_version).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub proton_version: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Config {
|
impl Launcher {
|
||||||
fn default() -> Self {
|
/// Absolute path to the launcher exe inside the prefix.
|
||||||
let home = home_dir();
|
pub fn full_exe_path(&self) -> PathBuf {
|
||||||
Self {
|
self.prefix_dir.join("drive_c").join(&self.exe_path)
|
||||||
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"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
/// Directory where GE-Proton versions are installed.
|
||||||
|
#[serde(default = "default_compat_dir")]
|
||||||
|
pub proton_compat_dir: PathBuf,
|
||||||
|
/// Default Proton version passed to PROTONPATH.
|
||||||
|
/// The literal "GE-Proton" makes umu-run fetch/use the latest.
|
||||||
|
#[serde(default = "default_proton_version")]
|
||||||
|
pub proton_version: String,
|
||||||
|
/// Configured launchers.
|
||||||
|
#[serde(default)]
|
||||||
|
pub launchers: Vec<Launcher>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn home_dir() -> PathBuf {
|
fn home_dir() -> PathBuf {
|
||||||
std::env::var("HOME")
|
std::env::var("HOME")
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
.unwrap_or_else(|_| PathBuf::from("/tmp"))
|
.expect("$HOME is not set; cannot determine default paths")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_compat_dir() -> PathBuf {
|
||||||
|
home_dir().join(".local/share/Steam/compatibilitytools.d")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_proton_version() -> String {
|
||||||
|
"GE-Proton".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The six launchers umutray ships out of the box. `exe_path`, `gameid`,
|
||||||
|
/// and `process_pattern` are best-effort defaults for typical installs —
|
||||||
|
/// users can adjust per-launcher via `umutray config edit`.
|
||||||
|
pub fn presets() -> Vec<Launcher> {
|
||||||
|
let games = home_dir().join("Games");
|
||||||
|
vec![
|
||||||
|
Launcher {
|
||||||
|
name: "battlenet".into(),
|
||||||
|
display: "Battle.net".into(),
|
||||||
|
prefix_dir: games.join("battlenet"),
|
||||||
|
exe_path: PathBuf::from(
|
||||||
|
"Program Files (x86)/Battle.net/Battle.net Launcher.exe",
|
||||||
|
),
|
||||||
|
gameid: "umu-battlenet".into(),
|
||||||
|
process_pattern: r"Battle\.net".into(),
|
||||||
|
installer_url: None,
|
||||||
|
proton_version: None,
|
||||||
|
},
|
||||||
|
Launcher {
|
||||||
|
name: "eaapp".into(),
|
||||||
|
display: "EA App".into(),
|
||||||
|
prefix_dir: games.join("eaapp"),
|
||||||
|
exe_path: PathBuf::from(
|
||||||
|
"Program Files/Electronic Arts/EA Desktop/EA Desktop/EADesktop.exe",
|
||||||
|
),
|
||||||
|
gameid: "umu-eaapp".into(),
|
||||||
|
process_pattern: r"EADesktop\.exe".into(),
|
||||||
|
installer_url: None,
|
||||||
|
proton_version: None,
|
||||||
|
},
|
||||||
|
Launcher {
|
||||||
|
name: "epic".into(),
|
||||||
|
display: "Epic Games".into(),
|
||||||
|
prefix_dir: games.join("epic"),
|
||||||
|
exe_path: PathBuf::from(
|
||||||
|
"Program Files (x86)/Epic Games/Launcher/Portal/Binaries/Win32/EpicGamesLauncher.exe",
|
||||||
|
),
|
||||||
|
gameid: "umu-epicgameslauncher".into(),
|
||||||
|
process_pattern: r"EpicGamesLauncher\.exe".into(),
|
||||||
|
installer_url: None,
|
||||||
|
proton_version: None,
|
||||||
|
},
|
||||||
|
Launcher {
|
||||||
|
name: "ubisoft".into(),
|
||||||
|
display: "Ubisoft Connect".into(),
|
||||||
|
prefix_dir: games.join("ubisoft"),
|
||||||
|
exe_path: PathBuf::from(
|
||||||
|
"Program Files (x86)/Ubisoft/Ubisoft Game Launcher/UbisoftConnect.exe",
|
||||||
|
),
|
||||||
|
gameid: "umu-uplay".into(),
|
||||||
|
process_pattern: r"UbisoftConnect\.exe|upc\.exe".into(),
|
||||||
|
installer_url: None,
|
||||||
|
proton_version: None,
|
||||||
|
},
|
||||||
|
Launcher {
|
||||||
|
name: "gog".into(),
|
||||||
|
display: "GOG Galaxy".into(),
|
||||||
|
prefix_dir: games.join("gog"),
|
||||||
|
exe_path: PathBuf::from(
|
||||||
|
"Program Files (x86)/GOG Galaxy/GalaxyClient.exe",
|
||||||
|
),
|
||||||
|
gameid: "umu-gog".into(),
|
||||||
|
process_pattern: r"GalaxyClient\.exe".into(),
|
||||||
|
installer_url: None,
|
||||||
|
proton_version: None,
|
||||||
|
},
|
||||||
|
Launcher {
|
||||||
|
name: "rockstar".into(),
|
||||||
|
display: "Rockstar Games".into(),
|
||||||
|
prefix_dir: games.join("rockstar"),
|
||||||
|
exe_path: PathBuf::from(
|
||||||
|
"Program Files/Rockstar Games/Launcher/Launcher.exe",
|
||||||
|
),
|
||||||
|
gameid: "umu-rockstar".into(),
|
||||||
|
process_pattern: r"Rockstar Games.*Launcher\.exe".into(),
|
||||||
|
installer_url: None,
|
||||||
|
proton_version: None,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
proton_compat_dir: default_compat_dir(),
|
||||||
|
proton_version: default_proton_version(),
|
||||||
|
launchers: presets(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
fn config_path() -> Result<PathBuf> {
|
pub fn config_path() -> Result<PathBuf> {
|
||||||
let dirs = ProjectDirs::from("co.aleshym", "", "battlenet-manager")
|
let dirs = ProjectDirs::from("co.aleshym", "", "umutray")
|
||||||
.context("Could not determine config directory")?;
|
.context("Could not determine config directory")?;
|
||||||
Ok(dirs.config_dir().join("config.toml"))
|
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> {
|
pub fn load() -> Result<Self> {
|
||||||
let path = Self::config_path()?;
|
let path = Self::config_path()?;
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
let config = Self::default();
|
let c = Self::default();
|
||||||
config.save().context("Failed to write default config")?;
|
c.save().context("Failed to write default config")?;
|
||||||
return Ok(config);
|
return Ok(c);
|
||||||
}
|
}
|
||||||
let content = std::fs::read_to_string(&path)
|
let content = std::fs::read_to_string(&path)
|
||||||
.with_context(|| format!("Failed to read config from {path:?}"))?;
|
.with_context(|| format!("Failed to read config from {path:?}"))?;
|
||||||
toml::from_str(&content).context("Failed to parse config.toml")
|
match toml::from_str::<Self>(&content) {
|
||||||
|
Ok(mut c) => {
|
||||||
|
if c.launchers.is_empty() {
|
||||||
|
c.launchers = presets();
|
||||||
|
c.save().context("Failed to write presets")?;
|
||||||
|
}
|
||||||
|
Ok(c)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let bak = path.with_extension("toml.bak");
|
||||||
|
std::fs::rename(&path, &bak).with_context(|| {
|
||||||
|
format!("Failed to back up stale config to {bak:?}")
|
||||||
|
})?;
|
||||||
|
eprintln!("warning: couldn't parse {}: {e}", path.display());
|
||||||
|
eprintln!(
|
||||||
|
" backed up to {} — writing fresh config with presets",
|
||||||
|
bak.display()
|
||||||
|
);
|
||||||
|
let c = Self::default();
|
||||||
|
c.save()?;
|
||||||
|
Ok(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write config to disk.
|
|
||||||
pub fn save(&self) -> Result<()> {
|
pub fn save(&self) -> Result<()> {
|
||||||
let path = Self::config_path()?;
|
let path = Self::config_path()?;
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
@@ -64,9 +203,55 @@ impl Config {
|
|||||||
.with_context(|| format!("Failed to write config to {path:?}"))
|
.with_context(|| format!("Failed to write config to {path:?}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Absolute path to the Battle.net Launcher.exe inside the prefix.
|
pub fn find(&self, name: &str) -> Option<&Launcher> {
|
||||||
pub fn launcher_exe(&self) -> PathBuf {
|
self.launchers.iter().find(|l| l.name == name)
|
||||||
self.prefix_dir
|
}
|
||||||
.join("drive_c/Program Files (x86)/Battle.net/Battle.net Launcher.exe")
|
|
||||||
|
pub fn show(&self) -> Result<()> {
|
||||||
|
let path = Self::config_path()?;
|
||||||
|
println!("# {}", path.display());
|
||||||
|
let s = toml::to_string_pretty(self)?;
|
||||||
|
print!("{s}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn edit() -> Result<()> {
|
||||||
|
let _ = Self::load()?;
|
||||||
|
let path = Self::config_path()?;
|
||||||
|
let editor = std::env::var("EDITOR")
|
||||||
|
.or_else(|_| std::env::var("VISUAL"))
|
||||||
|
.unwrap_or_else(|_| "nano".into());
|
||||||
|
let status = std::process::Command::new(&editor)
|
||||||
|
.arg(&path)
|
||||||
|
.status()
|
||||||
|
.with_context(|| format!("Failed to spawn editor '{editor}'"))?;
|
||||||
|
if !status.success() {
|
||||||
|
anyhow::bail!("Editor '{editor}' exited non-zero");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update global fields non-interactively, then save.
|
||||||
|
/// Use `config edit` for per-launcher changes.
|
||||||
|
pub fn set_globals(
|
||||||
|
&mut self,
|
||||||
|
proton_version: Option<String>,
|
||||||
|
compat_dir: Option<PathBuf>,
|
||||||
|
) -> Result<()> {
|
||||||
|
if proton_version.is_none() && compat_dir.is_none() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"nothing to set — pass --proton-version or --compat-dir"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(v) = proton_version {
|
||||||
|
self.proton_version = v;
|
||||||
|
}
|
||||||
|
if let Some(d) = compat_dir {
|
||||||
|
self.proton_compat_dir = d;
|
||||||
|
}
|
||||||
|
self.save()?;
|
||||||
|
println!("\x1b[1;32m✓\x1b[0m Config saved.");
|
||||||
|
println!();
|
||||||
|
self.show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+167
-174
@@ -1,174 +1,56 @@
|
|||||||
use crate::config::Config;
|
use crate::config::{Config, Launcher};
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::process::Command;
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
struct Check {
|
struct Check {
|
||||||
label: &'static str,
|
label: String,
|
||||||
pass: bool,
|
pass: bool,
|
||||||
detail: String,
|
detail: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Check {
|
impl Check {
|
||||||
fn pass(label: &'static str, detail: impl Into<String>) -> Self {
|
fn pass(label: impl Into<String>, detail: impl Into<String>) -> Self {
|
||||||
Self { label, pass: true, detail: detail.into() }
|
Self { label: label.into(), pass: true, detail: detail.into() }
|
||||||
}
|
}
|
||||||
fn fail(label: &'static str, detail: impl Into<String>) -> Self {
|
fn fail(label: impl Into<String>, detail: impl Into<String>) -> Self {
|
||||||
Self { label, pass: false, detail: detail.into() }
|
Self { label: label.into(), pass: false, detail: detail.into() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(config: &Config) {
|
pub fn run(config: &Config, name: Option<&str>) -> Result<()> {
|
||||||
let mut checks: Vec<Check> = Vec::new();
|
let mut checks: Vec<Check> = vec![
|
||||||
|
global_umu_check(),
|
||||||
|
global_vulkan_check(),
|
||||||
|
global_display_check(),
|
||||||
|
compat_dir_check(config),
|
||||||
|
];
|
||||||
|
|
||||||
|
let launchers: Vec<&Launcher> = if let Some(n) = name {
|
||||||
|
let l = config
|
||||||
|
.find(n)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("unknown launcher '{n}'"))?;
|
||||||
|
vec![l]
|
||||||
|
} else {
|
||||||
|
config.launchers.iter().collect()
|
||||||
|
};
|
||||||
|
for l in launchers {
|
||||||
|
checks.extend(launcher_checks(l));
|
||||||
|
}
|
||||||
|
|
||||||
let mut issues = 0u32;
|
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!();
|
println!();
|
||||||
for c in &checks {
|
for c in &checks {
|
||||||
let (symbol, colour, reset) = if c.pass {
|
if !c.pass {
|
||||||
|
issues += 1;
|
||||||
|
}
|
||||||
|
let (sym, col, rst) = if c.pass {
|
||||||
("✓", "\x1b[1;32m", "\x1b[0m")
|
("✓", "\x1b[1;32m", "\x1b[0m")
|
||||||
} else {
|
} else {
|
||||||
("✗", "\x1b[1;31m", "\x1b[0m")
|
("✗", "\x1b[1;31m", "\x1b[0m")
|
||||||
};
|
};
|
||||||
println!(" {colour}{symbol}{reset} {:16} {}", c.label, c.detail);
|
println!(" {col}{sym}{rst} {:24} {}", c.label, c.detail);
|
||||||
}
|
}
|
||||||
println!();
|
println!();
|
||||||
if issues == 0 {
|
if issues == 0 {
|
||||||
@@ -177,18 +59,120 @@ pub fn run(config: &Config) {
|
|||||||
println!(" \x1b[1;31m{issues} issue(s) found — see ✗ items above.\x1b[0m");
|
println!(" \x1b[1;31m{issues} issue(s) found — see ✗ items above.\x1b[0m");
|
||||||
}
|
}
|
||||||
println!();
|
println!();
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
fn global_umu_check() -> Check {
|
||||||
|
match which("umu-run") {
|
||||||
|
Some(p) => Check::pass("umu-run", format!("found at {p}")),
|
||||||
|
None => Check::fail("umu-run", "not found — install umu-launcher"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn which(cmd: &str) -> Option<String> {
|
fn global_vulkan_check() -> Check {
|
||||||
Command::new("which")
|
let ok = Command::new("vulkaninfo")
|
||||||
.arg(cmd)
|
.arg("--summary")
|
||||||
.output()
|
.stdout(Stdio::null())
|
||||||
.ok()
|
.stderr(Stdio::null())
|
||||||
.filter(|o| o.status.success())
|
.status()
|
||||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
.map(|s| s.success())
|
||||||
.map(|s| s.trim().to_string())
|
.unwrap_or(false);
|
||||||
|
if ok {
|
||||||
|
Check::pass("vulkan", "vulkaninfo OK")
|
||||||
|
} else {
|
||||||
|
Check::fail("vulkan", "vulkaninfo failed — check GPU drivers / vulkan-tools")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn global_display_check() -> Check {
|
||||||
|
let display = std::env::var("DISPLAY").ok();
|
||||||
|
let wayland = std::env::var("WAYLAND_DISPLAY").ok();
|
||||||
|
match (display, wayland) {
|
||||||
|
(Some(d), Some(_)) => Check::pass("display", format!("XWayland (DISPLAY={d})")),
|
||||||
|
(Some(d), None) => Check::pass("display", format!("X11 (DISPLAY={d})")),
|
||||||
|
(None, Some(_)) => Check::fail(
|
||||||
|
"display",
|
||||||
|
"Wayland session but DISPLAY unset; XWayland needed",
|
||||||
|
),
|
||||||
|
(None, None) => Check::fail("display", "neither DISPLAY nor WAYLAND_DISPLAY set"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compat_dir_check(config: &Config) -> Check {
|
||||||
|
let n = count_ge_proton(&config.proton_compat_dir);
|
||||||
|
if config.proton_version == "GE-Proton" {
|
||||||
|
Check::pass(
|
||||||
|
"proton",
|
||||||
|
format!(
|
||||||
|
"tracking latest; {n} version(s) in {}",
|
||||||
|
config.proton_compat_dir.display()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let path = config.proton_compat_dir.join(&config.proton_version);
|
||||||
|
if path.exists() {
|
||||||
|
Check::pass("proton", format!("{} installed", config.proton_version))
|
||||||
|
} else {
|
||||||
|
Check::fail(
|
||||||
|
"proton",
|
||||||
|
format!(
|
||||||
|
"{} missing — run: umutray update-proton --version={}",
|
||||||
|
config.proton_version, config.proton_version
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn launcher_checks(l: &Launcher) -> Vec<Check> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let tag = format!("[{}]", l.name);
|
||||||
|
|
||||||
|
if !l.prefix_dir.exists() {
|
||||||
|
out.push(Check::fail(
|
||||||
|
format!("{tag} prefix"),
|
||||||
|
format!(
|
||||||
|
"{} missing — run: umutray setup {}",
|
||||||
|
l.prefix_dir.display(),
|
||||||
|
l.name
|
||||||
|
),
|
||||||
|
));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
out.push(Check::pass(
|
||||||
|
format!("{tag} prefix"),
|
||||||
|
l.prefix_dir.display().to_string(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let exe = l.full_exe_path();
|
||||||
|
if exe.exists() {
|
||||||
|
out.push(Check::pass(format!("{tag} exe"), "installed"));
|
||||||
|
} else {
|
||||||
|
out.push(Check::fail(
|
||||||
|
format!("{tag} exe"),
|
||||||
|
format!("missing — run: umutray setup {}", l.name),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_owned_by_current_user(&l.prefix_dir) {
|
||||||
|
out.push(Check::pass(
|
||||||
|
format!("{tag} owner"),
|
||||||
|
"owned by current user",
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
out.push(Check::fail(
|
||||||
|
format!("{tag} owner"),
|
||||||
|
"not owned by current user",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if crate::launcher::is_running(l) {
|
||||||
|
out.push(Check::pass(format!("{tag} process"), "currently running"));
|
||||||
|
} else {
|
||||||
|
out.push(Check::pass(format!("{tag} process"), "not running"));
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
fn count_ge_proton(dir: &Path) -> usize {
|
fn count_ge_proton(dir: &Path) -> usize {
|
||||||
@@ -208,20 +192,29 @@ fn count_ge_proton(dir: &Path) -> usize {
|
|||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_owned_by_current_user(path: &Path) -> bool {
|
fn which(cmd: &str) -> Option<String> {
|
||||||
// Compare stat uid with current euid via id command (avoids libc dependency)
|
Command::new("which")
|
||||||
let uid_output = Command::new("id").arg("-u").output().ok();
|
.arg(cmd)
|
||||||
let stat_output = Command::new("stat")
|
|
||||||
.args(["-c", "%u", path.to_str().unwrap_or("")])
|
|
||||||
.output()
|
.output()
|
||||||
.ok();
|
.ok()
|
||||||
|
.filter(|o| o.status.success())
|
||||||
|
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
match (uid_output, stat_output) {
|
fn is_owned_by_current_user(path: &Path) -> bool {
|
||||||
(Some(u), Some(s)) => {
|
let file_uid = match std::fs::metadata(path) {
|
||||||
let uid = String::from_utf8_lossy(&u.stdout).trim().to_string();
|
Ok(m) => m.uid(),
|
||||||
let owner = String::from_utf8_lossy(&s.stdout).trim().to_string();
|
Err(_) => return true,
|
||||||
uid == owner
|
};
|
||||||
}
|
let current_uid: Option<u32> = Command::new("id")
|
||||||
_ => true, // assume OK if we can't check
|
.arg("-u")
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||||
|
.and_then(|s| s.trim().parse().ok());
|
||||||
|
match current_uid {
|
||||||
|
Some(uid) => uid == file_uid,
|
||||||
|
None => true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+63
-33
@@ -1,23 +1,37 @@
|
|||||||
use crate::config::Config;
|
use crate::config::{Config, Launcher};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
|
use std::process::Stdio;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
/// Spawn Battle.net via umu-run and return immediately.
|
/// Spawn the launcher via umu-run and return immediately.
|
||||||
pub fn launch(config: &Config) -> Result<()> {
|
pub fn launch(config: &Config, launcher: &Launcher) -> Result<()> {
|
||||||
let exe = config.launcher_exe();
|
let exe = launcher.full_exe_path();
|
||||||
|
|
||||||
if !exe.exists() {
|
if !exe.exists() {
|
||||||
anyhow::bail!(
|
bail!(
|
||||||
"Battle.net Launcher.exe not found at {exe:?}\n\
|
"launcher exe not found at {:?}\n\
|
||||||
Run 'battlenet-umu-setup.sh' to install it first."
|
Run `umutray setup {}` for setup instructions.",
|
||||||
|
exe,
|
||||||
|
launcher.name,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PROTONPATH: umu-run accepts the literal "GE-Proton" to auto-fetch the
|
||||||
|
// latest; for any pinned version it expects a full path to the install dir.
|
||||||
|
let version = launcher
|
||||||
|
.proton_version
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or(&config.proton_version);
|
||||||
|
let proton_path: std::ffi::OsString = if version == "GE-Proton" {
|
||||||
|
version.to_string().into()
|
||||||
|
} else {
|
||||||
|
config.proton_compat_dir.join(version).into_os_string()
|
||||||
|
};
|
||||||
|
|
||||||
std::process::Command::new("umu-run")
|
std::process::Command::new("umu-run")
|
||||||
.env("WINEPREFIX", &config.prefix_dir)
|
.env("WINEPREFIX", &launcher.prefix_dir)
|
||||||
.env("GAMEID", &config.gameid)
|
.env("GAMEID", &launcher.gameid)
|
||||||
.env("PROTONPATH", &config.proton_version)
|
.env("PROTONPATH", &proton_path)
|
||||||
.arg(&exe)
|
.arg(&exe)
|
||||||
.spawn()
|
.spawn()
|
||||||
.context(
|
.context(
|
||||||
@@ -28,31 +42,47 @@ pub fn launch(config: &Config) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gracefully stop Battle.net: SIGTERM → wait 3 s → SIGKILL.
|
/// SIGTERM → wait 3 s → SIGKILL for a single launcher.
|
||||||
pub fn kill() -> Result<()> {
|
pub fn kill(launcher: &Launcher) -> Result<()> {
|
||||||
let patterns = ["Battle\\.net", "Agent\\.exe", "Blizzard"];
|
kill_pattern(&launcher.process_pattern);
|
||||||
|
|
||||||
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if any Battle.net process is currently running.
|
/// Kill every configured launcher's processes.
|
||||||
pub fn is_running() -> bool {
|
pub fn kill_all(config: &Config) -> Result<()> {
|
||||||
|
// Single SIGTERM pass across all launchers, then one sleep, then SIGKILL.
|
||||||
|
// This keeps the total wait at 3 s instead of 3 s × N.
|
||||||
|
for l in &config.launchers {
|
||||||
|
send_signal("-15", &l.process_pattern);
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_secs(3));
|
||||||
|
for l in &config.launchers {
|
||||||
|
send_signal("-9", &l.process_pattern);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kill_pattern(pattern: &str) {
|
||||||
|
send_signal("-15", pattern);
|
||||||
|
thread::sleep(Duration::from_secs(3));
|
||||||
|
send_signal("-9", pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_signal(sig: &str, pattern: &str) {
|
||||||
|
let _ = std::process::Command::new("pkill")
|
||||||
|
.args([sig, "-f", pattern])
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if at least one process matching the launcher's pattern
|
||||||
|
/// is currently alive.
|
||||||
|
pub fn is_running(launcher: &Launcher) -> bool {
|
||||||
std::process::Command::new("pgrep")
|
std::process::Command::new("pgrep")
|
||||||
.args(["-fi", "battle.net"])
|
.args(["-f", &launcher.process_pattern])
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
.status()
|
.status()
|
||||||
.map(|s| s.success())
|
.map(|s| s.success())
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
|
|||||||
+133
-13
@@ -2,17 +2,19 @@ mod config;
|
|||||||
mod diagnose;
|
mod diagnose;
|
||||||
mod launcher;
|
mod launcher;
|
||||||
mod proton;
|
mod proton;
|
||||||
|
mod service;
|
||||||
|
mod setup;
|
||||||
mod tray;
|
mod tray;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
/// Battle.net launcher manager for Linux via umu/Proton-GE.
|
/// Tray-based Wine launcher manager for Linux via umu/Proton-GE.
|
||||||
///
|
///
|
||||||
/// Running without a subcommand starts the system tray daemon.
|
/// Running without a subcommand starts the system tray daemon.
|
||||||
/// Use `launch` in your .desktop shortcut for a direct, no-UI launch.
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "battlenet-manager", version, about)]
|
#[command(name = "umutray", version, about)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Option<Commands>,
|
command: Option<Commands>,
|
||||||
@@ -23,14 +25,32 @@ enum Commands {
|
|||||||
/// Start the system tray daemon (default when no subcommand given)
|
/// Start the system tray daemon (default when no subcommand given)
|
||||||
Tray,
|
Tray,
|
||||||
|
|
||||||
/// Launch Battle.net immediately and return — use this in .desktop shortcuts
|
/// Launch a configured launcher
|
||||||
Launch,
|
Launch {
|
||||||
|
/// Launcher name (e.g. battlenet, eaapp, epic)
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
|
||||||
/// Gracefully kill all Battle.net / Wine processes
|
/// Kill a specific launcher, or every configured one if no name is given
|
||||||
Kill,
|
Kill {
|
||||||
|
/// Launcher name (omit to kill all)
|
||||||
|
name: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
/// Check setup health and report any problems
|
/// Run health checks on a specific launcher, or all of them
|
||||||
Diagnose,
|
Diagnose {
|
||||||
|
/// Launcher name (omit to check all)
|
||||||
|
name: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// List configured launchers and whether they're installed / running
|
||||||
|
Launchers,
|
||||||
|
|
||||||
|
/// Print setup instructions for a launcher (automated wizard coming soon)
|
||||||
|
Setup {
|
||||||
|
/// Launcher name
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
|
||||||
/// Download and switch GE-Proton versions
|
/// Download and switch GE-Proton versions
|
||||||
UpdateProton {
|
UpdateProton {
|
||||||
@@ -38,7 +58,7 @@ enum Commands {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
latest: bool,
|
latest: bool,
|
||||||
|
|
||||||
/// Install a specific version (e.g. GE-Proton9-20)
|
/// Install a specific version (e.g. GE-Proton10-34)
|
||||||
#[arg(long, value_name = "VERSION")]
|
#[arg(long, value_name = "VERSION")]
|
||||||
version: Option<String>,
|
version: Option<String>,
|
||||||
|
|
||||||
@@ -46,6 +66,48 @@ enum Commands {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
list: bool,
|
list: bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Show or modify configuration
|
||||||
|
Config {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: ConfigAction,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Manage the systemd --user service that autostarts the tray
|
||||||
|
Service {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: ServiceAction,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum ConfigAction {
|
||||||
|
/// Print the config file path and current values
|
||||||
|
Show,
|
||||||
|
/// Print just the config file path
|
||||||
|
Path,
|
||||||
|
/// Open the config file in $EDITOR
|
||||||
|
Edit,
|
||||||
|
/// Update global fields. Use `config edit` for per-launcher changes.
|
||||||
|
Set {
|
||||||
|
/// Default Proton version (e.g. GE-Proton, GE-Proton10-34)
|
||||||
|
#[arg(long, value_name = "VERSION")]
|
||||||
|
proton_version: Option<String>,
|
||||||
|
|
||||||
|
/// GE-Proton install directory
|
||||||
|
#[arg(long, value_name = "PATH")]
|
||||||
|
compat_dir: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum ServiceAction {
|
||||||
|
/// Write the unit, daemon-reload, and enable+start the service
|
||||||
|
Install,
|
||||||
|
/// Stop, disable, and remove the unit file
|
||||||
|
Uninstall,
|
||||||
|
/// Show `systemctl --user status` for the service
|
||||||
|
Status,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
@@ -54,12 +116,70 @@ fn main() -> Result<()> {
|
|||||||
|
|
||||||
match cli.command.unwrap_or(Commands::Tray) {
|
match cli.command.unwrap_or(Commands::Tray) {
|
||||||
Commands::Tray => tray::run(&config)?,
|
Commands::Tray => tray::run(&config)?,
|
||||||
Commands::Launch => launcher::launch(&config)?,
|
|
||||||
Commands::Kill => launcher::kill()?,
|
Commands::Launch { name } => {
|
||||||
Commands::Diagnose => diagnose::run(&config),
|
let l = config.find(&name).ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("unknown launcher '{name}' — try `umutray launchers`")
|
||||||
|
})?;
|
||||||
|
launcher::launch(&config, l)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Commands::Kill { name } => match name {
|
||||||
|
Some(n) => {
|
||||||
|
let l = config.find(&n).ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("unknown launcher '{n}' — try `umutray launchers`")
|
||||||
|
})?;
|
||||||
|
launcher::kill(l)?;
|
||||||
|
}
|
||||||
|
None => launcher::kill_all(&config)?,
|
||||||
|
},
|
||||||
|
|
||||||
|
Commands::Diagnose { name } => {
|
||||||
|
diagnose::run(&config, name.as_deref())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Commands::Launchers => {
|
||||||
|
for l in &config.launchers {
|
||||||
|
let installed = l.full_exe_path().exists();
|
||||||
|
let running = launcher::is_running(l);
|
||||||
|
let marker = if installed {
|
||||||
|
"\x1b[1;32m✓\x1b[0m"
|
||||||
|
} else {
|
||||||
|
"·"
|
||||||
|
};
|
||||||
|
let state = if running { " (running)" } else { "" };
|
||||||
|
println!(" {marker} {:12} {}{}", l.name, l.display, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Commands::Setup { name } => {
|
||||||
|
let l = config.find(&name).ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("unknown launcher '{name}' — try `umutray launchers`")
|
||||||
|
})?;
|
||||||
|
setup::run(&config, l)?;
|
||||||
|
}
|
||||||
|
|
||||||
Commands::UpdateProton { latest, version, list } => {
|
Commands::UpdateProton { latest, version, list } => {
|
||||||
proton::run(&config, latest, version, list)?;
|
proton::run(&config, latest, version, list)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Commands::Config { action } => match action {
|
||||||
|
ConfigAction::Show => config.show()?,
|
||||||
|
ConfigAction::Path => {
|
||||||
|
println!("{}", config::Config::config_path()?.display());
|
||||||
|
}
|
||||||
|
ConfigAction::Edit => config::Config::edit()?,
|
||||||
|
ConfigAction::Set { proton_version, compat_dir } => {
|
||||||
|
let mut c = config;
|
||||||
|
c.set_globals(proton_version, compat_dir)?;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
Commands::Service { action } => match action {
|
||||||
|
ServiceAction::Install => service::install()?,
|
||||||
|
ServiceAction::Uninstall => service::uninstall()?,
|
||||||
|
ServiceAction::Status => service::status()?,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
+71
-11
@@ -20,7 +20,7 @@ struct Asset {
|
|||||||
|
|
||||||
fn http_client() -> Result<reqwest::blocking::Client> {
|
fn http_client() -> Result<reqwest::blocking::Client> {
|
||||||
reqwest::blocking::Client::builder()
|
reqwest::blocking::Client::builder()
|
||||||
.user_agent("battlenet-manager/0.1")
|
.user_agent("umutray/0.1")
|
||||||
.build()
|
.build()
|
||||||
.context("Failed to build HTTP client")
|
.context("Failed to build HTTP client")
|
||||||
}
|
}
|
||||||
@@ -31,6 +31,8 @@ fn fetch_releases(count: usize) -> Result<Vec<Release>> {
|
|||||||
.get(&url)
|
.get(&url)
|
||||||
.send()
|
.send()
|
||||||
.context("GitHub API request failed")?
|
.context("GitHub API request failed")?
|
||||||
|
.error_for_status()
|
||||||
|
.context("GitHub API returned an error (rate limited?)")?
|
||||||
.json()
|
.json()
|
||||||
.context("Failed to parse GitHub releases JSON")?;
|
.context("Failed to parse GitHub releases JSON")?;
|
||||||
Ok(releases)
|
Ok(releases)
|
||||||
@@ -42,6 +44,8 @@ fn fetch_release(tag: &str) -> Result<Release> {
|
|||||||
.get(&url)
|
.get(&url)
|
||||||
.send()
|
.send()
|
||||||
.with_context(|| format!("GitHub API request failed for tag {tag}"))?
|
.with_context(|| format!("GitHub API request failed for tag {tag}"))?
|
||||||
|
.error_for_status()
|
||||||
|
.with_context(|| format!("GitHub API returned an error for tag {tag}"))?
|
||||||
.json()
|
.json()
|
||||||
.context("Failed to parse release JSON")?;
|
.context("Failed to parse release JSON")?;
|
||||||
Ok(release)
|
Ok(release)
|
||||||
@@ -65,26 +69,30 @@ fn install_version(config: &Config, tag: &str) -> Result<()> {
|
|||||||
.with_context(|| format!("No .tar.gz asset found for {tag}"))?;
|
.with_context(|| format!("No .tar.gz asset found for {tag}"))?;
|
||||||
|
|
||||||
println!("Downloading {}...", asset.name);
|
println!("Downloading {}...", asset.name);
|
||||||
let bytes = http_client()?
|
// Stream straight to disk — the tarballs are ~600 MB and would otherwise
|
||||||
|
// balloon resident memory before extraction even starts.
|
||||||
|
let tmp_path = std::env::temp_dir().join(format!("{tag}.tar.gz"));
|
||||||
|
{
|
||||||
|
let mut resp = http_client()?
|
||||||
.get(&asset.browser_download_url)
|
.get(&asset.browser_download_url)
|
||||||
.send()
|
.send()
|
||||||
.context("Download failed")?
|
.context("Download failed")?
|
||||||
.bytes()
|
.error_for_status()
|
||||||
.context("Failed to read response bytes")?;
|
.context("Download returned an error status")?;
|
||||||
|
let total = resp.content_length();
|
||||||
// Write to a temp file then extract with system tar (avoids flate2/tar deps)
|
let f = std::fs::File::create(&tmp_path)
|
||||||
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:?}"))?;
|
.with_context(|| format!("Failed to create temp file {tmp_path:?}"))?;
|
||||||
f.write_all(&bytes).context("Failed to write archive")?;
|
let mut progress = ProgressWriter::new(f, total);
|
||||||
|
std::io::copy(&mut resp, &mut progress).context("Failed to stream archive to disk")?;
|
||||||
|
progress.finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("Extracting to {:?}...", config.proton_compat_dir);
|
println!("Extracting to {:?}...", config.proton_compat_dir);
|
||||||
std::fs::create_dir_all(&config.proton_compat_dir)?;
|
std::fs::create_dir_all(&config.proton_compat_dir)?;
|
||||||
|
|
||||||
let status = std::process::Command::new("tar")
|
let status = std::process::Command::new("tar")
|
||||||
.args(["-xzf", tmp_path.to_str().unwrap_or("")])
|
.arg("-xzf")
|
||||||
|
.arg(&tmp_path)
|
||||||
.current_dir(&config.proton_compat_dir)
|
.current_dir(&config.proton_compat_dir)
|
||||||
.status()
|
.status()
|
||||||
.context("Failed to run tar")?;
|
.context("Failed to run tar")?;
|
||||||
@@ -151,8 +159,60 @@ fn print_list(config: &Config) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wraps a writer to print download progress to stderr, throttled to one
|
||||||
|
/// update per megabyte so we don't spam the terminal.
|
||||||
|
struct ProgressWriter<W: Write> {
|
||||||
|
inner: W,
|
||||||
|
total: Option<u64>,
|
||||||
|
written: u64,
|
||||||
|
last_print: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<W: Write> ProgressWriter<W> {
|
||||||
|
fn new(inner: W, total: Option<u64>) -> Self {
|
||||||
|
Self { inner, total, written: 0, last_print: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(&mut self) {
|
||||||
|
let _ = self.inner.flush();
|
||||||
|
// Clear the progress line — the caller's next println! starts fresh.
|
||||||
|
eprintln!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<W: Write> Write for ProgressWriter<W> {
|
||||||
|
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||||
|
let n = self.inner.write(buf)?;
|
||||||
|
self.written += n as u64;
|
||||||
|
if self.written - self.last_print >= 1 << 20 {
|
||||||
|
self.last_print = self.written;
|
||||||
|
match self.total {
|
||||||
|
Some(t) => {
|
||||||
|
let pct = (self.written as f64 / t as f64) * 100.0;
|
||||||
|
eprint!(
|
||||||
|
"\r {:.1}% ({} / {} MiB)",
|
||||||
|
pct,
|
||||||
|
self.written >> 20,
|
||||||
|
t >> 20,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
None => eprint!("\r {} MiB", self.written >> 20),
|
||||||
|
}
|
||||||
|
let _ = std::io::stderr().flush();
|
||||||
|
}
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> std::io::Result<()> {
|
||||||
|
self.inner.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn pick_interactively(config: &Config) -> Result<String> {
|
fn pick_interactively(config: &Config) -> Result<String> {
|
||||||
let releases = fetch_releases(10)?;
|
let releases = fetch_releases(10)?;
|
||||||
|
if releases.is_empty() {
|
||||||
|
anyhow::bail!("GitHub returned no GE-Proton releases");
|
||||||
|
}
|
||||||
|
|
||||||
println!("Recent GE-Proton releases:");
|
println!("Recent GE-Proton releases:");
|
||||||
for (i, r) in releases.iter().enumerate() {
|
for (i, r) in releases.iter().enumerate() {
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
const UNIT_NAME: &str = "umutray.service";
|
||||||
|
|
||||||
|
fn unit_path() -> Result<PathBuf> {
|
||||||
|
let home = std::env::var("HOME").context("$HOME is not set")?;
|
||||||
|
Ok(PathBuf::from(home)
|
||||||
|
.join(".config/systemd/user")
|
||||||
|
.join(UNIT_NAME))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_unit(exe: &std::path::Path) -> String {
|
||||||
|
format!(
|
||||||
|
"[Unit]\n\
|
||||||
|
Description=Battle.net tray manager\n\
|
||||||
|
After=graphical-session.target\n\
|
||||||
|
PartOf=graphical-session.target\n\
|
||||||
|
\n\
|
||||||
|
[Service]\n\
|
||||||
|
ExecStart={exe}\n\
|
||||||
|
Restart=on-failure\n\
|
||||||
|
RestartSec=5\n\
|
||||||
|
\n\
|
||||||
|
[Install]\n\
|
||||||
|
WantedBy=graphical-session.target\n",
|
||||||
|
exe = exe.display(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn systemctl(args: &[&str]) -> Result<()> {
|
||||||
|
let status = Command::new("systemctl")
|
||||||
|
.arg("--user")
|
||||||
|
.args(args)
|
||||||
|
.status()
|
||||||
|
.context("Failed to invoke systemctl --user (is systemd installed?)")?;
|
||||||
|
if !status.success() {
|
||||||
|
bail!("systemctl --user {} exited non-zero", args.join(" "));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the unit, reload systemd, and enable+start the service.
|
||||||
|
pub fn install() -> Result<()> {
|
||||||
|
let exe = std::env::current_exe()
|
||||||
|
.context("Cannot determine path to own executable")?;
|
||||||
|
let path = unit_path()?;
|
||||||
|
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)
|
||||||
|
.with_context(|| format!("Failed to create {parent:?}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let contents = render_unit(&exe);
|
||||||
|
std::fs::write(&path, &contents)
|
||||||
|
.with_context(|| format!("Failed to write unit file {path:?}"))?;
|
||||||
|
println!("Wrote unit: {}", path.display());
|
||||||
|
println!("ExecStart: {}", exe.display());
|
||||||
|
println!();
|
||||||
|
|
||||||
|
systemctl(&["daemon-reload"])?;
|
||||||
|
systemctl(&["enable", "--now", UNIT_NAME])?;
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("\x1b[1;32m✓\x1b[0m Service installed and started.");
|
||||||
|
println!(" Status: systemctl --user status {UNIT_NAME}");
|
||||||
|
println!(" Logs: journalctl --user -u {UNIT_NAME} -f");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop, disable, and remove the unit file.
|
||||||
|
pub fn uninstall() -> Result<()> {
|
||||||
|
let path = unit_path()?;
|
||||||
|
|
||||||
|
// Ignore failures: the unit may already be stopped or unknown to systemd.
|
||||||
|
let _ = systemctl(&["disable", "--now", UNIT_NAME]);
|
||||||
|
|
||||||
|
if path.exists() {
|
||||||
|
std::fs::remove_file(&path)
|
||||||
|
.with_context(|| format!("Failed to remove {path:?}"))?;
|
||||||
|
println!("Removed {}", path.display());
|
||||||
|
} else {
|
||||||
|
println!("No unit file at {}", path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = systemctl(&["daemon-reload"]);
|
||||||
|
println!("\x1b[1;32m✓\x1b[0m Service removed.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pass through `systemctl --user status`.
|
||||||
|
pub fn status() -> Result<()> {
|
||||||
|
let _ = Command::new("systemctl")
|
||||||
|
.args(["--user", "status", UNIT_NAME])
|
||||||
|
.status();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
use crate::config::{Config, Launcher};
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
/// Print manual setup steps for a launcher.
|
||||||
|
///
|
||||||
|
/// This is a stub until the iced-based setup wizard lands. It walks the
|
||||||
|
/// user through creating the prefix directory, obtaining the installer,
|
||||||
|
/// and running it through umu.
|
||||||
|
pub fn run(config: &Config, launcher: &Launcher) -> Result<()> {
|
||||||
|
let version = launcher
|
||||||
|
.proton_version
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or(&config.proton_version);
|
||||||
|
let proton_path: String = if version == "GE-Proton" {
|
||||||
|
version.to_string()
|
||||||
|
} else {
|
||||||
|
config
|
||||||
|
.proton_compat_dir
|
||||||
|
.join(version)
|
||||||
|
.display()
|
||||||
|
.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("Setup steps for \x1b[1m{}\x1b[0m ({})", launcher.display, launcher.name);
|
||||||
|
println!();
|
||||||
|
println!("1. Create the prefix directory:");
|
||||||
|
println!(" mkdir -p {}", launcher.prefix_dir.display());
|
||||||
|
println!();
|
||||||
|
println!("2. Obtain the Windows installer for {}.", launcher.display);
|
||||||
|
if let Some(url) = &launcher.installer_url {
|
||||||
|
println!(" (configured source: {url})");
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
" No installer URL is configured for '{}'.",
|
||||||
|
launcher.name
|
||||||
|
);
|
||||||
|
println!(" Download the installer from the vendor and save it locally.");
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
println!("3. Run the installer under umu (replace INSTALLER.EXE with the path):");
|
||||||
|
println!(
|
||||||
|
" WINEPREFIX={} \\",
|
||||||
|
launcher.prefix_dir.display()
|
||||||
|
);
|
||||||
|
println!(" GAMEID={} \\", launcher.gameid);
|
||||||
|
println!(" PROTONPATH={proton_path} \\");
|
||||||
|
println!(" umu-run INSTALLER.EXE");
|
||||||
|
println!();
|
||||||
|
println!("4. After the installer finishes, verify it placed:");
|
||||||
|
println!(" {}", launcher.full_exe_path().display());
|
||||||
|
println!();
|
||||||
|
println!("5. Then: umutray launch {}", launcher.name);
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
"(A graphical setup wizard via iced is planned — this stub prints the manual\n \
|
||||||
|
steps in the meantime.)"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
+70
-49
@@ -1,70 +1,66 @@
|
|||||||
use crate::{config::Config, launcher};
|
use crate::{config::Config, launcher};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
pub struct BattlenetTray {
|
pub struct UmuTray {
|
||||||
pub config: Config,
|
pub config: Config,
|
||||||
/// Whether Battle.net is currently running; updated by background poller.
|
/// Per-launcher running state keyed by launcher.name
|
||||||
pub running: bool,
|
pub running: HashMap<String, bool>,
|
||||||
|
/// Set after the service spawns so Quit can shut down the SNI item
|
||||||
|
/// cleanly instead of yanking it off the bus via exit().
|
||||||
|
pub handle: Option<ksni::Handle<UmuTray>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ksni::Tray for BattlenetTray {
|
impl ksni::Tray for UmuTray {
|
||||||
fn id(&self) -> String {
|
fn id(&self) -> String {
|
||||||
"battlenet-manager".into()
|
"umutray".into()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn icon_name(&self) -> String {
|
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()
|
"applications-games".into()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fn title(&self) -> String {
|
fn title(&self) -> String {
|
||||||
if self.running {
|
if self.running.values().any(|&v| v) {
|
||||||
"Battle.net (running)".into()
|
"umutray (launcher running)".into()
|
||||||
} else {
|
} else {
|
||||||
"Battle.net".into()
|
"umutray".into()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn menu(&self) -> Vec<ksni::MenuItem<Self>> {
|
fn menu(&self) -> Vec<ksni::MenuItem<Self>> {
|
||||||
use ksni::menu::*;
|
use ksni::menu::*;
|
||||||
|
|
||||||
let mut items: Vec<ksni::MenuItem<Self>> = vec![];
|
let mut items: Vec<ksni::MenuItem<Self>> = vec![];
|
||||||
|
|
||||||
// Status label (non-interactive)
|
for l in &self.config.launchers {
|
||||||
|
let installed = l.full_exe_path().exists();
|
||||||
|
let running = *self.running.get(&l.name).unwrap_or(&false);
|
||||||
|
let name = l.name.clone();
|
||||||
|
let display = l.display.clone();
|
||||||
|
|
||||||
|
if !installed {
|
||||||
items.push(
|
items.push(
|
||||||
StandardItem {
|
StandardItem {
|
||||||
label: if self.running {
|
label: format!("{display} (not installed)"),
|
||||||
"● Battle.net is running".into()
|
|
||||||
} else {
|
|
||||||
"Battle.net".into()
|
|
||||||
},
|
|
||||||
enabled: false,
|
enabled: false,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
.into(),
|
.into(),
|
||||||
);
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
items.push(ksni::MenuItem::Separator);
|
if running {
|
||||||
|
|
||||||
// Launch / Kill
|
|
||||||
if self.running {
|
|
||||||
items.push(
|
items.push(
|
||||||
StandardItem {
|
StandardItem {
|
||||||
label: "Kill Battle.net".into(),
|
label: format!("Kill {display}"),
|
||||||
icon_name: "process-stop".into(),
|
icon_name: "process-stop".into(),
|
||||||
activate: Box::new(|_this: &mut Self| {
|
activate: Box::new(move |this: &mut Self| {
|
||||||
let _ = launcher::kill();
|
if let Some(l) = this.config.find(&name) {
|
||||||
|
let _ = launcher::kill(l);
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
@@ -73,11 +69,13 @@ impl ksni::Tray for BattlenetTray {
|
|||||||
} else {
|
} else {
|
||||||
items.push(
|
items.push(
|
||||||
StandardItem {
|
StandardItem {
|
||||||
label: "Launch Battle.net".into(),
|
label: format!("Launch {display}"),
|
||||||
icon_name: "media-playback-start".into(),
|
icon_name: "media-playback-start".into(),
|
||||||
activate: Box::new(|this: &mut Self| {
|
activate: Box::new(move |this: &mut Self| {
|
||||||
if let Err(e) = launcher::launch(&this.config) {
|
if let Some(l) = this.config.find(&name) {
|
||||||
eprintln!("battlenet-manager: launch failed: {e}");
|
if let Err(e) = launcher::launch(&this.config, l) {
|
||||||
|
eprintln!("umutray: launch {} failed: {e}", l.name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -85,10 +83,10 @@ impl ksni::Tray for BattlenetTray {
|
|||||||
.into(),
|
.into(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
items.push(ksni::MenuItem::Separator);
|
items.push(ksni::MenuItem::Separator);
|
||||||
|
|
||||||
// Update Proton (latest, background)
|
|
||||||
items.push(
|
items.push(
|
||||||
StandardItem {
|
StandardItem {
|
||||||
label: "Update GE-Proton (latest)".into(),
|
label: "Update GE-Proton (latest)".into(),
|
||||||
@@ -97,7 +95,7 @@ impl ksni::Tray for BattlenetTray {
|
|||||||
let config = this.config.clone();
|
let config = this.config.clone();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
if let Err(e) = crate::proton::install_latest(&config) {
|
if let Err(e) = crate::proton::install_latest(&config) {
|
||||||
eprintln!("battlenet-manager: proton update failed: {e}");
|
eprintln!("umutray: proton update failed: {e}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
@@ -108,13 +106,19 @@ impl ksni::Tray for BattlenetTray {
|
|||||||
|
|
||||||
items.push(ksni::MenuItem::Separator);
|
items.push(ksni::MenuItem::Separator);
|
||||||
|
|
||||||
// Quit
|
|
||||||
items.push(
|
items.push(
|
||||||
StandardItem {
|
StandardItem {
|
||||||
label: "Quit".into(),
|
label: "Quit".into(),
|
||||||
icon_name: "application-exit".into(),
|
icon_name: "application-exit".into(),
|
||||||
activate: Box::new(|_this: &mut Self| {
|
activate: Box::new(|this: &mut Self| {
|
||||||
|
if let Some(h) = this.handle.clone() {
|
||||||
|
thread::spawn(move || {
|
||||||
|
h.shutdown();
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
@@ -127,25 +131,42 @@ impl ksni::Tray for BattlenetTray {
|
|||||||
|
|
||||||
/// Start the system tray daemon. Blocks until the process is killed.
|
/// Start the system tray daemon. Blocks until the process is killed.
|
||||||
pub fn run(config: &Config) -> Result<()> {
|
pub fn run(config: &Config) -> Result<()> {
|
||||||
let tray = BattlenetTray {
|
let mut running = HashMap::new();
|
||||||
|
for l in &config.launchers {
|
||||||
|
running.insert(l.name.clone(), launcher::is_running(l));
|
||||||
|
}
|
||||||
|
|
||||||
|
let tray = UmuTray {
|
||||||
config: config.clone(),
|
config: config.clone(),
|
||||||
running: launcher::is_running(),
|
running,
|
||||||
|
handle: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let service = ksni::TrayService::new(tray);
|
let service = ksni::TrayService::new(tray);
|
||||||
let handle = service.spawn();
|
let handle = service.handle();
|
||||||
|
service.spawn();
|
||||||
|
|
||||||
// Background thread: poll Battle.net process state every 2 s and update the tray.
|
// Hand the tray a clone of its own handle so Quit can shut down cleanly.
|
||||||
let poller_handle = handle.clone();
|
let handle_for_self = handle.clone();
|
||||||
|
handle.update(move |t: &mut UmuTray| {
|
||||||
|
t.handle = Some(handle_for_self);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Background thread: poll every configured launcher's state every 2 s
|
||||||
|
// and push the snapshot to the tray.
|
||||||
|
let poll_handle = handle;
|
||||||
|
let launchers = config.launchers.clone();
|
||||||
thread::spawn(move || loop {
|
thread::spawn(move || loop {
|
||||||
let running = launcher::is_running();
|
let mut snapshot: HashMap<String, bool> = HashMap::new();
|
||||||
poller_handle.update(|tray: &mut BattlenetTray| {
|
for l in &launchers {
|
||||||
tray.running = running;
|
snapshot.insert(l.name.clone(), launcher::is_running(l));
|
||||||
|
}
|
||||||
|
poll_handle.update(move |t: &mut UmuTray| {
|
||||||
|
t.running = snapshot;
|
||||||
});
|
});
|
||||||
thread::sleep(Duration::from_secs(2));
|
thread::sleep(Duration::from_secs(2));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keep the main thread alive.
|
|
||||||
loop {
|
loop {
|
||||||
thread::sleep(Duration::from_secs(60));
|
thread::sleep(Duration::from_secs(60));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user