Compare commits
45 Commits
246ad03266
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4845ebe4f8 | |||
| c1893f9f64 | |||
| 2f4f1c64d2 | |||
| 8447581fe6 | |||
| a0ee01cd5d | |||
| aeed52d6dd | |||
| 2b538a286a | |||
| e213377a95 | |||
| f3f5046265 | |||
| 4e204d4bf7 | |||
| d3ac300b91 | |||
| 3c1742174b | |||
| b81c7fd863 | |||
| a1afa59f1a | |||
| 32c6e1fce0 | |||
| 20509fb488 | |||
| f645b58470 | |||
| 9ad1e6a745 | |||
| f70498158a | |||
| 3c78e1586f | |||
| 0c22e23ad3 | |||
| 108f385973 | |||
| d97a13e289 | |||
| 2e51b2e788 | |||
| c4587b0729 | |||
| d4f0515a82 | |||
| 156bb460a0 | |||
| 9134d3bab0 | |||
| 74f21b6b75 | |||
| 05a12b7cee | |||
| f170171895 | |||
| 9b7e474e80 | |||
| f2f584febf | |||
| 4c918e673b | |||
| 1bacf345f0 | |||
| e72ee69c14 | |||
| b72c642223 | |||
| 22fa1efabf | |||
| 14eccf4ef0 | |||
| 7e5ed3d447 | |||
| 336c5d908e | |||
| 6fe558ad00 | |||
| f7738d215b | |||
| 8908c15974 | |||
| 7de6f6d938 |
+10
@@ -0,0 +1,10 @@
|
||||
/target
|
||||
.vscode/
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
|
||||
# Packaging build artifacts
|
||||
packaging/pkg/
|
||||
packaging/src/
|
||||
packaging/umutray/
|
||||
packaging/*.pkg.tar.zst
|
||||
Generated
+5479
File diff suppressed because it is too large
Load Diff
+24
-8
@@ -1,11 +1,16 @@
|
||||
[package]
|
||||
name = "battlenet-manager"
|
||||
name = "umutray"
|
||||
version = "0.1.0"
|
||||
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]]
|
||||
name = "battlenet-manager"
|
||||
name = "umutray"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
@@ -14,7 +19,7 @@ clap = { version = "4", features = ["derive"] }
|
||||
|
||||
# Config serialisation
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
toml = "0.8"
|
||||
toml = "1"
|
||||
|
||||
# GitHub API responses
|
||||
serde_json = "1"
|
||||
@@ -22,11 +27,22 @@ serde_json = "1"
|
||||
# Error handling
|
||||
anyhow = "1"
|
||||
|
||||
# XDG config / data paths
|
||||
directories = "5"
|
||||
# XDG config / data / home paths
|
||||
dirs = "6"
|
||||
|
||||
# Terminal colour output
|
||||
owo-colors = "4"
|
||||
|
||||
# System tray via D-Bus StatusNotifierItem (KDE, GNOME+AppIndicator, Xfce, etc.)
|
||||
ksni = "0.2"
|
||||
ksni = { version = "0.3", features = ["blocking"] }
|
||||
|
||||
# HTTP for GE-Proton GitHub releases API
|
||||
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
||||
reqwest = { version = "0.13", features = ["blocking", "json"] }
|
||||
|
||||
# GUI for the setup wizard
|
||||
iced = { version = "0.14", features = ["tokio"] }
|
||||
iced_fonts = { version = "0.3", features = ["bootstrap"] }
|
||||
tokio = { version = "1.52.1", features = ["rt"] }
|
||||
|
||||
# Native file dialogs via XDG Desktop Portal
|
||||
rfd = "0.17"
|
||||
|
||||
@@ -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,112 @@
|
||||
# umutray
|
||||
|
||||
> **Note:** This project is entirely vibe-coded by [Claude](https://claude.com).
|
||||
> Every line of Rust, every commit message, and this README were written by
|
||||
> an AI working from conversational prompts. Expect the usual caveats.
|
||||
|
||||
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.
|
||||
- `autostart` — manages the XDG autostart entry so the tray starts with
|
||||
the graphical session.
|
||||
- `setup` — graphical wizard (iced) that downloads an installer URL
|
||||
(with progress bar) or accepts a local `.exe`, then runs it via
|
||||
`umu-run` in the launcher's Wine prefix with a live log pane.
|
||||
Uninstalled launchers expose a **Setup…** entry directly in the tray.
|
||||
|
||||
## 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 autostart install
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
| Command | What it does |
|
||||
| -------------------------------- | ------------------------------------------------------- |
|
||||
| `umutray` | Start the tray daemon (default) |
|
||||
| `umutray gui` | Open the graphical dashboard (with tray icon) |
|
||||
| `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 play <launcher> <game>` | Play a game with its configured overlays |
|
||||
| `umutray games [<launcher>]` | List configured games and their overlay flags |
|
||||
| `umutray diagnose [<name>]` | Health checks (one launcher or all) |
|
||||
| `umutray setup <name>` | Open the graphical setup wizard for a launcher |
|
||||
| `umutray detect [--apply]` | Scan common Wine prefixes for installed launchers |
|
||||
| `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 config add-launcher …` | Append a new launcher (needs `--exe-path`) |
|
||||
| `umutray config remove-launcher` | Drop a launcher (prefix on disk is left untouched) |
|
||||
| `umutray config add-game …` | Attach a game to a launcher (needs `--exe-path`) |
|
||||
| `umutray config remove-game …` | Drop a game from a launcher |
|
||||
| `umutray config set-game-flags …`| Per-game overlay toggles: gamemode/mangohud/gamescope |
|
||||
| `umutray autostart install` | Write XDG autostart entry (tray starts on login) |
|
||||
| `umutray autostart uninstall` | Remove the autostart and desktop entries |
|
||||
| `umutray autostart status` | Show whether XDG autostart is enabled |
|
||||
|
||||
## 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).
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="256" height="256" viewBox="0 0 256 256"><path fill="#6A2DD0" transform="scale(0.25 0.25)" d="M316.83 489.317C317.107 484.743 317.387 484.045 319.792 480.106C331.825 460.403 344.692 441.083 357.09 421.576L426.676 310.088L460.178 256.008C465.12 247.892 470.692 238.565 475.589 230.368C485.332 214.061 494.15 201.062 515.652 207.922C526.275 211.312 534.508 228.056 540.153 237.267L561.887 272.304L651.315 415.661L675.648 454.536C681.1 463.353 686.925 473.61 693.188 481.796C693.471 484.01 693.683 486.232 693.823 488.46C694.069 495.055 694.774 499.229 691.701 505.13C687.363 508.231 685.162 509.801 679.583 507.794C672.444 505.226 666.114 500.865 659.488 497.208L606.838 468.035C598.995 463.67 590.498 457.656 582.318 454.761C579.585 463.73 577.836 472.577 575.242 481.379L533.513 622.349C529.194 636.653 518.538 676.017 511.284 687.126C506.749 689.685 504.339 689.506 499.435 688.833C498.47 688.213 497.545 687.533 496.665 686.797C494.438 684.872 492.749 682.401 491.764 679.627C487.819 668.528 484.428 654.073 481.349 642.721L460.784 565.328L441.896 494.692C439.173 484.508 432.807 464.261 431.753 453.965C422.177 456.49 412.453 463.896 403.931 468.831C397.648 472.47 391.092 475.633 384.73 479.133C375.143 484.408 335.982 507.269 328.008 508.414C323.524 509.058 320.824 506.354 317.583 503.743C316.948 499.784 316.946 493.481 316.83 489.317Z"/><path fill="#0DE3F9" transform="scale(0.25 0.25)" d="M316.83 489.317C326.713 487.058 361.145 466.68 372.023 460.761L411.657 439.075C419.599 434.76 435.454 426.313 442.416 421.007C445.878 429.66 449.515 445.075 451.785 454.299C455.003 467.441 458.333 480.554 461.774 493.639L488.319 592.414C493.49 612.086 498.3 634.105 503.702 653.315C507.136 645.111 510.74 630.302 513.341 621.249L530.525 561.547L563.101 448.881C565.546 440.194 569.012 430.374 571.228 421.862C590.026 431.246 682.27 488.827 693.823 488.46C694.069 495.055 694.774 499.229 691.701 505.13C687.363 508.231 685.162 509.801 679.583 507.794C672.444 505.226 666.114 500.865 659.488 497.208L606.838 468.035C598.995 463.67 590.498 457.656 582.318 454.761C579.585 463.73 577.836 472.577 575.242 481.379L533.513 622.349C529.194 636.653 518.538 676.017 511.284 687.126C506.749 689.685 504.339 689.506 499.435 688.833C498.47 688.213 497.545 687.533 496.665 686.797C494.438 684.872 492.749 682.401 491.764 679.627C487.819 668.528 484.428 654.073 481.349 642.721L460.784 565.328L441.896 494.692C439.173 484.508 432.807 464.261 431.753 453.965C422.177 456.49 412.453 463.896 403.931 468.831C397.648 472.47 391.092 475.633 384.73 479.133C375.143 484.408 335.982 507.269 328.008 508.414C323.524 509.058 320.824 506.354 317.583 503.743C316.948 499.784 316.946 493.481 316.83 489.317Z"/><path fill="#6A2DD0" transform="scale(0.25 0.25)" d="M587.904 557.75C594.63 558.35 636.107 580.289 645.251 584.753L707.547 615.372C722.827 622.791 739.073 629.822 753.378 639.13C758.309 642.764 762.436 647.947 765.177 653.232C775.79 673.694 769.385 696.993 750.511 709.963C742.787 715.271 735.331 718.229 726.892 722.364L691.26 739.957C670.813 749.813 648.337 761.827 628.013 772.246L565.409 803.747C552.588 810.117 539.24 817.98 525.599 821.676C501.993 828.073 478.772 820.911 458.344 808.937C455.519 807.534 452.539 805.546 449.614 804.348C437.518 799.393 426.435 793.024 414.839 787.176L345.748 752.392L296.932 728.286C278.678 719.455 255.575 711.06 246.053 692.148C241.346 682.798 240.626 669.13 243.911 659.221C249.793 641.473 265.715 634.257 281.362 626.711L363.987 585.171C378.809 577.73 404.312 564.178 419.551 558.967C420.161 563.911 423.45 572.363 425.066 577.529C429.662 592.226 435.14 605.968 439.405 620.812C433.705 622.439 418.179 630.443 412.013 633.482L361.205 658.483L339.266 668.935C335.952 670.529 334.485 669.629 334.151 673.003C333.569 672.051 415.662 713.259 423.407 717.013C442.821 726.191 462.067 735.718 481.137 745.59C487.422 748.819 500.053 754.835 505.208 759.089C507.704 759.259 514.834 754.663 517.462 753.24C524.303 749.591 531.199 746.044 538.147 742.6L637.624 692.709C650.159 686.478 665.981 677.686 678.576 672.23C664.077 663.98 648.275 657.584 633.456 649.828C621.72 643.686 609.236 637.96 597.176 632.47C587.431 628.034 575.228 620.477 564.906 618.184C566.354 611.945 570.995 601.874 573.401 595.696C578.278 583.174 583.569 570.468 587.904 557.75Z"/></svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,60 @@
|
||||
# Maintainer: funman300 <funman300@gmail.com>
|
||||
#
|
||||
# AUR NOTE: This PKGBUILD is kept in the source repo for convenience during
|
||||
# development. For AUR submission, move it to a separate repository and update
|
||||
# the `source` array to point to the upstream git URL instead of a local path.
|
||||
#
|
||||
# To build locally:
|
||||
# cd packaging && makepkg -si
|
||||
#
|
||||
# To update for AUR, replace the source line with:
|
||||
# source=("$pkgname::git+https://git.aleshym.co/funman300/umutray.git#tag=v$pkgver")
|
||||
|
||||
pkgname=umutray
|
||||
pkgver=0.1.0
|
||||
pkgrel=6
|
||||
pkgdesc='Tray-based Wine launcher manager for Linux via umu/Proton-GE'
|
||||
arch=('x86_64')
|
||||
url='https://git.aleshym.co/funman300/umutray'
|
||||
license=('MIT')
|
||||
depends=('umu-launcher')
|
||||
makedepends=('rust' 'cargo')
|
||||
optdepends=(
|
||||
'gamemode: per-game GameMode support'
|
||||
'mangohud: per-game MangoHud overlay'
|
||||
'gamescope: per-game Gamescope compositor'
|
||||
)
|
||||
|
||||
# Build from the local repository. Change to the upstream URL for AUR.
|
||||
_localrepo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
source=("$pkgname::git+file://${_localrepo}")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
prepare() {
|
||||
cd "$pkgname"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
|
||||
}
|
||||
|
||||
build() {
|
||||
cd "$pkgname"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
export CARGO_TARGET_DIR=target
|
||||
cargo build --release --locked --offline
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$pkgname"
|
||||
|
||||
# Binary
|
||||
install -Dm755 target/release/umutray "$pkgdir/usr/bin/umutray"
|
||||
|
||||
# App menu entry
|
||||
install -Dm644 umutray.desktop "$pkgdir/usr/share/applications/umutray.desktop"
|
||||
|
||||
# Icon
|
||||
install -Dm644 assets/umutray.svg "$pkgdir/usr/share/icons/hicolor/scalable/apps/umutray.svg"
|
||||
|
||||
# License
|
||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
use anyhow::{Context, Result};
|
||||
use owo_colors::OwoColorize;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const DESKTOP_NAME: &str = "umutray.desktop";
|
||||
const ICON_SVG: &[u8] = include_bytes!("../assets/umutray.svg");
|
||||
|
||||
fn home() -> Result<PathBuf> {
|
||||
dirs::home_dir().context("Cannot determine home directory")
|
||||
}
|
||||
|
||||
fn autostart_path() -> Result<PathBuf> {
|
||||
Ok(home()?.join(".config/autostart").join(DESKTOP_NAME))
|
||||
}
|
||||
|
||||
fn desktop_path() -> Result<PathBuf> {
|
||||
Ok(home()?.join(".local/share/applications").join(DESKTOP_NAME))
|
||||
}
|
||||
|
||||
fn render_desktop(exe: &std::path::Path, autostart: bool) -> String {
|
||||
let exec = if autostart {
|
||||
format!("{}", exe.display())
|
||||
} else {
|
||||
format!("{} gui", exe.display())
|
||||
};
|
||||
let mut s = format!(
|
||||
"[Desktop Entry]\n\
|
||||
Name=umutray\n\
|
||||
Comment=Wine launcher manager for Windows game launchers\n\
|
||||
Exec={exec}\n\
|
||||
Icon=umutray\n\
|
||||
Type=Application\n\
|
||||
Categories=Game;\n\
|
||||
Keywords=wine;proton;gaming;launcher;\n\
|
||||
StartupNotify=false\n",
|
||||
);
|
||||
if autostart {
|
||||
s.push_str("X-GNOME-Autostart-enabled=true\n");
|
||||
s.push_str("Hidden=false\n");
|
||||
} else {
|
||||
s.push_str("StartupNotify=true\n");
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn ensure_parent(path: &Path) -> Result<()> {
|
||||
if let Some(p) = path.parent() {
|
||||
std::fs::create_dir_all(p)
|
||||
.with_context(|| format!("Failed to create {}", p.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_file(path: &Path, contents: impl AsRef<[u8]>) -> Result<()> {
|
||||
ensure_parent(path)?;
|
||||
std::fs::write(path, contents)
|
||||
.with_context(|| format!("Failed to write {}", path.display()))
|
||||
}
|
||||
|
||||
fn remove_file(path: &Path) -> Result<()> {
|
||||
if path.exists() {
|
||||
std::fs::remove_file(path)
|
||||
.with_context(|| format!("Failed to remove {}", path.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn icon_path() -> Result<PathBuf> {
|
||||
Ok(home()?.join(".local/share/icons/hicolor/scalable/apps/umutray.svg"))
|
||||
}
|
||||
|
||||
fn install_icon() -> Result<()> {
|
||||
write_file(&icon_path()?, ICON_SVG)
|
||||
}
|
||||
|
||||
/// Ensure the tray icon SVG is present in the XDG icon theme directory.
|
||||
/// Called automatically on startup so the icon works without needing
|
||||
/// a separate install step.
|
||||
pub fn ensure_icon() {
|
||||
let Ok(path) = icon_path() else { return };
|
||||
if !path.exists() {
|
||||
let _ = install_icon();
|
||||
}
|
||||
}
|
||||
|
||||
fn uninstall_icon() -> Result<()> {
|
||||
remove_file(&icon_path()?)
|
||||
}
|
||||
|
||||
/// Install only the .desktop file so umutray appears in the app menu.
|
||||
/// Called automatically on first `umutray gui` run.
|
||||
pub fn install_desktop() -> Result<()> {
|
||||
let exe = std::env::current_exe().context("Cannot determine path to own executable")?;
|
||||
let desktop = desktop_path()?;
|
||||
write_file(&desktop, render_desktop(&exe, false))?;
|
||||
install_icon()?;
|
||||
println!("{} App menu entry written: {}", "✓".green().bold(), desktop.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove the .desktop file.
|
||||
pub fn uninstall_desktop() -> Result<()> {
|
||||
let desktop = desktop_path()?;
|
||||
if desktop.exists() {
|
||||
remove_file(&desktop)?;
|
||||
println!("Removed {}", desktop.display());
|
||||
} else {
|
||||
println!("No desktop file at {}", desktop.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write an XDG autostart entry and the app-menu .desktop file.
|
||||
pub fn install() -> Result<()> {
|
||||
let exe = std::env::current_exe().context("Cannot determine path to own executable")?;
|
||||
|
||||
// XDG autostart
|
||||
let autostart = autostart_path()?;
|
||||
write_file(&autostart, render_desktop(&exe, true))?;
|
||||
println!("Wrote autostart: {}", autostart.display());
|
||||
|
||||
// App-menu entry
|
||||
install_desktop()?;
|
||||
|
||||
println!();
|
||||
println!("{} Autostart installed.", "✓".green().bold());
|
||||
println!(" umutray will start with your next graphical session.");
|
||||
println!(" To start now: {}", exe.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove the XDG autostart entry and the app-menu .desktop file.
|
||||
pub fn uninstall() -> Result<()> {
|
||||
let autostart = autostart_path()?;
|
||||
if autostart.exists() {
|
||||
remove_file(&autostart)?;
|
||||
println!("Removed {}", autostart.display());
|
||||
} else {
|
||||
println!("No autostart file at {}", autostart.display());
|
||||
}
|
||||
|
||||
uninstall_desktop()?;
|
||||
uninstall_icon()?;
|
||||
|
||||
println!("{} Autostart removed.", "✓".green().bold());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Show whether the XDG autostart entry is present.
|
||||
pub fn status() -> Result<()> {
|
||||
let autostart = autostart_path()?;
|
||||
if autostart.exists() {
|
||||
println!("{} Autostart enabled: {}", "✓".green().bold(), autostart.display());
|
||||
} else {
|
||||
println!("{} Autostart not installed ({})", "✗".red().bold(), autostart.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
+434
-34
@@ -1,59 +1,272 @@
|
||||
use anyhow::{Context, Result};
|
||||
use directories::ProjectDirs;
|
||||
use owo_colors::OwoColorize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Expresses the desired change to a game's gamescope setting.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum GamescopeUpdate {
|
||||
/// Leave the current value unchanged.
|
||||
Unchanged,
|
||||
/// Disable gamescope.
|
||||
Disable,
|
||||
/// Enable gamescope with the given CLI arguments.
|
||||
Enable(Vec<String>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Launcher {
|
||||
/// 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,
|
||||
/// Path to the launcher exe, relative to the prefix's drive_c/.
|
||||
pub exe_path: PathBuf,
|
||||
/// umu GAMEID (used to look up protonfixes).
|
||||
pub gameid: String,
|
||||
/// pgrep/pkill -f regex matching this launcher's running processes.
|
||||
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>,
|
||||
/// Games installed through this launcher. Overlays (gamemode, mangohud,
|
||||
/// gamescope) only apply to games — the launcher itself always runs bare.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub games: Vec<Game>,
|
||||
}
|
||||
|
||||
impl Launcher {
|
||||
/// Absolute path to the launcher exe inside the prefix.
|
||||
pub fn full_exe_path(&self) -> PathBuf {
|
||||
self.prefix_dir.join("drive_c").join(&self.exe_path)
|
||||
}
|
||||
|
||||
pub fn find_game(&self, name: &str) -> Option<&Game> {
|
||||
self.games.iter().find(|g| g.name == name)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Game {
|
||||
/// Short CLI name (e.g. "overwatch").
|
||||
pub name: String,
|
||||
/// Display name for tray / menus.
|
||||
pub display: String,
|
||||
/// Path to the game exe relative to the launcher's prefix `drive_c/`.
|
||||
pub exe_path: PathBuf,
|
||||
/// Optional extra args passed to the game exe after umu-run.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub args: Vec<String>,
|
||||
/// Wrap the game in `gamemoderun`.
|
||||
#[serde(default)]
|
||||
pub gamemode: bool,
|
||||
/// Set `MANGOHUD=1` for the game process.
|
||||
#[serde(default)]
|
||||
pub mangohud: bool,
|
||||
/// Wrap the game in `gamescope`. `None` = disabled; `Some(vec)` = enabled
|
||||
/// with those CLI args (empty vec = gamescope defaults).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub gamescope: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Game {
|
||||
/// Absolute path to the game exe inside the launcher's prefix.
|
||||
pub fn full_exe_path(&self, launcher: &Launcher) -> PathBuf {
|
||||
launcher.prefix_dir.join("drive_c").join(&self.exe_path)
|
||||
}
|
||||
}
|
||||
|
||||
#[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
|
||||
/// 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 {
|
||||
dirs::home_dir().expect("Cannot determine home directory")
|
||||
}
|
||||
|
||||
fn default_compat_dir() -> PathBuf {
|
||||
home_dir().join(".local/share/Steam/compatibilitytools.d")
|
||||
}
|
||||
|
||||
fn default_proton_version() -> String {
|
||||
"GE-Proton".into()
|
||||
}
|
||||
|
||||
fn regex_escape(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 4);
|
||||
for c in s.chars() {
|
||||
if matches!(
|
||||
c,
|
||||
'.' | '*' | '?' | '+' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '\\' | '^' | '$'
|
||||
) {
|
||||
out.push('\\');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 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|Blizzard.*Agent".into(),
|
||||
installer_url: Some(
|
||||
"https://www.battle.net/download/getInstallerForGame?os=win&gameProgram=BATTLENET_APP&version=Live".into(),
|
||||
),
|
||||
proton_version: None,
|
||||
games: vec![],
|
||||
},
|
||||
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: Some(
|
||||
"https://origin-a.akamaihd.net/EA-Desktop-Client-Download/installer-releases/EAappInstaller.exe".into(),
|
||||
),
|
||||
proton_version: None,
|
||||
games: vec![],
|
||||
},
|
||||
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: Some(
|
||||
"https://launcher-public-service-prod06.ol.epicgames.com/launcher/api/installer/download/EpicGamesLauncherInstaller.msi".into(),
|
||||
),
|
||||
proton_version: None,
|
||||
games: vec![],
|
||||
},
|
||||
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: Some(
|
||||
"https://ubistatic3-a.akamaihd.net/orbit/launcher_installer/UbisoftConnectInstaller.exe".into(),
|
||||
),
|
||||
proton_version: None,
|
||||
games: vec![],
|
||||
},
|
||||
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: Some(
|
||||
"https://webinstallers.gog-statics.com/download/GOG_Galaxy_2.0.exe".into(),
|
||||
),
|
||||
proton_version: None,
|
||||
games: vec![],
|
||||
},
|
||||
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: Some(
|
||||
"https://gamedownloads.rockstargames.com/public/installer/Rockstar-Games-Launcher.exe".into(),
|
||||
),
|
||||
proton_version: None,
|
||||
games: vec![],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
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"),
|
||||
proton_compat_dir: default_compat_dir(),
|
||||
proton_version: default_proton_version(),
|
||||
launchers: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
pub fn config_path() -> Result<PathBuf> {
|
||||
let config_dir = dirs::config_dir()
|
||||
.context("Could not determine config directory")?;
|
||||
Ok(dirs.config_dir().join("config.toml"))
|
||||
Ok(config_dir.join("umutray").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 c = Self::default();
|
||||
c.save().context("Failed to write default config")?;
|
||||
return Ok(c);
|
||||
}
|
||||
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")
|
||||
.with_context(|| format!("Failed to read config from {}", path.display()))?;
|
||||
match toml::from_str::<Self>(&content) {
|
||||
Ok(c) => {
|
||||
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.display()))?;
|
||||
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<()> {
|
||||
let path = Self::config_path()?;
|
||||
if let Some(parent) = path.parent() {
|
||||
@@ -61,12 +274,199 @@ impl Config {
|
||||
}
|
||||
let content = toml::to_string_pretty(self)?;
|
||||
std::fs::write(&path, content)
|
||||
.with_context(|| format!("Failed to write config to {path:?}"))
|
||||
.with_context(|| format!("Failed to write config to {}", path.display()))
|
||||
}
|
||||
|
||||
/// 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")
|
||||
pub fn find(&self, name: &str) -> Option<&Launcher> {
|
||||
self.launchers.iter().find(|l| l.name == name)
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn add_launcher(
|
||||
&mut self,
|
||||
name: String,
|
||||
display: Option<String>,
|
||||
exe_path: PathBuf,
|
||||
prefix_dir: Option<PathBuf>,
|
||||
gameid: Option<String>,
|
||||
process_pattern: Option<String>,
|
||||
installer_url: Option<String>,
|
||||
) -> Result<()> {
|
||||
if self.launchers.iter().any(|l| l.name == name) {
|
||||
anyhow::bail!("launcher '{name}' already exists");
|
||||
}
|
||||
let display = display.unwrap_or_else(|| name.clone());
|
||||
let prefix_dir = prefix_dir.unwrap_or_else(|| home_dir().join("Games").join(&name));
|
||||
let gameid = gameid.unwrap_or_else(|| format!("umu-{name}"));
|
||||
let process_pattern = process_pattern.unwrap_or_else(|| {
|
||||
exe_path
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(regex_escape)
|
||||
.unwrap_or_else(|| name.clone())
|
||||
});
|
||||
self.launchers.push(Launcher {
|
||||
name: name.clone(),
|
||||
display,
|
||||
prefix_dir,
|
||||
exe_path,
|
||||
gameid,
|
||||
process_pattern,
|
||||
installer_url,
|
||||
proton_version: None,
|
||||
games: vec![],
|
||||
});
|
||||
self.save()?;
|
||||
println!("{} Added launcher '{name}'.", "✓".green().bold());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn add_game(
|
||||
&mut self,
|
||||
launcher: &str,
|
||||
name: String,
|
||||
display: Option<String>,
|
||||
exe_path: PathBuf,
|
||||
gamemode: bool,
|
||||
mangohud: bool,
|
||||
gamescope: Option<Vec<String>>,
|
||||
) -> Result<()> {
|
||||
let l = self
|
||||
.launchers
|
||||
.iter_mut()
|
||||
.find(|l| l.name == launcher)
|
||||
.ok_or_else(|| anyhow::anyhow!("no launcher named '{launcher}'"))?;
|
||||
if l.games.iter().any(|g| g.name == name) {
|
||||
anyhow::bail!("launcher '{launcher}' already has a game named '{name}'");
|
||||
}
|
||||
let display = display.unwrap_or_else(|| name.clone());
|
||||
l.games.push(Game {
|
||||
name: name.clone(),
|
||||
display,
|
||||
exe_path,
|
||||
args: vec![],
|
||||
gamemode,
|
||||
mangohud,
|
||||
gamescope,
|
||||
});
|
||||
self.save()?;
|
||||
println!("{} Added game '{name}' under launcher '{launcher}'.", "✓".green().bold());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_game(&mut self, launcher: &str, name: &str) -> Result<()> {
|
||||
let l = self
|
||||
.launchers
|
||||
.iter_mut()
|
||||
.find(|l| l.name == launcher)
|
||||
.ok_or_else(|| anyhow::anyhow!("no launcher named '{launcher}'"))?;
|
||||
let before = l.games.len();
|
||||
l.games.retain(|g| g.name != name);
|
||||
if l.games.len() == before {
|
||||
anyhow::bail!("launcher '{launcher}' has no game named '{name}'");
|
||||
}
|
||||
self.save()?;
|
||||
println!("{} Removed game '{name}' from '{launcher}'.", "✓".green().bold());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update per-game overlay flags. Each arg is `None` = leave as-is.
|
||||
pub fn set_game_flags(
|
||||
&mut self,
|
||||
launcher: &str,
|
||||
name: &str,
|
||||
gamemode: Option<bool>,
|
||||
mangohud: Option<bool>,
|
||||
gamescope: GamescopeUpdate,
|
||||
) -> Result<()> {
|
||||
if gamemode.is_none() && mangohud.is_none() && matches!(gamescope, GamescopeUpdate::Unchanged) {
|
||||
anyhow::bail!(
|
||||
"nothing to set — pass --gamemode, --mangohud, --gamescope, or --no-gamescope"
|
||||
);
|
||||
}
|
||||
let l = self
|
||||
.launchers
|
||||
.iter_mut()
|
||||
.find(|l| l.name == launcher)
|
||||
.ok_or_else(|| anyhow::anyhow!("no launcher named '{launcher}'"))?;
|
||||
let g =
|
||||
l.games.iter_mut().find(|g| g.name == name).ok_or_else(|| {
|
||||
anyhow::anyhow!("launcher '{launcher}' has no game named '{name}'")
|
||||
})?;
|
||||
if let Some(v) = gamemode {
|
||||
g.gamemode = v;
|
||||
}
|
||||
if let Some(v) = mangohud {
|
||||
g.mangohud = v;
|
||||
}
|
||||
match gamescope {
|
||||
GamescopeUpdate::Unchanged => {}
|
||||
GamescopeUpdate::Disable => g.gamescope = None,
|
||||
GamescopeUpdate::Enable(args) => g.gamescope = Some(args),
|
||||
}
|
||||
self.save()?;
|
||||
println!("{} Updated flags for '{launcher}/{name}'.", "✓".green().bold());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_launcher(&mut self, name: &str) -> Result<()> {
|
||||
let before = self.launchers.len();
|
||||
self.launchers.retain(|l| l.name != name);
|
||||
if self.launchers.len() == before {
|
||||
anyhow::bail!("no launcher named '{name}'");
|
||||
}
|
||||
self.save()?;
|
||||
println!(
|
||||
"{} Removed '{name}'. The Wine prefix on disk was left untouched.",
|
||||
"✓".green().bold()
|
||||
);
|
||||
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!("{} Config saved.", "✓".green().bold());
|
||||
println!();
|
||||
self.show()
|
||||
}
|
||||
}
|
||||
|
||||
+620
@@ -0,0 +1,620 @@
|
||||
use crate::config::{Config, Launcher};
|
||||
use anyhow::Result;
|
||||
use owo_colors::OwoColorize;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DetectHit {
|
||||
pub display: String,
|
||||
pub prefix: PathBuf,
|
||||
/// True if this launcher is already in config with this exact prefix.
|
||||
pub configured: bool,
|
||||
}
|
||||
|
||||
/// Scan default Wine prefix locations and return hits against known presets.
|
||||
pub fn scan_for_gui(config: &Config) -> Vec<DetectHit> {
|
||||
let roots: Vec<PathBuf> = default_roots().into_iter().filter(|r| r.is_dir()).collect();
|
||||
let prefixes = scan_prefixes(&roots);
|
||||
let mut hits = Vec::new();
|
||||
for preset in crate::config::presets() {
|
||||
for prefix in &prefixes {
|
||||
if prefix.join("drive_c").join(&preset.exe_path).exists() {
|
||||
let configured = config
|
||||
.find(&preset.name)
|
||||
.is_some_and(|l| l.prefix_dir == *prefix);
|
||||
hits.push(DetectHit {
|
||||
display: preset.display.clone(),
|
||||
prefix: prefix.clone(),
|
||||
configured,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
hits
|
||||
}
|
||||
|
||||
/// Directories inside drive_c that contain Windows system files, not games.
|
||||
const SYSTEM_DIRS: &[&str] = &[
|
||||
"windows",
|
||||
"users",
|
||||
"programdata",
|
||||
"internet explorer",
|
||||
"windows media player",
|
||||
"windowspowershell",
|
||||
"microsoft.net",
|
||||
"common files",
|
||||
"microsoft",
|
||||
"windows nt",
|
||||
"windowsapps",
|
||||
];
|
||||
|
||||
/// Directory names that are pure launcher infrastructure — no game executables
|
||||
/// are ever installed here. Do NOT add parent dirs like "Epic Games" or
|
||||
/// "Ubisoft" that also contain game subdirectories; use SKIP_EXES instead.
|
||||
const SKIP_DIRS: &[&str] = &[
|
||||
"battle.net", // Battle.net launcher dir; its games live elsewhere
|
||||
"ea desktop", // EA Desktop launcher subfolder only
|
||||
"gog galaxy", // GOG Galaxy launcher; games are normally in GOG Games/
|
||||
"wine",
|
||||
"mono",
|
||||
"gecko",
|
||||
];
|
||||
|
||||
/// Exe filename patterns that are launcher tools, not games.
|
||||
const SKIP_EXES: &[&str] = &[
|
||||
"uninstall",
|
||||
"uninst",
|
||||
"crash",
|
||||
"error",
|
||||
"reporter",
|
||||
"update",
|
||||
"updater",
|
||||
"setup",
|
||||
"installer",
|
||||
"helper",
|
||||
"agent",
|
||||
"service",
|
||||
"repair",
|
||||
"diagnostic",
|
||||
"redist",
|
||||
"vcredist",
|
||||
"dxsetup",
|
||||
"dxwebsetup",
|
||||
"dotnetfx",
|
||||
"vc_redist",
|
||||
"bootstrapper",
|
||||
"launcher", // launcher tools, not games
|
||||
"battlenet",
|
||||
"blizzard",
|
||||
"eadesktop",
|
||||
"eabackgroundservice",
|
||||
"ealink",
|
||||
"epicgameslauncher",
|
||||
"epicwebhelper",
|
||||
"ubisoftconnect",
|
||||
"ubisoftgamelauncher",
|
||||
"upc",
|
||||
"galaxyclient",
|
||||
"galaxycommunication",
|
||||
"galaxypeer",
|
||||
"socialclubhelper",
|
||||
"subprocess",
|
||||
"cefprocess",
|
||||
"webhelper",
|
||||
"webview",
|
||||
"7za",
|
||||
"aria2c",
|
||||
];
|
||||
|
||||
// --- Name resolution ---
|
||||
|
||||
/// Cache of absolute exe path → resolved display name (populated lazily).
|
||||
static NAME_CACHE: LazyLock<Mutex<HashMap<PathBuf, String>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
/// Install-path → display title, built once from Legendary / Heroic metadata.
|
||||
static STORE_TITLES: LazyLock<HashMap<PathBuf, String>> =
|
||||
LazyLock::new(build_store_titles);
|
||||
|
||||
/// Read every `installed.json` that Legendary or Heroic may have written and
|
||||
/// return a map of absolute install directory → game title.
|
||||
fn build_store_titles() -> HashMap<PathBuf, String> {
|
||||
let mut map = HashMap::new();
|
||||
let Some(home) = dirs::home_dir() else { return map };
|
||||
|
||||
// Legendary standalone + Heroic's bundled copy (native and Flatpak).
|
||||
let legendary_candidates = [
|
||||
home.join(".config/legendary/installed.json"),
|
||||
home.join(".config/heroic/legendaryConfig/legendary/installed.json"),
|
||||
home.join(".var/app/com.heroicgameslauncher.hgl/config/legendary/installed.json"),
|
||||
home.join(".var/app/com.heroicgameslauncher.hgl/config/heroic/legendaryConfig/legendary/installed.json"),
|
||||
];
|
||||
for path in &legendary_candidates {
|
||||
if let Ok(text) = std::fs::read_to_string(path) {
|
||||
parse_legendary_installed(&text, &mut map);
|
||||
}
|
||||
}
|
||||
|
||||
// Heroic GOG store (native and Flatpak).
|
||||
let gog_candidates = [
|
||||
home.join(".config/heroic/gog_store/installed.json"),
|
||||
home.join(".var/app/com.heroicgameslauncher.hgl/config/heroic/gog_store/installed.json"),
|
||||
];
|
||||
for path in &gog_candidates {
|
||||
if let Ok(text) = std::fs::read_to_string(path) {
|
||||
parse_heroic_gog_installed(&text, &mut map);
|
||||
}
|
||||
}
|
||||
|
||||
map
|
||||
}
|
||||
|
||||
/// Legendary `installed.json`: `{ "AppName": { "install_path": "...", "title": "..." } }`
|
||||
fn parse_legendary_installed(text: &str, map: &mut HashMap<PathBuf, String>) {
|
||||
let Ok(json) = serde_json::from_str::<serde_json::Value>(text) else { return };
|
||||
let Some(obj) = json.as_object() else { return };
|
||||
for entry in obj.values() {
|
||||
let Some(path) = entry.get("install_path").and_then(|v| v.as_str()) else { continue };
|
||||
let Some(title) = entry.get("title").and_then(|v| v.as_str()) else { continue };
|
||||
if !title.is_empty() {
|
||||
map.insert(PathBuf::from(path), title.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Heroic GOG `installed.json`: `{ "installed": [ { "install_path": "...", "title": "..." } ] }`
|
||||
fn parse_heroic_gog_installed(text: &str, map: &mut HashMap<PathBuf, String>) {
|
||||
let Ok(json) = serde_json::from_str::<serde_json::Value>(text) else { return };
|
||||
let Some(arr) = json.get("installed").and_then(|v| v.as_array()) else { return };
|
||||
for entry in arr {
|
||||
let Some(path) = entry.get("install_path").and_then(|v| v.as_str()) else { continue };
|
||||
let Some(title) = entry.get("title").and_then(|v| v.as_str()) else { continue };
|
||||
if !title.is_empty() {
|
||||
map.insert(PathBuf::from(path), title.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk up from `exe_path` checking each ancestor against `STORE_TITLES`.
|
||||
fn store_title(exe_path: &Path) -> Option<String> {
|
||||
exe_path.ancestors().skip(1).find_map(|d| STORE_TITLES.get(d).cloned())
|
||||
}
|
||||
|
||||
/// Scan a launcher's Wine prefix for installed game executables.
|
||||
/// Returns (display_name, path_relative_to_drive_c) pairs, sorted alphabetically,
|
||||
/// excluding the launcher's own exe and any already-configured games.
|
||||
pub fn scan_games_in_prefix(launcher: &Launcher) -> Vec<(String, String)> {
|
||||
let drive_c = launcher.prefix_dir.join("drive_c");
|
||||
if !drive_c.exists() {
|
||||
return vec![];
|
||||
}
|
||||
let search_dirs = [
|
||||
drive_c.join("Program Files"),
|
||||
drive_c.join("Program Files (x86)"),
|
||||
];
|
||||
let already: HashSet<String> = launcher
|
||||
.games
|
||||
.iter()
|
||||
.map(|g| g.exe_path.to_string_lossy().to_lowercase())
|
||||
.collect();
|
||||
let launcher_exe = launcher.exe_path.to_string_lossy().to_lowercase();
|
||||
|
||||
let mut results = Vec::new();
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
for dir in &search_dirs {
|
||||
scan_exe_dir(dir, &drive_c, &launcher_exe, &already, &mut results, &mut seen, 0);
|
||||
}
|
||||
results.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
results
|
||||
}
|
||||
|
||||
fn scan_exe_dir(
|
||||
dir: &Path,
|
||||
drive_c: &Path,
|
||||
launcher_exe: &str,
|
||||
already: &HashSet<String>,
|
||||
out: &mut Vec<(String, String)>,
|
||||
seen: &mut HashSet<String>,
|
||||
depth: u32,
|
||||
) {
|
||||
if depth > 4 {
|
||||
return;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let lower = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
if SYSTEM_DIRS.iter().any(|s| lower.starts_with(s)) {
|
||||
continue;
|
||||
}
|
||||
if SKIP_DIRS.iter().any(|s| lower == *s) {
|
||||
continue;
|
||||
}
|
||||
if path.is_dir() {
|
||||
scan_exe_dir(&path, drive_c, launcher_exe, already, out, seen, depth + 1);
|
||||
} else if path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("exe"))
|
||||
{
|
||||
let Ok(rel) = path.strip_prefix(drive_c) else { continue };
|
||||
let rel_str = rel.to_string_lossy().to_string();
|
||||
let rel_lower = rel_str.to_lowercase();
|
||||
if rel_lower == launcher_exe || already.contains(&rel_lower) {
|
||||
continue;
|
||||
}
|
||||
// Skip launcher tools, updaters, and non-game executables
|
||||
let stem_lower = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
if SKIP_EXES.iter().any(|s| stem_lower.contains(s)) {
|
||||
continue;
|
||||
}
|
||||
if !seen.insert(rel_lower) {
|
||||
continue;
|
||||
}
|
||||
let display = resolve_game_name(&path, None);
|
||||
out.push((display, rel_str));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a human-readable display name for a game exe.
|
||||
///
|
||||
/// Resolution pipeline (first hit wins):
|
||||
/// 1. Explicit name — if `explicit_name` is `Some`, return it immediately.
|
||||
/// 2. Legendary / Heroic `installed.json` — maps install path → title,
|
||||
/// covers both Epic (via Legendary) and GOG (via Heroic's GOG store).
|
||||
/// 3. Manifest walk — walks up from the exe looking for GOG `.info` and
|
||||
/// Epic `.egstore/*.item` JSON files at the game's installation root.
|
||||
/// 4. Launcher path — reads the game name from well-known directory
|
||||
/// structures laid down by the launcher (e.g. `Epic Games/<Name>/`).
|
||||
/// 5. Nearest non-generic parent directory name, or raw exe stem.
|
||||
/// No name generation — if the directory name is unknown, it is used
|
||||
/// as-is rather than being fabricated from the exe filename.
|
||||
///
|
||||
/// Results from stages 2–5 are cached by path after first computation.
|
||||
pub fn resolve_game_name(exe_path: &Path, explicit_name: Option<&str>) -> String {
|
||||
if let Some(name) = explicit_name {
|
||||
return name.to_string();
|
||||
}
|
||||
|
||||
{
|
||||
let cache = NAME_CACHE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(cached) = cache.get(exe_path) {
|
||||
return cached.clone();
|
||||
}
|
||||
}
|
||||
|
||||
let name = resolve_uncached(exe_path);
|
||||
|
||||
NAME_CACHE
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.insert(exe_path.to_path_buf(), name.clone());
|
||||
|
||||
name
|
||||
}
|
||||
|
||||
fn resolve_uncached(exe_path: &Path) -> String {
|
||||
// Stage 2 – Legendary / Heroic installed.json (install path → title)
|
||||
if let Some(name) = store_title(exe_path) {
|
||||
return name;
|
||||
}
|
||||
|
||||
// Stage 3 – manifest files at the game's installation root
|
||||
if let Some(name) = read_manifest_name(exe_path) {
|
||||
return name;
|
||||
}
|
||||
|
||||
// Stage 4 – game name from known launcher directory structures
|
||||
if let Some(name) = name_from_launcher_path(exe_path) {
|
||||
return name;
|
||||
}
|
||||
|
||||
// Stage 5 – nearest non-generic parent directory, or raw exe stem.
|
||||
// No name generation: if we don't know, we say so honestly.
|
||||
prettify_exe_name(exe_path)
|
||||
}
|
||||
|
||||
/// Walk up from `exe_path` looking for platform manifest files that record the
|
||||
/// game's display name. Manifests live at the game's installation *root*, which
|
||||
/// can be several directories above the actual exe.
|
||||
///
|
||||
/// Supported formats:
|
||||
/// - GOG: `goggame-<id>.info` → `{ "gameName": "..." }`
|
||||
/// - Epic: `.egstore/<id>.item` → `{ "DisplayName": "..." }`
|
||||
fn read_manifest_name(exe_path: &Path) -> Option<String> {
|
||||
for d in exe_path.ancestors().skip(1) {
|
||||
let dirname = d.file_name().and_then(|n| n.to_str()).unwrap_or("").to_lowercase();
|
||||
// Stop once we reach drive_c root or the Program Files tier — manifests
|
||||
// are never above the game's installation folder.
|
||||
if dirname == "drive_c" || dirname.starts_with("program files") {
|
||||
break;
|
||||
}
|
||||
if let Some(name) = read_gog_manifest(d).or_else(|| read_epic_manifest(d)) {
|
||||
return Some(name);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn read_gog_manifest(dir: &Path) -> Option<String> {
|
||||
for entry in std::fs::read_dir(dir).ok()?.flatten() {
|
||||
let fname = entry.file_name();
|
||||
let fname = fname.to_string_lossy();
|
||||
if fname.starts_with("goggame-") && fname.ends_with(".info") {
|
||||
let text = std::fs::read_to_string(entry.path()).ok()?;
|
||||
let json: serde_json::Value = serde_json::from_str(&text).ok()?;
|
||||
let t = json.get("gameName")?.as_str()?.trim();
|
||||
if !t.is_empty() {
|
||||
return Some(t.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn read_epic_manifest(dir: &Path) -> Option<String> {
|
||||
let egstore = dir.join(".egstore");
|
||||
if !egstore.is_dir() {
|
||||
return None;
|
||||
}
|
||||
for entry in std::fs::read_dir(&egstore).ok()?.flatten() {
|
||||
if entry.path().extension().and_then(|e| e.to_str()) == Some("item") {
|
||||
let text = std::fs::read_to_string(entry.path()).ok()?;
|
||||
let json: serde_json::Value = serde_json::from_str(&text).ok()?;
|
||||
let t = json.get("DisplayName")?.as_str()?.trim();
|
||||
if !t.is_empty() {
|
||||
return Some(t.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract a game name from well-known launcher directory conventions.
|
||||
///
|
||||
/// Launchers install each game into a named subdirectory of their own folder.
|
||||
/// That subdirectory name *is* the display name:
|
||||
/// - Epic: `…/Epic Games/<GameName>/…`
|
||||
/// - GOG: `…/GOG Games/<GameName>/…`
|
||||
/// - Steam: `…/steamapps/common/<GameName>/…`
|
||||
/// - Rockstar:`…/Rockstar Games/<GameName>/…`
|
||||
/// - EA: `…/EA Games/<GameName>/…`
|
||||
/// - Ubisoft: `…/Ubisoft Game Launcher/games/<GameName>/…`
|
||||
fn name_from_launcher_path(exe_path: &Path) -> Option<String> {
|
||||
let comps: Vec<&std::ffi::OsStr> = exe_path.components().map(|c| c.as_os_str()).collect();
|
||||
|
||||
for (i, comp) in comps.iter().enumerate() {
|
||||
let lower = comp.to_str().unwrap_or("").to_lowercase();
|
||||
match lower.as_str() {
|
||||
"epic games" | "gog games" | "rockstar games" | "ea games" => {
|
||||
return comps.get(i + 1).and_then(|c| c.to_str()).map(str::to_string);
|
||||
}
|
||||
// Ubisoft: …/Ubisoft Game Launcher/games/<GameName>/…
|
||||
"ubisoft game launcher" => {
|
||||
return comps.get(i + 2).and_then(|c| c.to_str()).map(str::to_string);
|
||||
}
|
||||
"common"
|
||||
if i > 0
|
||||
&& comps[i - 1].to_str().unwrap_or("").to_lowercase() == "steamapps" =>
|
||||
{
|
||||
return comps.get(i + 1).and_then(|c| c.to_str()).map(str::to_string);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Heuristic last-resort name derivation from an exe path.
|
||||
///
|
||||
/// Walks up parent directories looking for a non-generic name; falls back to
|
||||
/// inserting spaces into the CamelCase / digit-boundary exe stem.
|
||||
pub fn prettify_exe_name(path: &Path) -> String {
|
||||
const GENERIC_DIRS: &[&str] = &[
|
||||
"bin", "binaries", "x64", "x86", "win64", "win32", "retail",
|
||||
"shipping", "game", "runtime", "_retail_", "_commonredist",
|
||||
"launcher", "engine", "client",
|
||||
];
|
||||
|
||||
for d in path.ancestors().skip(1) {
|
||||
let name = d.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
let lower = name.to_lowercase();
|
||||
if !name.is_empty()
|
||||
&& !GENERIC_DIRS.iter().any(|g| lower == *g)
|
||||
&& !lower.starts_with("program files")
|
||||
{
|
||||
return name.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing useful in the path — return the exe stem as-is.
|
||||
path.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("Unknown")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
const MAX_DEPTH: u32 = 3;
|
||||
|
||||
pub fn run(config: &Config, extra_dirs: &[PathBuf], apply: bool) -> Result<()> {
|
||||
let mut roots = default_roots();
|
||||
roots.extend(extra_dirs.iter().cloned());
|
||||
roots.sort();
|
||||
roots.dedup();
|
||||
let existing: Vec<PathBuf> = roots.into_iter().filter(|r| r.is_dir()).collect();
|
||||
|
||||
let prefixes = scan_prefixes(&existing);
|
||||
println!(
|
||||
"Scanned {} root{} → found {} prefix{}.\n",
|
||||
existing.len(),
|
||||
if existing.len() == 1 { "" } else { "s" },
|
||||
prefixes.len(),
|
||||
if prefixes.len() == 1 { "" } else { "es" },
|
||||
);
|
||||
|
||||
let by_launcher = match_launchers(config, &prefixes);
|
||||
|
||||
if apply {
|
||||
apply_findings(config, &by_launcher)?;
|
||||
} else {
|
||||
print_findings(config, &by_launcher);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn default_roots() -> Vec<PathBuf> {
|
||||
let Some(home) = dirs::home_dir() else {
|
||||
return Vec::new();
|
||||
};
|
||||
vec![
|
||||
home.join("Games"),
|
||||
home.join(".wine"),
|
||||
home.join(".local/share/lutris/runners/wine"),
|
||||
home.join(".local/share/bottles/bottles"),
|
||||
home.join(".var/app/com.usebottles.bottles/data/bottles/bottles"),
|
||||
home.join("Games/Heroic/Prefixes/default"),
|
||||
home.join(".var/app/com.heroicgameslauncher.hgl/config/heroic/Prefixes/default"),
|
||||
]
|
||||
}
|
||||
|
||||
fn scan_prefixes(roots: &[PathBuf]) -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
for root in roots {
|
||||
collect_prefixes(root, 0, &mut out);
|
||||
}
|
||||
out.sort();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
fn collect_prefixes(dir: &Path, depth: u32, out: &mut Vec<PathBuf>) {
|
||||
if dir.join("drive_c").is_dir() {
|
||||
out.push(dir.to_path_buf());
|
||||
return;
|
||||
}
|
||||
// Proton / umu layout: <gameid>/pfx/drive_c
|
||||
if dir.join("pfx/drive_c").is_dir() {
|
||||
out.push(dir.join("pfx"));
|
||||
return;
|
||||
}
|
||||
if depth >= MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
if entry.file_type().is_ok_and(|t| t.is_dir()) {
|
||||
collect_prefixes(&entry.path(), depth + 1, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn match_launchers(config: &Config, prefixes: &[PathBuf]) -> HashMap<String, Vec<PathBuf>> {
|
||||
let mut by_launcher: HashMap<String, Vec<PathBuf>> = HashMap::new();
|
||||
for l in &config.launchers {
|
||||
for prefix in prefixes {
|
||||
if prefix.join("drive_c").join(&l.exe_path).exists() {
|
||||
by_launcher
|
||||
.entry(l.name.clone())
|
||||
.or_default()
|
||||
.push(prefix.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
by_launcher
|
||||
}
|
||||
|
||||
fn print_findings(config: &Config, by_launcher: &HashMap<String, Vec<PathBuf>>) {
|
||||
let mut any_divergent = false;
|
||||
for l in &config.launchers {
|
||||
match by_launcher.get(&l.name) {
|
||||
None => {
|
||||
println!(" · {:12} not found", l.name);
|
||||
}
|
||||
Some(matches) if matches.len() > 1 => {
|
||||
println!(" {} {:12} multiple prefixes:", "⚠".yellow(), l.name);
|
||||
for p in matches {
|
||||
println!(" {}", p.display());
|
||||
}
|
||||
}
|
||||
Some(matches) => {
|
||||
let detected = &matches[0];
|
||||
if *detected == l.prefix_dir {
|
||||
println!(" {} {:12} {}", "✓".green().bold(), l.name, detected.display());
|
||||
} else {
|
||||
any_divergent = true;
|
||||
println!(
|
||||
" {} {:12} {} (was {})",
|
||||
"→".cyan(),
|
||||
l.name,
|
||||
detected.display(),
|
||||
l.prefix_dir.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if any_divergent {
|
||||
println!("\nRerun with --apply to update config.");
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_findings(config: &Config, by_launcher: &HashMap<String, Vec<PathBuf>>) -> Result<()> {
|
||||
let mut c = config.clone();
|
||||
let mut updated = 0;
|
||||
let mut ambiguous = 0;
|
||||
for l in c.launchers.iter_mut() {
|
||||
let Some(matches) = by_launcher.get(&l.name) else {
|
||||
continue;
|
||||
};
|
||||
if matches.len() > 1 {
|
||||
ambiguous += 1;
|
||||
println!(" {} {:12} ambiguous — update via `config edit`", "⚠".yellow(), l.name);
|
||||
continue;
|
||||
}
|
||||
let detected = &matches[0];
|
||||
if *detected == l.prefix_dir {
|
||||
println!(" {} {:12} unchanged", "✓".green().bold(), l.name);
|
||||
continue;
|
||||
}
|
||||
println!(
|
||||
" {} {:12} {} → {}",
|
||||
"→".green().bold(),
|
||||
l.name,
|
||||
l.prefix_dir.display(),
|
||||
detected.display()
|
||||
);
|
||||
l.prefix_dir = detected.clone();
|
||||
updated += 1;
|
||||
}
|
||||
if updated > 0 {
|
||||
c.save()?;
|
||||
println!(
|
||||
"\nUpdated {updated} launcher{}.",
|
||||
if updated == 1 { "" } else { "s" }
|
||||
);
|
||||
} else {
|
||||
println!("\nNothing to update.");
|
||||
}
|
||||
if ambiguous > 0 {
|
||||
println!(
|
||||
"{ambiguous} launcher{} skipped (multiple matches).",
|
||||
if ambiguous == 1 { "" } else { "s" }
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
+211
-184
@@ -1,194 +1,211 @@
|
||||
use crate::config::Config;
|
||||
use crate::config::{Config, Launcher};
|
||||
use anyhow::Result;
|
||||
use owo_colors::OwoColorize;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
struct Check {
|
||||
label: &'static str,
|
||||
pass: bool,
|
||||
detail: String,
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CheckResult {
|
||||
pub label: String,
|
||||
pub pass: bool,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl Check {
|
||||
fn pass(label: &'static str, detail: impl Into<String>) -> Self {
|
||||
Self { label, pass: true, detail: detail.into() }
|
||||
impl CheckResult {
|
||||
fn pass(label: impl Into<String>, detail: impl Into<String>) -> Self {
|
||||
Self { label: label.into(), pass: true, detail: detail.into() }
|
||||
}
|
||||
fn fail(label: &'static str, detail: impl Into<String>) -> Self {
|
||||
Self { label, pass: false, detail: detail.into() }
|
||||
fn fail(label: impl Into<String>, detail: impl Into<String>) -> Self {
|
||||
Self { label: label.into(), pass: false, detail: detail.into() }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(config: &Config) {
|
||||
let mut checks: Vec<Check> = Vec::new();
|
||||
pub fn run_checks(config: &Config, name: Option<&str>) -> Result<Vec<CheckResult>> {
|
||||
let mut checks = vec![
|
||||
global_umu_check(),
|
||||
global_vulkan_check(),
|
||||
global_display_check(),
|
||||
compat_dir_check(config),
|
||||
wineserver_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));
|
||||
}
|
||||
Ok(checks)
|
||||
}
|
||||
|
||||
pub fn run(config: &Config, name: Option<&str>) -> Result<()> {
|
||||
let checks = run_checks(config, name)?;
|
||||
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")
|
||||
if !c.pass {
|
||||
issues += 1;
|
||||
}
|
||||
if c.pass {
|
||||
println!(" {} {:24} {}", "✓".green().bold(), c.label, c.detail);
|
||||
} else {
|
||||
("✗", "\x1b[1;31m", "\x1b[0m")
|
||||
};
|
||||
println!(" {colour}{symbol}{reset} {:16} {}", c.label, c.detail);
|
||||
println!(" {} {:24} {}", "✗".red().bold(), c.label, c.detail);
|
||||
}
|
||||
}
|
||||
println!();
|
||||
if issues == 0 {
|
||||
println!(" \x1b[1;32mAll checks passed.\x1b[0m");
|
||||
println!(" {}", "All checks passed.".green().bold());
|
||||
} else {
|
||||
println!(" \x1b[1;31m{issues} issue(s) found — see ✗ items above.\x1b[0m");
|
||||
println!(" {}", format!("{issues} issue(s) found — see ✗ items above.").red().bold());
|
||||
}
|
||||
println!();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
fn global_umu_check() -> CheckResult {
|
||||
match which("umu-run") {
|
||||
Some(p) => CheckResult::pass("umu-run", format!("found at {p}")),
|
||||
None => CheckResult::fail("umu-run", "not found — install umu-launcher"),
|
||||
}
|
||||
}
|
||||
|
||||
fn which(cmd: &str) -> Option<String> {
|
||||
Command::new("which")
|
||||
.arg(cmd)
|
||||
fn global_vulkan_check() -> CheckResult {
|
||||
let ok = Command::new("vulkaninfo")
|
||||
.arg("--summary")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.is_ok_and(|s| s.success());
|
||||
if ok {
|
||||
CheckResult::pass("vulkan", "vulkaninfo OK")
|
||||
} else {
|
||||
CheckResult::fail(
|
||||
"vulkan",
|
||||
"vulkaninfo failed — check GPU drivers / vulkan-tools",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn global_display_check() -> CheckResult {
|
||||
let display = std::env::var("DISPLAY").ok();
|
||||
let wayland = std::env::var("WAYLAND_DISPLAY").ok();
|
||||
match (display, wayland) {
|
||||
(Some(d), Some(_)) => CheckResult::pass("display", format!("XWayland (DISPLAY={d})")),
|
||||
(Some(d), None) => CheckResult::pass("display", format!("X11 (DISPLAY={d})")),
|
||||
(None, Some(_)) => CheckResult::fail(
|
||||
"display",
|
||||
"Wayland session but DISPLAY unset; XWayland needed",
|
||||
),
|
||||
(None, None) => CheckResult::fail("display", "neither DISPLAY nor WAYLAND_DISPLAY set"),
|
||||
}
|
||||
}
|
||||
|
||||
fn compat_dir_check(config: &Config) -> CheckResult {
|
||||
let n = count_ge_proton(&config.proton_compat_dir);
|
||||
if config.proton_version == "GE-Proton" {
|
||||
CheckResult::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() {
|
||||
CheckResult::pass("proton", format!("{} installed", config.proton_version))
|
||||
} else {
|
||||
CheckResult::fail(
|
||||
"proton",
|
||||
format!(
|
||||
"{} missing — run: umutray update-proton --version={}",
|
||||
config.proton_version, config.proton_version
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wineserver_check(config: &Config) -> CheckResult {
|
||||
let count = wineserver_count();
|
||||
if count == 0 {
|
||||
return CheckResult::pass("wine procs", "no wineserver running");
|
||||
}
|
||||
let any_running = config.launchers.iter().any(crate::launcher::is_running);
|
||||
if any_running {
|
||||
CheckResult::pass(
|
||||
"wine procs",
|
||||
format!("{count} wineserver process(es); launcher active"),
|
||||
)
|
||||
} else {
|
||||
CheckResult::fail(
|
||||
"wine procs",
|
||||
format!("{count} stale wineserver process(es) — try: umutray kill"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn wineserver_count() -> usize {
|
||||
Command::new("pgrep")
|
||||
.args(["-c", "-f", "wineserver"])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
.and_then(|s| s.trim().parse::<usize>().ok())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn launcher_checks(l: &Launcher) -> Vec<CheckResult> {
|
||||
let mut out = Vec::new();
|
||||
let tag = format!("[{}]", l.name);
|
||||
|
||||
if !l.prefix_dir.exists() {
|
||||
out.push(CheckResult::fail(
|
||||
format!("{tag} prefix"),
|
||||
format!(
|
||||
"{} missing — run: umutray setup {}",
|
||||
l.prefix_dir.display(),
|
||||
l.name
|
||||
),
|
||||
));
|
||||
return out;
|
||||
}
|
||||
out.push(CheckResult::pass(
|
||||
format!("{tag} prefix"),
|
||||
l.prefix_dir.display().to_string(),
|
||||
));
|
||||
|
||||
let exe = l.full_exe_path();
|
||||
if exe.exists() {
|
||||
out.push(CheckResult::pass(format!("{tag} exe"), "installed"));
|
||||
} else {
|
||||
out.push(CheckResult::fail(
|
||||
format!("{tag} exe"),
|
||||
format!("missing — run: umutray setup {}", l.name),
|
||||
));
|
||||
}
|
||||
|
||||
if is_owned_by_current_user(&l.prefix_dir) {
|
||||
out.push(CheckResult::pass(format!("{tag} owner"), "owned by current user"));
|
||||
} else {
|
||||
out.push(CheckResult::fail(
|
||||
format!("{tag} owner"),
|
||||
"not owned by current user",
|
||||
));
|
||||
}
|
||||
|
||||
if crate::launcher::is_running(l) {
|
||||
out.push(CheckResult::pass(format!("{tag} process"), "currently running"));
|
||||
} else {
|
||||
out.push(CheckResult::pass(format!("{tag} process"), "not running"));
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn count_ge_proton(dir: &Path) -> usize {
|
||||
@@ -200,28 +217,38 @@ fn count_ge_proton(dir: &Path) -> usize {
|
||||
.filter(|e| {
|
||||
e.file_name()
|
||||
.to_str()
|
||||
.map(|s| s.starts_with("GE-Proton"))
|
||||
.unwrap_or(false)
|
||||
.is_some_and(|s| s.starts_with("GE-Proton"))
|
||||
})
|
||||
.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("")])
|
||||
fn which(cmd: &str) -> Option<String> {
|
||||
Command::new("which")
|
||||
.arg(cmd)
|
||||
.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
|
||||
}
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
}
|
||||
|
||||
fn current_uid() -> Option<u32> {
|
||||
std::fs::read_to_string("/proc/self/status")
|
||||
.ok()
|
||||
.and_then(|s| {
|
||||
s.lines()
|
||||
.find(|l| l.starts_with("Uid:"))
|
||||
.and_then(|l| l.split_whitespace().nth(1))
|
||||
.and_then(|s| s.parse().ok())
|
||||
})
|
||||
}
|
||||
|
||||
fn is_owned_by_current_user(path: &Path) -> bool {
|
||||
let file_uid = match std::fs::metadata(path) {
|
||||
Ok(m) => m.uid(),
|
||||
Err(_) => return true,
|
||||
};
|
||||
current_uid().map_or(true, |uid| uid == file_uid)
|
||||
}
|
||||
|
||||
+1764
File diff suppressed because it is too large
Load Diff
+152
-34
@@ -1,24 +1,62 @@
|
||||
use crate::config::Config;
|
||||
use anyhow::{Context, Result};
|
||||
use crate::config::{Config, Game, Launcher};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::ffi::OsString;
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
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();
|
||||
/// Resolve PROTONPATH for umu-run: the literal "GE-Proton" makes umu-run
|
||||
/// auto-fetch the latest; a pinned version gets the full path in compat_dir.
|
||||
pub fn resolve_proton_path(config: &Config, launcher: &Launcher) -> OsString {
|
||||
let version = launcher
|
||||
.proton_version
|
||||
.as_deref()
|
||||
.unwrap_or(&config.proton_version);
|
||||
if version == "GE-Proton" {
|
||||
version.to_string().into()
|
||||
} else {
|
||||
config.proton_compat_dir.join(version).into_os_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the launcher via umu-run and return immediately.
|
||||
pub fn launch(config: &Config, launcher: &Launcher) -> Result<()> {
|
||||
let exe = launcher.full_exe_path();
|
||||
if !exe.exists() {
|
||||
anyhow::bail!(
|
||||
"Battle.net Launcher.exe not found at {exe:?}\n\
|
||||
Run 'battlenet-umu-setup.sh' to install it first."
|
||||
bail!(
|
||||
"launcher exe not found at {}\n\
|
||||
Run `umutray setup {}` for setup instructions.",
|
||||
exe.display(),
|
||||
launcher.name,
|
||||
);
|
||||
}
|
||||
|
||||
std::process::Command::new("umu-run")
|
||||
.env("WINEPREFIX", &config.prefix_dir)
|
||||
.env("GAMEID", &config.gameid)
|
||||
.env("PROTONPATH", &config.proton_version)
|
||||
.arg(&exe)
|
||||
let proton_path = resolve_proton_path(config, launcher);
|
||||
|
||||
// Propagate overlay env vars from any configured game so that games
|
||||
// launched from within the launcher (e.g. Battle.net → Diablo) inherit
|
||||
// them. gamemoderun wraps umu-run so the whole process tree gets
|
||||
// gamemode; MANGOHUD=1 is inherited by all child processes.
|
||||
let any_gamemode = launcher.games.iter().any(|g| g.gamemode);
|
||||
let any_mangohud = launcher.games.iter().any(|g| g.mangohud);
|
||||
|
||||
let (prog, args): (OsString, Vec<OsString>) = if any_gamemode {
|
||||
let mut a = vec![OsString::from("umu-run")];
|
||||
a.push(exe.into_os_string());
|
||||
("gamemoderun".into(), a)
|
||||
} else {
|
||||
(OsString::from("umu-run"), vec![exe.into_os_string()])
|
||||
};
|
||||
|
||||
let mut cmd = std::process::Command::new(&prog);
|
||||
cmd.env("WINEPREFIX", &launcher.prefix_dir)
|
||||
.env("GAMEID", &launcher.gameid)
|
||||
.env("PROTONPATH", &proton_path);
|
||||
if any_mangohud {
|
||||
cmd.env("MANGOHUD", "1");
|
||||
}
|
||||
cmd.args(&args)
|
||||
.spawn()
|
||||
.context(
|
||||
"Failed to spawn umu-run. Is it installed?\n\
|
||||
@@ -28,32 +66,112 @@ pub fn launch(config: &Config) -> Result<()> {
|
||||
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();
|
||||
/// Launch a game directly via umu-run, wrapped in the per-game overlays
|
||||
/// (gamescope, gamemoderun, MANGOHUD). Ensures the parent launcher is
|
||||
/// running first so the game can authenticate online.
|
||||
pub fn play_game(config: &Config, launcher: &Launcher, game: &Game) -> Result<()> {
|
||||
let exe = game.full_exe_path(launcher);
|
||||
if !exe.exists() {
|
||||
bail!(
|
||||
"game exe not found at {}\n\
|
||||
Check exe_path for '{}/{}' in config, or install the game via the launcher first.",
|
||||
exe.display(),
|
||||
launcher.name,
|
||||
game.name,
|
||||
);
|
||||
}
|
||||
|
||||
thread::sleep(Duration::from_secs(3));
|
||||
|
||||
for pattern in &patterns {
|
||||
let _ = std::process::Command::new("pkill")
|
||||
.args(["-9", "-f", pattern])
|
||||
.status();
|
||||
// Start the launcher if it isn't already running so the game has an
|
||||
// active authentication session (avoids offline-mode).
|
||||
if !is_running(launcher) {
|
||||
launch(config, launcher)?;
|
||||
// Give the launcher a moment to initialise before spawning the game.
|
||||
thread::sleep(Duration::from_secs(5));
|
||||
}
|
||||
|
||||
let proton_path = resolve_proton_path(config, launcher);
|
||||
|
||||
let (prog, args) = build_wrapped_argv(&exe, game);
|
||||
|
||||
let mut cmd = std::process::Command::new(&prog);
|
||||
cmd.env("WINEPREFIX", &launcher.prefix_dir)
|
||||
.env("GAMEID", &launcher.gameid)
|
||||
.env("PROTONPATH", &proton_path);
|
||||
if game.mangohud {
|
||||
cmd.env("MANGOHUD", "1");
|
||||
}
|
||||
cmd.args(&args);
|
||||
cmd.spawn().with_context(|| {
|
||||
format!(
|
||||
"Failed to spawn '{}'. Is umu-run / gamemoderun / gamescope on PATH?",
|
||||
prog.to_string_lossy()
|
||||
)
|
||||
})?;
|
||||
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)
|
||||
/// Outermost → innermost: gamescope → gamemoderun → umu-run → exe [args]
|
||||
fn build_wrapped_argv(exe: &Path, game: &Game) -> (OsString, Vec<OsString>) {
|
||||
let mut argv: Vec<OsString> = Vec::new();
|
||||
if let Some(gs_args) = &game.gamescope {
|
||||
argv.push("gamescope".into());
|
||||
for a in gs_args {
|
||||
argv.push(a.into());
|
||||
}
|
||||
argv.push("--".into());
|
||||
}
|
||||
if game.gamemode {
|
||||
argv.push("gamemoderun".into());
|
||||
}
|
||||
argv.push("umu-run".into());
|
||||
argv.push(exe.as_os_str().to_os_string());
|
||||
for a in &game.args {
|
||||
argv.push(a.into());
|
||||
}
|
||||
let mut iter = argv.into_iter();
|
||||
let prog = iter.next().expect("argv contains at least umu-run");
|
||||
(prog, iter.collect())
|
||||
}
|
||||
|
||||
/// SIGTERM → wait 3 s → SIGKILL for a single launcher.
|
||||
pub fn kill(launcher: &Launcher) {
|
||||
kill_pattern(&launcher.process_pattern);
|
||||
}
|
||||
|
||||
/// Kill every configured launcher's processes.
|
||||
pub fn kill_all(config: &Config) {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
.args(["-f", &launcher.process_pattern])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.is_ok_and(|s| s.success())
|
||||
}
|
||||
|
||||
+411
-14
@@ -1,18 +1,27 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod config;
|
||||
mod detect;
|
||||
mod diagnose;
|
||||
mod gui;
|
||||
mod launcher;
|
||||
mod proton;
|
||||
mod autostart;
|
||||
mod setup;
|
||||
mod theme;
|
||||
mod tray;
|
||||
mod util;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
use owo_colors::OwoColorize;
|
||||
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.
|
||||
/// Use `launch` in your .desktop shortcut for a direct, no-UI launch.
|
||||
#[derive(Parser)]
|
||||
#[command(name = "battlenet-manager", version, about)]
|
||||
#[command(name = "umutray", version, about)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Option<Commands>,
|
||||
@@ -23,14 +32,61 @@ 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,
|
||||
/// Launch a configured launcher
|
||||
Launch {
|
||||
/// Launcher name (e.g. battlenet, eaapp, epic)
|
||||
name: String,
|
||||
},
|
||||
|
||||
/// Gracefully kill all Battle.net / Wine processes
|
||||
Kill,
|
||||
/// Kill a specific launcher, or every configured one if no name is given
|
||||
Kill {
|
||||
/// Launcher name (omit to kill all)
|
||||
name: Option<String>,
|
||||
},
|
||||
|
||||
/// Check setup health and report any problems
|
||||
Diagnose,
|
||||
/// Run health checks on a specific launcher, or all of them
|
||||
Diagnose {
|
||||
/// Launcher name (omit to check all)
|
||||
name: Option<String>,
|
||||
},
|
||||
|
||||
/// List configured launchers and whether they're installed / running
|
||||
Launchers,
|
||||
|
||||
/// Play a specific game through its launcher's prefix, applying the
|
||||
/// per-game overlay flags (gamemode, mangohud, gamescope).
|
||||
Play {
|
||||
/// Launcher name (e.g. battlenet)
|
||||
launcher: String,
|
||||
/// Game name (e.g. overwatch)
|
||||
game: String,
|
||||
},
|
||||
|
||||
/// List configured games per launcher
|
||||
Games {
|
||||
/// Only show games for this launcher (omit for all)
|
||||
launcher: Option<String>,
|
||||
},
|
||||
|
||||
/// Open the graphical setup wizard. Omit NAME to pick from the launcher list.
|
||||
Setup {
|
||||
/// Launcher name (e.g. battlenet). Omit to open the launcher picker.
|
||||
name: Option<String>,
|
||||
},
|
||||
|
||||
/// Open the graphical dashboard (default when launched from app menu)
|
||||
Gui,
|
||||
|
||||
/// Scan common Wine prefix locations for installed launchers
|
||||
Detect {
|
||||
/// Additional directory to scan (repeatable)
|
||||
#[arg(long, value_name = "PATH")]
|
||||
dir: Vec<PathBuf>,
|
||||
|
||||
/// Write detected prefix_dirs to config
|
||||
#[arg(long)]
|
||||
apply: bool,
|
||||
},
|
||||
|
||||
/// Download and switch GE-Proton versions
|
||||
UpdateProton {
|
||||
@@ -38,7 +94,7 @@ enum Commands {
|
||||
#[arg(long)]
|
||||
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")]
|
||||
version: Option<String>,
|
||||
|
||||
@@ -46,21 +102,362 @@ enum Commands {
|
||||
#[arg(long)]
|
||||
list: bool,
|
||||
},
|
||||
|
||||
/// Show or modify configuration
|
||||
Config {
|
||||
#[command(subcommand)]
|
||||
action: ConfigAction,
|
||||
},
|
||||
|
||||
/// Manage the XDG autostart and desktop entries
|
||||
Autostart {
|
||||
#[command(subcommand)]
|
||||
action: AutostartAction,
|
||||
},
|
||||
}
|
||||
|
||||
#[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>,
|
||||
},
|
||||
/// Add a new launcher to the config
|
||||
AddLauncher {
|
||||
/// Short CLI name (e.g. "heroic")
|
||||
name: String,
|
||||
|
||||
/// Windows exe path relative to drive_c/ (e.g. "Program Files/Foo/foo.exe")
|
||||
#[arg(long, value_name = "PATH")]
|
||||
exe_path: PathBuf,
|
||||
|
||||
/// Display name for menus (defaults to NAME)
|
||||
#[arg(long)]
|
||||
display: Option<String>,
|
||||
|
||||
/// Wine prefix dir (defaults to ~/Games/NAME)
|
||||
#[arg(long, value_name = "PATH")]
|
||||
prefix_dir: Option<PathBuf>,
|
||||
|
||||
/// umu GAMEID (defaults to "umu-NAME")
|
||||
#[arg(long)]
|
||||
gameid: Option<String>,
|
||||
|
||||
/// pgrep -f regex (defaults to escaped exe basename)
|
||||
#[arg(long)]
|
||||
process_pattern: Option<String>,
|
||||
|
||||
/// Optional installer URL
|
||||
#[arg(long)]
|
||||
installer_url: Option<String>,
|
||||
},
|
||||
/// Remove a launcher from the config (leaves its prefix on disk)
|
||||
RemoveLauncher {
|
||||
/// Short CLI name
|
||||
name: String,
|
||||
},
|
||||
/// Add a game under an existing launcher
|
||||
AddGame {
|
||||
/// Launcher that owns this game
|
||||
launcher: String,
|
||||
|
||||
/// Short CLI name for the game (e.g. "overwatch")
|
||||
name: String,
|
||||
|
||||
/// Game exe path relative to drive_c/
|
||||
#[arg(long, value_name = "PATH")]
|
||||
exe_path: PathBuf,
|
||||
|
||||
/// Display name (defaults to NAME)
|
||||
#[arg(long)]
|
||||
display: Option<String>,
|
||||
|
||||
/// Wrap the game in gamemoderun
|
||||
#[arg(long)]
|
||||
gamemode: bool,
|
||||
|
||||
/// Set MANGOHUD=1 for the game
|
||||
#[arg(long)]
|
||||
mangohud: bool,
|
||||
|
||||
/// Enable gamescope with these args (space-separated, e.g. "-f -W 2560")
|
||||
#[arg(long, value_name = "ARGS")]
|
||||
gamescope: Option<String>,
|
||||
},
|
||||
/// Remove a game from a launcher
|
||||
RemoveGame { launcher: String, name: String },
|
||||
/// Toggle per-game overlay flags
|
||||
SetGameFlags {
|
||||
launcher: String,
|
||||
name: String,
|
||||
|
||||
/// true / false — wrap in gamemoderun
|
||||
#[arg(long, value_name = "BOOL")]
|
||||
gamemode: Option<bool>,
|
||||
|
||||
/// true / false — set MANGOHUD=1
|
||||
#[arg(long, value_name = "BOOL")]
|
||||
mangohud: Option<bool>,
|
||||
|
||||
/// Enable gamescope with these args (space-separated)
|
||||
#[arg(long, value_name = "ARGS", conflicts_with = "no_gamescope")]
|
||||
gamescope: Option<String>,
|
||||
|
||||
/// Disable gamescope wrapping
|
||||
#[arg(long)]
|
||||
no_gamescope: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum AutostartAction {
|
||||
/// Write ~/.config/autostart/umutray.desktop so the tray starts on login (includes app menu entry)
|
||||
Install,
|
||||
/// Remove the autostart entry and app menu entry
|
||||
Uninstall,
|
||||
/// Show whether the XDG autostart entry is present
|
||||
Status,
|
||||
/// Install only the app menu entry
|
||||
InstallDesktop,
|
||||
/// Remove the app menu entry
|
||||
UninstallDesktop,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let config = config::Config::load()?;
|
||||
|
||||
// Ensure the SVG icon is present in the XDG icon theme so the tray
|
||||
// and desktop entries can find it without a separate install step.
|
||||
autostart::ensure_icon();
|
||||
|
||||
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 } => {
|
||||
|
||||
Commands::Launch { name } => {
|
||||
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 { "✓".green().bold().to_string() } else { "·".to_string() };
|
||||
let state = if running { " (running)" } else { "" };
|
||||
println!(" {marker} {:12} {}{}", l.name, l.display, state);
|
||||
}
|
||||
}
|
||||
|
||||
Commands::Play {
|
||||
launcher: lname,
|
||||
game: gname,
|
||||
} => {
|
||||
let l = config.find(&lname).ok_or_else(|| {
|
||||
anyhow::anyhow!("unknown launcher '{lname}' — try `umutray launchers`")
|
||||
})?;
|
||||
let g = l.find_game(&gname).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"launcher '{lname}' has no game named '{gname}' — try `umutray games {lname}`"
|
||||
)
|
||||
})?;
|
||||
launcher::play_game(&config, l, g)?;
|
||||
}
|
||||
|
||||
Commands::Games { launcher: lname } => {
|
||||
let launchers: Vec<&config::Launcher> = match &lname {
|
||||
Some(n) => vec![config.find(n).ok_or_else(|| {
|
||||
anyhow::anyhow!("unknown launcher '{n}' — try `umutray launchers`")
|
||||
})?],
|
||||
None => config.launchers.iter().collect(),
|
||||
};
|
||||
for l in launchers {
|
||||
if l.games.is_empty() {
|
||||
println!(" {}: (no games)", l.display);
|
||||
continue;
|
||||
}
|
||||
println!(" {}:", l.display);
|
||||
for g in &l.games {
|
||||
let installed = g.full_exe_path(l).exists();
|
||||
let marker = if installed { "✓".green().bold().to_string() } else { "·".to_string() };
|
||||
let flags = format_game_flags(g);
|
||||
println!(" {marker} {:14} {}{}", g.name, g.display, flags);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Commands::Setup { name } => match name {
|
||||
None => setup::run_new(&config)?,
|
||||
Some(n) => {
|
||||
let l = config.find(&n).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"unknown launcher '{n}' — try `umutray setup` to add it first"
|
||||
)
|
||||
})?;
|
||||
setup::run(&config, l)?;
|
||||
}
|
||||
},
|
||||
|
||||
Commands::Gui => {
|
||||
// Start the tray icon immediately alongside the GUI.
|
||||
let tray_handle = tray::spawn(&config);
|
||||
|
||||
match gui::run(&config)? {
|
||||
gui::CloseAction::Quit => {
|
||||
tray_handle.shutdown();
|
||||
}
|
||||
gui::CloseAction::MinimizeToTray => {
|
||||
// GUI closed, tray keeps running. Block until killed.
|
||||
loop {
|
||||
std::thread::sleep(std::time::Duration::from_secs(60));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Commands::Detect { dir, apply } => {
|
||||
detect::run(&config, &dir, apply)?;
|
||||
}
|
||||
|
||||
Commands::UpdateProton {
|
||||
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)?;
|
||||
}
|
||||
ConfigAction::AddLauncher {
|
||||
name,
|
||||
exe_path,
|
||||
display,
|
||||
prefix_dir,
|
||||
gameid,
|
||||
process_pattern,
|
||||
installer_url,
|
||||
} => {
|
||||
let mut c = config;
|
||||
c.add_launcher(
|
||||
name,
|
||||
display,
|
||||
exe_path,
|
||||
prefix_dir,
|
||||
gameid,
|
||||
process_pattern,
|
||||
installer_url,
|
||||
)?;
|
||||
}
|
||||
ConfigAction::RemoveLauncher { name } => {
|
||||
let mut c = config;
|
||||
c.remove_launcher(&name)?;
|
||||
}
|
||||
ConfigAction::AddGame {
|
||||
launcher,
|
||||
name,
|
||||
exe_path,
|
||||
display,
|
||||
gamemode,
|
||||
mangohud,
|
||||
gamescope,
|
||||
} => {
|
||||
let mut c = config;
|
||||
let gs =
|
||||
gamescope.map(|s| s.split_whitespace().map(String::from).collect::<Vec<_>>());
|
||||
c.add_game(&launcher, name, display, exe_path, gamemode, mangohud, gs)?;
|
||||
}
|
||||
ConfigAction::RemoveGame { launcher, name } => {
|
||||
let mut c = config;
|
||||
c.remove_game(&launcher, &name)?;
|
||||
}
|
||||
ConfigAction::SetGameFlags {
|
||||
launcher,
|
||||
name,
|
||||
gamemode,
|
||||
mangohud,
|
||||
gamescope,
|
||||
no_gamescope,
|
||||
} => {
|
||||
let gs_update = if no_gamescope {
|
||||
config::GamescopeUpdate::Disable
|
||||
} else if let Some(s) = gamescope {
|
||||
config::GamescopeUpdate::Enable(
|
||||
s.split_whitespace().map(String::from).collect(),
|
||||
)
|
||||
} else {
|
||||
config::GamescopeUpdate::Unchanged
|
||||
};
|
||||
let mut c = config;
|
||||
c.set_game_flags(&launcher, &name, gamemode, mangohud, gs_update)?;
|
||||
}
|
||||
},
|
||||
|
||||
Commands::Autostart { action } => match action {
|
||||
AutostartAction::Install => autostart::install()?,
|
||||
AutostartAction::Uninstall => autostart::uninstall()?,
|
||||
AutostartAction::Status => autostart::status()?,
|
||||
AutostartAction::InstallDesktop => autostart::install_desktop()?,
|
||||
AutostartAction::UninstallDesktop => autostart::uninstall_desktop()?,
|
||||
},
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn format_game_flags(g: &config::Game) -> String {
|
||||
let mut tags: Vec<&str> = Vec::new();
|
||||
if g.gamemode {
|
||||
tags.push("gamemode");
|
||||
}
|
||||
if g.mangohud {
|
||||
tags.push("mangohud");
|
||||
}
|
||||
if g.gamescope.is_some() {
|
||||
tags.push("gamescope");
|
||||
}
|
||||
if tags.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" [{}]", tags.join(", "))
|
||||
}
|
||||
}
|
||||
|
||||
+128
-18
@@ -1,10 +1,12 @@
|
||||
use crate::config::Config;
|
||||
use anyhow::{Context, Result};
|
||||
use owo_colors::OwoColorize;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashSet;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const GITHUB_API: &str =
|
||||
"https://api.github.com/repos/GloriousEggroll/proton-ge-custom/releases";
|
||||
const GITHUB_API: &str = "https://api.github.com/repos/GloriousEggroll/proton-ge-custom/releases";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Release {
|
||||
@@ -20,7 +22,7 @@ struct Asset {
|
||||
|
||||
fn http_client() -> Result<reqwest::blocking::Client> {
|
||||
reqwest::blocking::Client::builder()
|
||||
.user_agent("battlenet-manager/0.1")
|
||||
.user_agent("umutray/0.1")
|
||||
.build()
|
||||
.context("Failed to build HTTP client")
|
||||
}
|
||||
@@ -31,6 +33,8 @@ fn fetch_releases(count: usize) -> Result<Vec<Release>> {
|
||||
.get(&url)
|
||||
.send()
|
||||
.context("GitHub API request failed")?
|
||||
.error_for_status()
|
||||
.context("GitHub API returned an error (rate limited?)")?
|
||||
.json()
|
||||
.context("Failed to parse GitHub releases JSON")?;
|
||||
Ok(releases)
|
||||
@@ -42,6 +46,8 @@ fn fetch_release(tag: &str) -> Result<Release> {
|
||||
.get(&url)
|
||||
.send()
|
||||
.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()
|
||||
.context("Failed to parse release JSON")?;
|
||||
Ok(release)
|
||||
@@ -51,7 +57,7 @@ fn fetch_release(tag: &str) -> Result<Release> {
|
||||
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:?}");
|
||||
println!("{tag} is already installed at {}", install_path.display());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -65,26 +71,30 @@ fn install_version(config: &Config, tag: &str) -> Result<()> {
|
||||
.with_context(|| format!("No .tar.gz asset found for {tag}"))?;
|
||||
|
||||
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)
|
||||
.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")?;
|
||||
.error_for_status()
|
||||
.context("Download returned an error status")?;
|
||||
let total = resp.content_length();
|
||||
let f = std::fs::File::create(&tmp_path)
|
||||
.with_context(|| format!("Failed to create temp file {}", tmp_path.display()))?;
|
||||
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.display());
|
||||
std::fs::create_dir_all(&config.proton_compat_dir)?;
|
||||
|
||||
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)
|
||||
.status()
|
||||
.context("Failed to run tar")?;
|
||||
@@ -99,6 +109,54 @@ fn install_version(config: &Config, tag: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return all GE-Proton* directories found in `dir`.
|
||||
fn scan_ge_proton_in(dir: &Path, seen: &mut HashSet<String>, out: &mut Vec<String>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
for entry in entries.flatten() {
|
||||
if !entry.file_type().is_ok_and(|t| t.is_dir()) {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with("GE-Proton") && seen.insert(name.clone()) {
|
||||
out.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return all GE-Proton versions found in `dir`, newest-first,
|
||||
/// prepended with "GE-Proton (latest)".
|
||||
pub fn list_installed_from(dir: PathBuf) -> Vec<String> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut versions = Vec::new();
|
||||
scan_ge_proton_in(&dir, &mut seen, &mut versions);
|
||||
versions.sort_by(|a, b| b.cmp(a));
|
||||
let mut out = vec!["GE-Proton (latest)".to_string()];
|
||||
out.extend(versions);
|
||||
out
|
||||
}
|
||||
|
||||
/// Return all GE-Proton versions found across the configured compat dir and
|
||||
/// common system Proton locations (Steam, ProtonUp-Qt, /usr/share/steam).
|
||||
pub fn list_installed(config: &Config) -> Vec<String> {
|
||||
let mut dirs = vec![config.proton_compat_dir.clone()];
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
// ProtonUp-Qt and manual installs land here by default
|
||||
dirs.push(home.join(".steam/root/compatibilitytools.d"));
|
||||
dirs.push(home.join(".local/share/Steam/compatibilitytools.d"));
|
||||
}
|
||||
dirs.push(PathBuf::from("/usr/share/steam/compatibilitytools.d"));
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
let mut versions = Vec::new();
|
||||
for dir in &dirs {
|
||||
scan_ge_proton_in(dir, &mut seen, &mut versions);
|
||||
}
|
||||
versions.sort_by(|a, b| b.cmp(a));
|
||||
let mut out = vec!["GE-Proton (latest)".to_string()];
|
||||
out.extend(versions);
|
||||
out
|
||||
}
|
||||
|
||||
/// Install the latest GE-Proton release (called from tray menu).
|
||||
pub fn install_latest(config: &Config) -> Result<()> {
|
||||
println!("Checking for latest GE-Proton...");
|
||||
@@ -145,19 +203,71 @@ fn print_list(config: &Config) -> Result<()> {
|
||||
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 { "" };
|
||||
let marker = if installed { format!(" {}", "✓ installed".green().bold()) } else { String::new() };
|
||||
println!(" {}{}", r.tag_name, marker);
|
||||
}
|
||||
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> {
|
||||
let releases = fetch_releases(10)?;
|
||||
if releases.is_empty() {
|
||||
anyhow::bail!("GitHub returned no GE-Proton releases");
|
||||
}
|
||||
|
||||
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 { "" };
|
||||
let marker = if installed { format!(" {}", "✓".green().bold()) } else { String::new() };
|
||||
println!(" {:2}) {}{}", i + 1, r.tag_name, marker);
|
||||
}
|
||||
|
||||
|
||||
+1021
File diff suppressed because it is too large
Load Diff
+208
@@ -0,0 +1,208 @@
|
||||
use iced::widget::{button, container, text};
|
||||
use iced::{Background, Border, Color, Element, Shadow, Theme, Vector};
|
||||
|
||||
// ── Palette ────────────────────────────────────────────────────────────────
|
||||
|
||||
pub const ACCENT: Color = Color {
|
||||
r: 0.49,
|
||||
g: 0.55,
|
||||
b: 0.97,
|
||||
a: 1.0,
|
||||
};
|
||||
pub const GREEN: Color = Color {
|
||||
r: 0.29,
|
||||
g: 0.87,
|
||||
b: 0.50,
|
||||
a: 1.0,
|
||||
};
|
||||
pub const RED: Color = Color {
|
||||
r: 0.97,
|
||||
g: 0.44,
|
||||
b: 0.44,
|
||||
a: 1.0,
|
||||
};
|
||||
pub const MUTED: Color = Color {
|
||||
r: 0.42,
|
||||
g: 0.44,
|
||||
b: 0.50,
|
||||
a: 1.0,
|
||||
};
|
||||
pub const DIM: Color = Color {
|
||||
r: 0.55,
|
||||
g: 0.58,
|
||||
b: 0.64,
|
||||
a: 1.0,
|
||||
};
|
||||
pub const SURFACE: Color = Color {
|
||||
r: 0.12,
|
||||
g: 0.13,
|
||||
b: 0.16,
|
||||
a: 1.0,
|
||||
};
|
||||
pub const SURFACE_RAISED: Color = Color {
|
||||
r: 0.15,
|
||||
g: 0.16,
|
||||
b: 0.19,
|
||||
a: 1.0,
|
||||
};
|
||||
pub const BORDER_CLR: Color = Color {
|
||||
r: 0.20,
|
||||
g: 0.21,
|
||||
b: 0.26,
|
||||
a: 1.0,
|
||||
};
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Bootstrap-icon helper — keeps call sites tidy.
|
||||
pub fn icon(codepoint: &str, size: u32) -> iced::widget::Text<'static> {
|
||||
text(codepoint.to_owned())
|
||||
.font(iced::Font::with_name("bootstrap-icons"))
|
||||
.size(size)
|
||||
}
|
||||
|
||||
/// Styled section heading (uppercase, dimmed).
|
||||
pub fn section_heading<'a, M: 'a>(label: &str) -> Element<'a, M> {
|
||||
text(label.to_uppercase())
|
||||
.size(10)
|
||||
.style(move |_: &Theme| text::Style { color: Some(DIM) })
|
||||
.into()
|
||||
}
|
||||
|
||||
// ── Button styles ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Accent-filled primary button style.
|
||||
pub fn btn_accent(_theme: &Theme, status: button::Status) -> button::Style {
|
||||
let (bg, fg) = match status {
|
||||
button::Status::Active => (ACCENT, Color::WHITE),
|
||||
button::Status::Hovered => (Color { a: 0.85, ..ACCENT }, Color::WHITE),
|
||||
_ => (
|
||||
Color { a: 0.5, ..ACCENT },
|
||||
Color {
|
||||
a: 0.7,
|
||||
..Color::WHITE
|
||||
},
|
||||
),
|
||||
};
|
||||
button::Style {
|
||||
background: Some(Background::Color(bg)),
|
||||
text_color: fg,
|
||||
border: Border {
|
||||
color: Color::TRANSPARENT,
|
||||
width: 0.0,
|
||||
radius: 8.0.into(),
|
||||
},
|
||||
shadow: NO_SHADOW,
|
||||
snap: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub const NO_SHADOW: Shadow = Shadow {
|
||||
color: Color::TRANSPARENT,
|
||||
offset: Vector::ZERO,
|
||||
blur_radius: 0.0,
|
||||
};
|
||||
|
||||
/// Ghost / outline secondary button style.
|
||||
pub fn btn_ghost(_theme: &Theme, status: button::Status) -> button::Style {
|
||||
let (bg, border_a) = match status {
|
||||
button::Status::Hovered => (
|
||||
Color { r: 0.22, g: 0.23, b: 0.28, a: 1.0 },
|
||||
0.40,
|
||||
),
|
||||
button::Status::Pressed => (
|
||||
Color { r: 0.25, g: 0.26, b: 0.31, a: 1.0 },
|
||||
0.50,
|
||||
),
|
||||
_ => (
|
||||
Color { r: 0.18, g: 0.19, b: 0.23, a: 1.0 },
|
||||
0.25,
|
||||
),
|
||||
};
|
||||
button::Style {
|
||||
background: Some(Background::Color(bg)),
|
||||
text_color: Color {
|
||||
r: 0.78,
|
||||
g: 0.80,
|
||||
b: 0.85,
|
||||
a: 1.0,
|
||||
},
|
||||
border: Border {
|
||||
color: Color { a: border_a, ..BORDER_CLR },
|
||||
width: 1.0,
|
||||
radius: 8.0.into(),
|
||||
},
|
||||
shadow: NO_SHADOW,
|
||||
snap: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Red danger button style.
|
||||
pub fn btn_danger(_theme: &Theme, status: button::Status) -> button::Style {
|
||||
let (bg, fg) = match status {
|
||||
button::Status::Active => (RED, Color::WHITE),
|
||||
button::Status::Hovered => (Color { a: 0.85, ..RED }, Color::WHITE),
|
||||
_ => (
|
||||
Color { a: 0.5, ..RED },
|
||||
Color {
|
||||
a: 0.7,
|
||||
..Color::WHITE
|
||||
},
|
||||
),
|
||||
};
|
||||
button::Style {
|
||||
background: Some(Background::Color(bg)),
|
||||
text_color: fg,
|
||||
border: Border {
|
||||
color: Color::TRANSPARENT,
|
||||
width: 0.0,
|
||||
radius: 8.0.into(),
|
||||
},
|
||||
shadow: NO_SHADOW,
|
||||
snap: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Container styles ───────────────────────────────────────────────────────
|
||||
|
||||
/// Full-window background style.
|
||||
pub fn surface_bg(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(Background::Color(SURFACE)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Raised card style (12px radius, 1px border).
|
||||
pub fn card_style(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(Background::Color(SURFACE_RAISED)),
|
||||
border: Border {
|
||||
color: BORDER_CLR,
|
||||
width: 1.0,
|
||||
radius: 12.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Inner sub-card style (darker background, subtle border, 8px radius).
|
||||
pub fn sub_card_style(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(Background::Color(Color {
|
||||
r: 0.11,
|
||||
g: 0.12,
|
||||
b: 0.15,
|
||||
a: 0.8,
|
||||
})),
|
||||
border: Border {
|
||||
color: Color {
|
||||
a: 0.15,
|
||||
..BORDER_CLR
|
||||
},
|
||||
width: 1.0,
|
||||
radius: 8.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
+275
-62
@@ -1,83 +1,173 @@
|
||||
use crate::{config::Config, launcher};
|
||||
use anyhow::Result;
|
||||
use std::collections::HashMap;
|
||||
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,
|
||||
fn spawn_setup(config: &Config, name: &str) {
|
||||
let config = config.clone();
|
||||
let name = name.to_owned();
|
||||
thread::spawn(move || {
|
||||
if let Some(l) = config.find(&name) {
|
||||
let l = l.clone();
|
||||
if let Err(e) = crate::setup::run(&config, &l) {
|
||||
eprintln!("umutray: setup for {name} failed: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
impl ksni::Tray for BattlenetTray {
|
||||
fn spawn_gui(config: &Config) {
|
||||
let config = config.clone();
|
||||
thread::spawn(move || {
|
||||
match crate::gui::run(&config) {
|
||||
Ok(_) => {}
|
||||
Err(e) => eprintln!("umutray: failed to launch dashboard: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_setup_picker(config: &Config) {
|
||||
let config = config.clone();
|
||||
thread::spawn(move || {
|
||||
if let Err(e) = crate::setup::run_new(&config) {
|
||||
eprintln!("umutray: failed to launch setup picker: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
enum GameFlag {
|
||||
GameMode,
|
||||
MangoHud,
|
||||
Gamescope,
|
||||
}
|
||||
|
||||
fn toggle_flag(this: &mut UmuTray, launcher: &str, game: &str, flag: GameFlag) {
|
||||
for l in this.config.launchers.iter_mut() {
|
||||
if l.name != launcher {
|
||||
continue;
|
||||
}
|
||||
for g in l.games.iter_mut() {
|
||||
if g.name != game {
|
||||
continue;
|
||||
}
|
||||
match flag {
|
||||
GameFlag::GameMode => g.gamemode = !g.gamemode,
|
||||
GameFlag::MangoHud => g.mangohud = !g.mangohud,
|
||||
GameFlag::Gamescope => {
|
||||
g.gamescope = if g.gamescope.is_some() {
|
||||
None
|
||||
} else {
|
||||
Some(vec![])
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(e) = this.config.save() {
|
||||
eprintln!("umutray: failed to persist flag toggle: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn play_from_tray(config: &Config, launcher: &str, game: &str) {
|
||||
let Some(l) = config.find(launcher) else {
|
||||
return;
|
||||
};
|
||||
let Some(g) = l.find_game(game) else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = crate::launcher::play_game(config, l, g) {
|
||||
eprintln!("umutray: play {game} failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
pub struct UmuTray {
|
||||
pub config: Config,
|
||||
/// Per-launcher running state keyed by launcher.name
|
||||
pub running: HashMap<String, bool>,
|
||||
/// Set after the tray spawns so Quit can shut down the SNI item
|
||||
/// cleanly instead of yanking it off the bus via exit().
|
||||
pub handle: Option<ksni::blocking::Handle<UmuTray>>,
|
||||
}
|
||||
|
||||
impl ksni::Tray for UmuTray {
|
||||
fn id(&self) -> String {
|
||||
"battlenet-manager".into()
|
||||
"umutray".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()
|
||||
}
|
||||
"umutray".into()
|
||||
}
|
||||
|
||||
fn title(&self) -> String {
|
||||
if self.running {
|
||||
"Battle.net (running)".into()
|
||||
if self.running.values().any(|&v| v) {
|
||||
"umutray (launcher running)".into()
|
||||
} else {
|
||||
"Battle.net".into()
|
||||
"umutray".into()
|
||||
}
|
||||
}
|
||||
|
||||
fn menu(&self) -> Vec<ksni::MenuItem<Self>> {
|
||||
use ksni::menu::*;
|
||||
let mut items: Vec<ksni::MenuItem<Self>> = vec![
|
||||
StandardItem {
|
||||
label: "Open Dashboard".into(),
|
||||
icon_name: "applications-games".into(),
|
||||
activate: Box::new(|this: &mut Self| spawn_gui(&this.config)),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
ksni::MenuItem::Separator,
|
||||
];
|
||||
|
||||
let mut items: Vec<ksni::MenuItem<Self>> = vec![];
|
||||
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();
|
||||
|
||||
// Status label (non-interactive)
|
||||
if !installed {
|
||||
let setup_name = name.clone();
|
||||
items.push(
|
||||
StandardItem {
|
||||
label: if self.running {
|
||||
"● Battle.net is running".into()
|
||||
} else {
|
||||
"Battle.net".into()
|
||||
},
|
||||
enabled: false,
|
||||
label: format!("Setup {display}…"),
|
||||
icon_name: "document-new".into(),
|
||||
activate: Box::new(move |this: &mut Self| {
|
||||
spawn_setup(&this.config, &setup_name);
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
items.push(ksni::MenuItem::Separator);
|
||||
|
||||
// Launch / Kill
|
||||
if self.running {
|
||||
if running {
|
||||
let kill_name = name.clone();
|
||||
items.push(
|
||||
StandardItem {
|
||||
label: "Kill Battle.net".into(),
|
||||
label: format!("Kill {display}"),
|
||||
icon_name: "process-stop".into(),
|
||||
activate: Box::new(|_this: &mut Self| {
|
||||
let _ = launcher::kill();
|
||||
activate: Box::new(move |this: &mut Self| {
|
||||
if let Some(l) = this.config.find(&kill_name) {
|
||||
launcher::kill(l);
|
||||
}
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
} else {
|
||||
let launch_name = name.clone();
|
||||
items.push(
|
||||
StandardItem {
|
||||
label: "Launch Battle.net".into(),
|
||||
label: format!("Launch {display}"),
|
||||
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}");
|
||||
activate: Box::new(move |this: &mut Self| {
|
||||
if let Some(l) = this.config.find(&launch_name) {
|
||||
if let Err(e) = launcher::launch(&this.config, l) {
|
||||
eprintln!("umutray: launch {} failed: {e}", l.name);
|
||||
}
|
||||
}
|
||||
}),
|
||||
..Default::default()
|
||||
@@ -86,9 +176,90 @@ impl ksni::Tray for BattlenetTray {
|
||||
);
|
||||
}
|
||||
|
||||
// Per-game submenus with Play + overlay toggles.
|
||||
for g in &l.games {
|
||||
let gdisplay = g.display.clone();
|
||||
let gname_play = g.name.clone();
|
||||
let lname_play = name.clone();
|
||||
let gname_gm = g.name.clone();
|
||||
let lname_gm = name.clone();
|
||||
let gname_mh = g.name.clone();
|
||||
let lname_mh = name.clone();
|
||||
let gname_gs = g.name.clone();
|
||||
let lname_gs = name.clone();
|
||||
|
||||
let mut sub: Vec<ksni::MenuItem<Self>> = Vec::new();
|
||||
sub.push(
|
||||
StandardItem {
|
||||
label: format!("Play {gdisplay}"),
|
||||
icon_name: "media-playback-start".into(),
|
||||
activate: Box::new(move |this: &mut Self| {
|
||||
play_from_tray(&this.config, &lname_play, &gname_play);
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
sub.push(ksni::MenuItem::Separator);
|
||||
sub.push(
|
||||
CheckmarkItem {
|
||||
label: "GameMode".into(),
|
||||
checked: g.gamemode,
|
||||
activate: Box::new(move |this: &mut Self| {
|
||||
toggle_flag(this, &lname_gm, &gname_gm, GameFlag::GameMode);
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
sub.push(
|
||||
CheckmarkItem {
|
||||
label: "MangoHud".into(),
|
||||
checked: g.mangohud,
|
||||
activate: Box::new(move |this: &mut Self| {
|
||||
toggle_flag(this, &lname_mh, &gname_mh, GameFlag::MangoHud);
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
sub.push(
|
||||
CheckmarkItem {
|
||||
label: "Gamescope".into(),
|
||||
checked: g.gamescope.is_some(),
|
||||
activate: Box::new(move |this: &mut Self| {
|
||||
toggle_flag(this, &lname_gs, &gname_gs, GameFlag::Gamescope);
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
|
||||
items.push(
|
||||
SubMenu {
|
||||
label: gdisplay,
|
||||
submenu: sub,
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if self.config.launchers.is_empty() {
|
||||
items.push(
|
||||
StandardItem {
|
||||
label: "Add Launcher…".into(),
|
||||
icon_name: "list-add".into(),
|
||||
activate: Box::new(|this: &mut Self| spawn_setup_picker(&this.config)),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
items.push(ksni::MenuItem::Separator);
|
||||
|
||||
// Update Proton (latest, background)
|
||||
items.push(
|
||||
StandardItem {
|
||||
label: "Update GE-Proton (latest)".into(),
|
||||
@@ -97,7 +268,7 @@ impl ksni::Tray for BattlenetTray {
|
||||
let config = this.config.clone();
|
||||
thread::spawn(move || {
|
||||
if let Err(e) = crate::proton::install_latest(&config) {
|
||||
eprintln!("battlenet-manager: proton update failed: {e}");
|
||||
eprintln!("umutray: proton update failed: {e}");
|
||||
}
|
||||
});
|
||||
}),
|
||||
@@ -108,13 +279,19 @@ impl ksni::Tray for BattlenetTray {
|
||||
|
||||
items.push(ksni::MenuItem::Separator);
|
||||
|
||||
// Quit
|
||||
items.push(
|
||||
StandardItem {
|
||||
label: "Quit".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);
|
||||
});
|
||||
} else {
|
||||
std::process::exit(0);
|
||||
}
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
@@ -127,26 +304,62 @@ impl ksni::Tray for BattlenetTray {
|
||||
|
||||
/// 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.
|
||||
let _handle = spawn(config);
|
||||
loop {
|
||||
thread::sleep(Duration::from_secs(60));
|
||||
}
|
||||
}
|
||||
|
||||
/// A handle that can shut down the tray from another thread.
|
||||
pub struct TrayHandle {
|
||||
inner: ksni::blocking::Handle<UmuTray>,
|
||||
}
|
||||
|
||||
impl TrayHandle {
|
||||
pub fn shutdown(&self) {
|
||||
let h = self.inner.clone();
|
||||
thread::spawn(move || h.shutdown());
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the tray icon in the background and return a handle to shut it down.
|
||||
pub fn spawn(config: &Config) -> TrayHandle {
|
||||
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(),
|
||||
running,
|
||||
handle: None,
|
||||
};
|
||||
|
||||
let handle = {
|
||||
use ksni::blocking::TrayMethods;
|
||||
tray.spawn().expect("Failed to spawn tray service")
|
||||
};
|
||||
|
||||
// Hand the tray a clone of its own handle so Quit can shut down cleanly.
|
||||
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.clone();
|
||||
let launchers = config.launchers.clone();
|
||||
thread::spawn(move || loop {
|
||||
let mut snapshot: HashMap<String, bool> = HashMap::new();
|
||||
for l in &launchers {
|
||||
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));
|
||||
});
|
||||
|
||||
TrayHandle { inner: handle }
|
||||
}
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/// Open a native folder picker dialog and return the chosen path, or None if
|
||||
/// the user cancelled. Uses XDG Desktop Portal where available.
|
||||
pub fn pick_folder(title: &str) -> Option<String> {
|
||||
rfd::FileDialog::new()
|
||||
.set_title(title)
|
||||
.pick_folder()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// Open a native file picker dialog starting in `start_dir`, or None if
|
||||
/// the user cancelled. Uses XDG Desktop Portal where available.
|
||||
pub fn pick_file(title: &str, start_dir: &str) -> Option<String> {
|
||||
rfd::FileDialog::new()
|
||||
.set_title(title)
|
||||
.set_directory(start_dir)
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
[Desktop Entry]
|
||||
Name=umutray
|
||||
Comment=Wine launcher manager for Windows game launchers
|
||||
Exec=umutray gui
|
||||
Icon=umutray
|
||||
Type=Application
|
||||
Categories=Game;
|
||||
Keywords=wine;proton;gaming;launcher;
|
||||
StartupNotify=true
|
||||
Reference in New Issue
Block a user