Compare commits
8 Commits
f3f5046265
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4845ebe4f8 | |||
| c1893f9f64 | |||
| 2f4f1c64d2 | |||
| 8447581fe6 | |||
| a0ee01cd5d | |||
| aeed52d6dd | |||
| 2b538a286a | |||
| e213377a95 |
@@ -1,5 +1,7 @@
|
|||||||
/target
|
/target
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.claude/
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
# Packaging build artifacts
|
# Packaging build artifacts
|
||||||
packaging/pkg/
|
packaging/pkg/
|
||||||
|
|||||||
Generated
+1030
-1245
File diff suppressed because it is too large
Load Diff
+11
-10
@@ -19,7 +19,7 @@ clap = { version = "4", features = ["derive"] }
|
|||||||
|
|
||||||
# Config serialisation
|
# Config serialisation
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
toml = "0.8"
|
toml = "1"
|
||||||
|
|
||||||
# GitHub API responses
|
# GitHub API responses
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
@@ -27,21 +27,22 @@ serde_json = "1"
|
|||||||
# Error handling
|
# Error handling
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
|
||||||
# XDG config / data paths
|
# XDG config / data / home paths
|
||||||
directories = "5"
|
dirs = "6"
|
||||||
|
|
||||||
# Home directory lookup
|
|
||||||
dirs = "5"
|
|
||||||
|
|
||||||
# Terminal colour output
|
# Terminal colour output
|
||||||
owo-colors = "4"
|
owo-colors = "4"
|
||||||
|
|
||||||
# System tray via D-Bus StatusNotifierItem (KDE, GNOME+AppIndicator, Xfce, etc.)
|
# 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
|
# 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
|
# GUI for the setup wizard
|
||||||
iced = { version = "0.13", features = ["tokio"] }
|
iced = { version = "0.14", features = ["tokio"] }
|
||||||
iced_fonts = { version = "0.1", features = ["bootstrap"] }
|
iced_fonts = { version = "0.3", features = ["bootstrap"] }
|
||||||
|
tokio = { version = "1.52.1", features = ["rt"] }
|
||||||
|
|
||||||
|
# Native file dialogs via XDG Desktop Portal
|
||||||
|
rfd = "0.17"
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ Launch / Kill entries. Users can add or remove launchers in `config edit`.
|
|||||||
(no ~600 MB in-memory buffering), with a progress indicator.
|
(no ~600 MB in-memory buffering), with a progress indicator.
|
||||||
- `diagnose` — sanity-checks umu-run, Vulkan, display server, per-launcher
|
- `diagnose` — sanity-checks umu-run, Vulkan, display server, per-launcher
|
||||||
prefix / exe / ownership / running state.
|
prefix / exe / ownership / running state.
|
||||||
- `service` — installs an XDG autostart entry so the tray autostarts with
|
- `autostart` — manages the XDG autostart entry so the tray starts with
|
||||||
the graphical session.
|
the graphical session.
|
||||||
- `setup` — graphical wizard (iced) that downloads an installer URL
|
- `setup` — graphical wizard (iced) that downloads an installer URL
|
||||||
(with progress bar) or accepts a local `.exe`, then runs it via
|
(with progress bar) or accepts a local `.exe`, then runs it via
|
||||||
@@ -51,7 +51,7 @@ sudo pacman -S umu-launcher vulkan-tools
|
|||||||
Then enable autostart:
|
Then enable autostart:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
umutray service install
|
umutray autostart install
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
@@ -79,9 +79,9 @@ umutray service install
|
|||||||
| `umutray config add-game …` | Attach a game to a launcher (needs `--exe-path`) |
|
| `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 remove-game …` | Drop a game from a launcher |
|
||||||
| `umutray config set-game-flags …`| Per-game overlay toggles: gamemode/mangohud/gamescope |
|
| `umutray config set-game-flags …`| Per-game overlay toggles: gamemode/mangohud/gamescope |
|
||||||
| `umutray service install` | Write XDG autostart entry (tray starts on login) |
|
| `umutray autostart install` | Write XDG autostart entry (tray starts on login) |
|
||||||
| `umutray service uninstall` | Remove the autostart and desktop entries |
|
| `umutray autostart uninstall` | Remove the autostart and desktop entries |
|
||||||
| `umutray service status` | Show whether XDG autostart is enabled |
|
| `umutray autostart status` | Show whether XDG autostart is enabled |
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
+4
-3
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
pkgname=umutray
|
pkgname=umutray
|
||||||
pkgver=0.1.0
|
pkgver=0.1.0
|
||||||
pkgrel=4
|
pkgrel=6
|
||||||
pkgdesc='Tray-based Wine launcher manager for Linux via umu/Proton-GE'
|
pkgdesc='Tray-based Wine launcher manager for Linux via umu/Proton-GE'
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
url='https://git.aleshym.co/funman300/umutray'
|
url='https://git.aleshym.co/funman300/umutray'
|
||||||
@@ -20,8 +20,6 @@ license=('MIT')
|
|||||||
depends=('umu-launcher')
|
depends=('umu-launcher')
|
||||||
makedepends=('rust' 'cargo')
|
makedepends=('rust' 'cargo')
|
||||||
optdepends=(
|
optdepends=(
|
||||||
'zenity: folder picker in setup wizard (GNOME/GTK)'
|
|
||||||
'kdialog: folder picker in setup wizard (KDE)'
|
|
||||||
'gamemode: per-game GameMode support'
|
'gamemode: per-game GameMode support'
|
||||||
'mangohud: per-game MangoHud overlay'
|
'mangohud: per-game MangoHud overlay'
|
||||||
'gamescope: per-game Gamescope compositor'
|
'gamescope: per-game Gamescope compositor'
|
||||||
@@ -54,6 +52,9 @@ package() {
|
|||||||
# App menu entry
|
# App menu entry
|
||||||
install -Dm644 umutray.desktop "$pkgdir/usr/share/applications/umutray.desktop"
|
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
|
# License
|
||||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use owo_colors::OwoColorize;
|
use owo_colors::OwoColorize;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
const DESKTOP_NAME: &str = "umutray.desktop";
|
const DESKTOP_NAME: &str = "umutray.desktop";
|
||||||
|
const ICON_SVG: &[u8] = include_bytes!("../assets/umutray.svg");
|
||||||
|
|
||||||
fn home() -> Result<PathBuf> {
|
fn home() -> Result<PathBuf> {
|
||||||
dirs::home_dir().context("Cannot determine home directory")
|
dirs::home_dir().context("Cannot determine home directory")
|
||||||
@@ -17,42 +18,82 @@ fn desktop_path() -> Result<PathBuf> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_desktop(exe: &std::path::Path, autostart: bool) -> String {
|
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!(
|
let mut s = format!(
|
||||||
"[Desktop Entry]\n\
|
"[Desktop Entry]\n\
|
||||||
Name=umutray\n\
|
Name=umutray\n\
|
||||||
Comment=Wine launcher manager for Windows game launchers\n\
|
Comment=Wine launcher manager for Windows game launchers\n\
|
||||||
Exec={exe}\n\
|
Exec={exec}\n\
|
||||||
Icon=applications-games\n\
|
Icon=umutray\n\
|
||||||
Type=Application\n\
|
Type=Application\n\
|
||||||
Categories=Game;\n\
|
Categories=Game;\n\
|
||||||
Keywords=wine;proton;gaming;launcher;\n\
|
Keywords=wine;proton;gaming;launcher;\n\
|
||||||
StartupNotify=false\n",
|
StartupNotify=false\n",
|
||||||
exe = exe.display(),
|
|
||||||
);
|
);
|
||||||
if autostart {
|
if autostart {
|
||||||
s.push_str("X-GNOME-Autostart-enabled=true\n");
|
s.push_str("X-GNOME-Autostart-enabled=true\n");
|
||||||
s.push_str("Hidden=false\n");
|
s.push_str("Hidden=false\n");
|
||||||
} else {
|
} else {
|
||||||
// App-menu entry launches the GUI
|
|
||||||
s = s.replace(
|
|
||||||
&format!("Exec={}", exe.display()),
|
|
||||||
&format!("Exec={} gui", exe.display()),
|
|
||||||
);
|
|
||||||
s.push_str("StartupNotify=true\n");
|
s.push_str("StartupNotify=true\n");
|
||||||
}
|
}
|
||||||
s
|
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.
|
/// Install only the .desktop file so umutray appears in the app menu.
|
||||||
/// Called automatically on first `umutray gui` run.
|
/// Called automatically on first `umutray gui` run.
|
||||||
pub fn install_desktop() -> Result<()> {
|
pub fn install_desktop() -> Result<()> {
|
||||||
let exe = std::env::current_exe().context("Cannot determine path to own executable")?;
|
let exe = std::env::current_exe().context("Cannot determine path to own executable")?;
|
||||||
let desktop = desktop_path()?;
|
let desktop = desktop_path()?;
|
||||||
if let Some(p) = desktop.parent() {
|
write_file(&desktop, render_desktop(&exe, false))?;
|
||||||
std::fs::create_dir_all(p).with_context(|| format!("Failed to create {p:?}"))?;
|
install_icon()?;
|
||||||
}
|
|
||||||
std::fs::write(&desktop, render_desktop(&exe, false))
|
|
||||||
.with_context(|| format!("Failed to write desktop file {desktop:?}"))?;
|
|
||||||
println!("{} App menu entry written: {}", "✓".green().bold(), desktop.display());
|
println!("{} App menu entry written: {}", "✓".green().bold(), desktop.display());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -61,8 +102,7 @@ pub fn install_desktop() -> Result<()> {
|
|||||||
pub fn uninstall_desktop() -> Result<()> {
|
pub fn uninstall_desktop() -> Result<()> {
|
||||||
let desktop = desktop_path()?;
|
let desktop = desktop_path()?;
|
||||||
if desktop.exists() {
|
if desktop.exists() {
|
||||||
std::fs::remove_file(&desktop)
|
remove_file(&desktop)?;
|
||||||
.with_context(|| format!("Failed to remove {desktop:?}"))?;
|
|
||||||
println!("Removed {}", desktop.display());
|
println!("Removed {}", desktop.display());
|
||||||
} else {
|
} else {
|
||||||
println!("No desktop file at {}", desktop.display());
|
println!("No desktop file at {}", desktop.display());
|
||||||
@@ -76,11 +116,7 @@ pub fn install() -> Result<()> {
|
|||||||
|
|
||||||
// XDG autostart
|
// XDG autostart
|
||||||
let autostart = autostart_path()?;
|
let autostart = autostart_path()?;
|
||||||
if let Some(p) = autostart.parent() {
|
write_file(&autostart, render_desktop(&exe, true))?;
|
||||||
std::fs::create_dir_all(p).with_context(|| format!("Failed to create {p:?}"))?;
|
|
||||||
}
|
|
||||||
std::fs::write(&autostart, render_desktop(&exe, true))
|
|
||||||
.with_context(|| format!("Failed to write autostart file {autostart:?}"))?;
|
|
||||||
println!("Wrote autostart: {}", autostart.display());
|
println!("Wrote autostart: {}", autostart.display());
|
||||||
|
|
||||||
// App-menu entry
|
// App-menu entry
|
||||||
@@ -97,14 +133,14 @@ pub fn install() -> Result<()> {
|
|||||||
pub fn uninstall() -> Result<()> {
|
pub fn uninstall() -> Result<()> {
|
||||||
let autostart = autostart_path()?;
|
let autostart = autostart_path()?;
|
||||||
if autostart.exists() {
|
if autostart.exists() {
|
||||||
std::fs::remove_file(&autostart)
|
remove_file(&autostart)?;
|
||||||
.with_context(|| format!("Failed to remove {autostart:?}"))?;
|
|
||||||
println!("Removed {}", autostart.display());
|
println!("Removed {}", autostart.display());
|
||||||
} else {
|
} else {
|
||||||
println!("No autostart file at {}", autostart.display());
|
println!("No autostart file at {}", autostart.display());
|
||||||
}
|
}
|
||||||
|
|
||||||
uninstall_desktop()?;
|
uninstall_desktop()?;
|
||||||
|
uninstall_icon()?;
|
||||||
|
|
||||||
println!("{} Autostart removed.", "✓".green().bold());
|
println!("{} Autostart removed.", "✓".green().bold());
|
||||||
Ok(())
|
Ok(())
|
||||||
+22
-11
@@ -1,9 +1,19 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use directories::ProjectDirs;
|
|
||||||
use owo_colors::OwoColorize;
|
use owo_colors::OwoColorize;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Launcher {
|
pub struct Launcher {
|
||||||
/// Short CLI name (e.g. "battlenet").
|
/// Short CLI name (e.g. "battlenet").
|
||||||
@@ -223,9 +233,9 @@ impl Default for Config {
|
|||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub fn config_path() -> Result<PathBuf> {
|
pub fn config_path() -> Result<PathBuf> {
|
||||||
let dirs = ProjectDirs::from("co.aleshym", "", "umutray")
|
let config_dir = dirs::config_dir()
|
||||||
.context("Could not determine config directory")?;
|
.context("Could not determine config directory")?;
|
||||||
Ok(dirs.config_dir().join("config.toml"))
|
Ok(config_dir.join("umutray").join("config.toml"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load() -> Result<Self> {
|
pub fn load() -> Result<Self> {
|
||||||
@@ -236,7 +246,7 @@ impl Config {
|
|||||||
return Ok(c);
|
return Ok(c);
|
||||||
}
|
}
|
||||||
let content = std::fs::read_to_string(&path)
|
let content = std::fs::read_to_string(&path)
|
||||||
.with_context(|| format!("Failed to read config from {path:?}"))?;
|
.with_context(|| format!("Failed to read config from {}", path.display()))?;
|
||||||
match toml::from_str::<Self>(&content) {
|
match toml::from_str::<Self>(&content) {
|
||||||
Ok(c) => {
|
Ok(c) => {
|
||||||
Ok(c)
|
Ok(c)
|
||||||
@@ -244,7 +254,7 @@ impl Config {
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
let bak = path.with_extension("toml.bak");
|
let bak = path.with_extension("toml.bak");
|
||||||
std::fs::rename(&path, &bak)
|
std::fs::rename(&path, &bak)
|
||||||
.with_context(|| format!("Failed to back up stale config to {bak:?}"))?;
|
.with_context(|| format!("Failed to back up stale config to {}", bak.display()))?;
|
||||||
eprintln!("warning: couldn't parse {}: {e}", path.display());
|
eprintln!("warning: couldn't parse {}: {e}", path.display());
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" backed up to {} — writing fresh config with presets",
|
" backed up to {} — writing fresh config with presets",
|
||||||
@@ -264,7 +274,7 @@ impl Config {
|
|||||||
}
|
}
|
||||||
let content = toml::to_string_pretty(self)?;
|
let content = toml::to_string_pretty(self)?;
|
||||||
std::fs::write(&path, content)
|
std::fs::write(&path, content)
|
||||||
.with_context(|| format!("Failed to write config to {path:?}"))
|
.with_context(|| format!("Failed to write config to {}", path.display()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn find(&self, name: &str) -> Option<&Launcher> {
|
pub fn find(&self, name: &str) -> Option<&Launcher> {
|
||||||
@@ -386,16 +396,15 @@ impl Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Update per-game overlay flags. Each arg is `None` = leave as-is.
|
/// Update per-game overlay flags. Each arg is `None` = leave as-is.
|
||||||
/// `gamescope = Some(None)` disables it; `Some(Some(vec))` enables with args.
|
|
||||||
pub fn set_game_flags(
|
pub fn set_game_flags(
|
||||||
&mut self,
|
&mut self,
|
||||||
launcher: &str,
|
launcher: &str,
|
||||||
name: &str,
|
name: &str,
|
||||||
gamemode: Option<bool>,
|
gamemode: Option<bool>,
|
||||||
mangohud: Option<bool>,
|
mangohud: Option<bool>,
|
||||||
gamescope: Option<Option<Vec<String>>>,
|
gamescope: GamescopeUpdate,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if gamemode.is_none() && mangohud.is_none() && gamescope.is_none() {
|
if gamemode.is_none() && mangohud.is_none() && matches!(gamescope, GamescopeUpdate::Unchanged) {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"nothing to set — pass --gamemode, --mangohud, --gamescope, or --no-gamescope"
|
"nothing to set — pass --gamemode, --mangohud, --gamescope, or --no-gamescope"
|
||||||
);
|
);
|
||||||
@@ -415,8 +424,10 @@ impl Config {
|
|||||||
if let Some(v) = mangohud {
|
if let Some(v) = mangohud {
|
||||||
g.mangohud = v;
|
g.mangohud = v;
|
||||||
}
|
}
|
||||||
if let Some(v) = gamescope {
|
match gamescope {
|
||||||
g.gamescope = v;
|
GamescopeUpdate::Unchanged => {}
|
||||||
|
GamescopeUpdate::Disable => g.gamescope = None,
|
||||||
|
GamescopeUpdate::Enable(args) => g.gamescope = Some(args),
|
||||||
}
|
}
|
||||||
self.save()?;
|
self.save()?;
|
||||||
println!("{} Updated flags for '{launcher}/{name}'.", "✓".green().bold());
|
println!("{} Updated flags for '{launcher}/{name}'.", "✓".green().bold());
|
||||||
|
|||||||
+244
-69
@@ -3,6 +3,7 @@ use anyhow::Result;
|
|||||||
use owo_colors::OwoColorize;
|
use owo_colors::OwoColorize;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{LazyLock, Mutex};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct DetectHit {
|
pub struct DetectHit {
|
||||||
@@ -22,8 +23,7 @@ pub fn scan_for_gui(config: &Config) -> Vec<DetectHit> {
|
|||||||
if prefix.join("drive_c").join(&preset.exe_path).exists() {
|
if prefix.join("drive_c").join(&preset.exe_path).exists() {
|
||||||
let configured = config
|
let configured = config
|
||||||
.find(&preset.name)
|
.find(&preset.name)
|
||||||
.map(|l| l.prefix_dir == *prefix)
|
.is_some_and(|l| l.prefix_dir == *prefix);
|
||||||
.unwrap_or(false);
|
|
||||||
hits.push(DetectHit {
|
hits.push(DetectHit {
|
||||||
display: preset.display.clone(),
|
display: preset.display.clone(),
|
||||||
prefix: prefix.clone(),
|
prefix: prefix.clone(),
|
||||||
@@ -51,15 +51,13 @@ const SYSTEM_DIRS: &[&str] = &[
|
|||||||
"windowsapps",
|
"windowsapps",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Directory names that contain launcher infrastructure, not games.
|
/// 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] = &[
|
const SKIP_DIRS: &[&str] = &[
|
||||||
"battle.net",
|
"battle.net", // Battle.net launcher dir; its games live elsewhere
|
||||||
"electronic arts",
|
"ea desktop", // EA Desktop launcher subfolder only
|
||||||
"ea desktop",
|
"gog galaxy", // GOG Galaxy launcher; games are normally in GOG Games/
|
||||||
"epic games",
|
|
||||||
"gog galaxy",
|
|
||||||
"ubisoft",
|
|
||||||
"rockstar games",
|
|
||||||
"wine",
|
"wine",
|
||||||
"mono",
|
"mono",
|
||||||
"gecko",
|
"gecko",
|
||||||
@@ -111,6 +109,80 @@ const SKIP_EXES: &[&str] = &[
|
|||||||
"aria2c",
|
"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.
|
/// Scan a launcher's Wine prefix for installed game executables.
|
||||||
/// Returns (display_name, path_relative_to_drive_c) pairs, sorted alphabetically,
|
/// Returns (display_name, path_relative_to_drive_c) pairs, sorted alphabetically,
|
||||||
/// excluding the launcher's own exe and any already-configured games.
|
/// excluding the launcher's own exe and any already-configured games.
|
||||||
@@ -170,8 +242,7 @@ fn scan_exe_dir(
|
|||||||
} else if path
|
} else if path
|
||||||
.extension()
|
.extension()
|
||||||
.and_then(|e| e.to_str())
|
.and_then(|e| e.to_str())
|
||||||
.map(|e| e.eq_ignore_ascii_case("exe"))
|
.is_some_and(|e| e.eq_ignore_ascii_case("exe"))
|
||||||
.unwrap_or(false)
|
|
||||||
{
|
{
|
||||||
let Ok(rel) = path.strip_prefix(drive_c) else { continue };
|
let Ok(rel) = path.strip_prefix(drive_c) else { continue };
|
||||||
let rel_str = rel.to_string_lossy().to_string();
|
let rel_str = rel.to_string_lossy().to_string();
|
||||||
@@ -191,30 +262,173 @@ fn scan_exe_dir(
|
|||||||
if !seen.insert(rel_lower) {
|
if !seen.insert(rel_lower) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let display = prettify_game_name(&path);
|
let display = resolve_game_name(&path, None);
|
||||||
out.push((display, rel_str));
|
out.push((display, rel_str));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Derive a human-readable game name from an exe path.
|
/// Resolve a human-readable display name for a game exe.
|
||||||
///
|
///
|
||||||
/// Strategy: use the parent directory name (e.g. "Call of Duty" from
|
/// Resolution pipeline (first hit wins):
|
||||||
/// `Program Files/Call of Duty/game.exe`) unless it looks generic
|
/// 1. Explicit name — if `explicit_name` is `Some`, return it immediately.
|
||||||
/// (like "Bin", "x64", "Binaries"), in which case walk up. Falls back
|
/// 2. Legendary / Heroic `installed.json` — maps install path → title,
|
||||||
/// to humanising the exe file stem by inserting spaces before capitals.
|
/// covers both Epic (via Legendary) and GOG (via Heroic's GOG store).
|
||||||
fn prettify_game_name(path: &Path) -> String {
|
/// 3. Manifest walk — walks up from the exe looking for GOG `.info` and
|
||||||
// Generic directory names that don't make good game labels
|
/// 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] = &[
|
const GENERIC_DIRS: &[&str] = &[
|
||||||
"bin", "binaries", "x64", "x86", "win64", "win32", "retail",
|
"bin", "binaries", "x64", "x86", "win64", "win32", "retail",
|
||||||
"shipping", "game", "runtime", "_retail_", "_commonredist",
|
"shipping", "game", "runtime", "_retail_", "_commonredist",
|
||||||
"launcher", "engine", "client",
|
"launcher", "engine", "client",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Try parent directories (closest first, up to 3 levels)
|
for d in path.ancestors().skip(1) {
|
||||||
let mut dir = path.parent();
|
|
||||||
for _ in 0..3 {
|
|
||||||
let Some(d) = dir else { break };
|
|
||||||
let name = d.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
let name = d.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||||
let lower = name.to_lowercase();
|
let lower = name.to_lowercase();
|
||||||
if !name.is_empty()
|
if !name.is_empty()
|
||||||
@@ -223,52 +437,13 @@ fn prettify_game_name(path: &Path) -> String {
|
|||||||
{
|
{
|
||||||
return name.to_string();
|
return name.to_string();
|
||||||
}
|
}
|
||||||
dir = d.parent();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: humanise the exe stem ("BlackOps6" → "Black Ops 6")
|
// Nothing useful in the path — return the exe stem as-is.
|
||||||
let stem = path
|
path.file_stem()
|
||||||
.file_stem()
|
|
||||||
.and_then(|s| s.to_str())
|
.and_then(|s| s.to_str())
|
||||||
.unwrap_or("Unknown");
|
.unwrap_or("Unknown")
|
||||||
humanise_stem(stem)
|
.to_string()
|
||||||
}
|
|
||||||
|
|
||||||
/// Insert spaces before uppercase runs and digit boundaries.
|
|
||||||
/// "BlackOps6" → "Black Ops 6", "HITMAN3" → "HITMAN 3"
|
|
||||||
fn humanise_stem(s: &str) -> String {
|
|
||||||
let mut out = String::with_capacity(s.len() + 4);
|
|
||||||
let chars: Vec<char> = s.chars().collect();
|
|
||||||
for (i, &c) in chars.iter().enumerate() {
|
|
||||||
if i > 0 {
|
|
||||||
let prev = chars[i - 1];
|
|
||||||
// Letter→digit or digit→letter boundary
|
|
||||||
if (prev.is_alphabetic() && c.is_ascii_digit())
|
|
||||||
|| (prev.is_ascii_digit() && c.is_alphabetic())
|
|
||||||
{
|
|
||||||
out.push(' ');
|
|
||||||
}
|
|
||||||
// lowercase→uppercase ("nO" in "BlackOps")
|
|
||||||
else if prev.is_lowercase() && c.is_uppercase() {
|
|
||||||
out.push(' ');
|
|
||||||
}
|
|
||||||
// UPPER run ending: "ABCdef" → "AB Cdef"
|
|
||||||
else if i + 1 < chars.len()
|
|
||||||
&& prev.is_uppercase()
|
|
||||||
&& c.is_uppercase()
|
|
||||||
&& chars[i + 1].is_lowercase()
|
|
||||||
{
|
|
||||||
out.push(' ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Replace underscores / hyphens with spaces
|
|
||||||
if c == '_' || c == '-' {
|
|
||||||
out.push(' ');
|
|
||||||
} else {
|
|
||||||
out.push(c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_DEPTH: u32 = 3;
|
const MAX_DEPTH: u32 = 3;
|
||||||
@@ -301,7 +476,7 @@ pub fn run(config: &Config, extra_dirs: &[PathBuf], apply: bool) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn default_roots() -> Vec<PathBuf> {
|
fn default_roots() -> Vec<PathBuf> {
|
||||||
let Ok(home) = std::env::var("HOME").map(PathBuf::from) else {
|
let Some(home) = dirs::home_dir() else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
vec![
|
vec![
|
||||||
@@ -342,7 +517,7 @@ fn collect_prefixes(dir: &Path, depth: u32, out: &mut Vec<PathBuf>) {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
|
if entry.file_type().is_ok_and(|t| t.is_dir()) {
|
||||||
collect_prefixes(&entry.path(), depth + 1, out);
|
collect_prefixes(&entry.path(), depth + 1, out);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-14
@@ -80,8 +80,7 @@ fn global_vulkan_check() -> CheckResult {
|
|||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
.stderr(Stdio::null())
|
.stderr(Stdio::null())
|
||||||
.status()
|
.status()
|
||||||
.map(|s| s.success())
|
.is_ok_and(|s| s.success());
|
||||||
.unwrap_or(false);
|
|
||||||
if ok {
|
if ok {
|
||||||
CheckResult::pass("vulkan", "vulkaninfo OK")
|
CheckResult::pass("vulkan", "vulkaninfo OK")
|
||||||
} else {
|
} else {
|
||||||
@@ -218,8 +217,7 @@ fn count_ge_proton(dir: &Path) -> usize {
|
|||||||
.filter(|e| {
|
.filter(|e| {
|
||||||
e.file_name()
|
e.file_name()
|
||||||
.to_str()
|
.to_str()
|
||||||
.map(|s| s.starts_with("GE-Proton"))
|
.is_some_and(|s| s.starts_with("GE-Proton"))
|
||||||
.unwrap_or(false)
|
|
||||||
})
|
})
|
||||||
.count()
|
.count()
|
||||||
})
|
})
|
||||||
@@ -236,19 +234,21 @@ fn which(cmd: &str) -> Option<String> {
|
|||||||
.map(|s| s.trim().to_string())
|
.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 {
|
fn is_owned_by_current_user(path: &Path) -> bool {
|
||||||
let file_uid = match std::fs::metadata(path) {
|
let file_uid = match std::fs::metadata(path) {
|
||||||
Ok(m) => m.uid(),
|
Ok(m) => m.uid(),
|
||||||
Err(_) => return true,
|
Err(_) => return true,
|
||||||
};
|
};
|
||||||
let current_uid: Option<u32> = Command::new("id")
|
current_uid().map_or(true, |uid| uid == file_uid)
|
||||||
.arg("-u")
|
|
||||||
.output()
|
|
||||||
.ok()
|
|
||||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
|
||||||
.and_then(|s| s.trim().parse().ok());
|
|
||||||
match current_uid {
|
|
||||||
Some(uid) => uid == file_uid,
|
|
||||||
None => true,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+157
-125
@@ -1,10 +1,10 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
config::Config, detect, diagnose, launcher, proton, service,
|
config::Config, detect, diagnose, launcher, proton, autostart,
|
||||||
theme::{
|
theme::{
|
||||||
btn_accent, btn_danger, btn_ghost, card_style, icon, section_heading, sub_card_style,
|
btn_accent, btn_danger, btn_ghost, card_style, icon, section_heading, sub_card_style,
|
||||||
surface_bg, ACCENT, BORDER_CLR, DIM, GREEN, MUTED, NO_SHADOW, RED,
|
surface_bg, ACCENT, BORDER_CLR, DIM, GREEN, MUTED, NO_SHADOW, RED,
|
||||||
},
|
},
|
||||||
util::{async_blocking, pick_file, pick_folder},
|
util::{pick_file, pick_folder},
|
||||||
};
|
};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use iced::widget::{
|
use iced::widget::{
|
||||||
@@ -77,9 +77,9 @@ pub enum Message {
|
|||||||
BrowseCompatDir,
|
BrowseCompatDir,
|
||||||
BrowseCompatDirDone(Option<String>),
|
BrowseCompatDirDone(Option<String>),
|
||||||
SaveSettings,
|
SaveSettings,
|
||||||
ServiceInstall,
|
AutostartInstall,
|
||||||
ServiceUninstall,
|
AutostartUninstall,
|
||||||
ServiceActionDone(Result<(), String>),
|
AutostartDone(Result<(), String>),
|
||||||
LaunchProtontricks,
|
LaunchProtontricks,
|
||||||
// Close dialog
|
// Close dialog
|
||||||
CloseRequested(iced::window::Id),
|
CloseRequested(iced::window::Id),
|
||||||
@@ -112,8 +112,8 @@ struct Dashboard {
|
|||||||
settings_proton_version: String,
|
settings_proton_version: String,
|
||||||
settings_compat_dir: String,
|
settings_compat_dir: String,
|
||||||
proton_versions: Vec<String>,
|
proton_versions: Vec<String>,
|
||||||
service_busy: bool,
|
autostart_busy: bool,
|
||||||
service_status: String,
|
autostart_status: String,
|
||||||
// Close dialog
|
// Close dialog
|
||||||
close_dialog_open: bool,
|
close_dialog_open: bool,
|
||||||
close_action: Arc<Mutex<Option<CloseAction>>>,
|
close_action: Arc<Mutex<Option<CloseAction>>>,
|
||||||
@@ -147,8 +147,8 @@ impl Dashboard {
|
|||||||
settings_proton_version,
|
settings_proton_version,
|
||||||
settings_compat_dir,
|
settings_compat_dir,
|
||||||
proton_versions,
|
proton_versions,
|
||||||
service_busy: false,
|
autostart_busy: false,
|
||||||
service_status: String::new(),
|
autostart_status: String::new(),
|
||||||
close_dialog_open: false,
|
close_dialog_open: false,
|
||||||
close_action,
|
close_action,
|
||||||
}
|
}
|
||||||
@@ -163,27 +163,28 @@ fn update(state: &mut Dashboard, msg: Message) -> Task<Message> {
|
|||||||
.map(|l| (l.name.clone(), l.process_pattern.clone()))
|
.map(|l| (l.name.clone(), l.process_pattern.clone()))
|
||||||
.collect();
|
.collect();
|
||||||
Task::perform(
|
Task::perform(
|
||||||
async_blocking(move || {
|
async {
|
||||||
let mut map = HashMap::new();
|
tokio::task::spawn_blocking(move || {
|
||||||
for (name, pattern) in launchers {
|
let mut map = HashMap::new();
|
||||||
let running = std::process::Command::new("pgrep")
|
for (name, pattern) in launchers {
|
||||||
.args(["-f", &pattern])
|
let running = std::process::Command::new("pgrep")
|
||||||
.stdout(std::process::Stdio::null())
|
.args(["-f", &pattern])
|
||||||
.stderr(std::process::Stdio::null())
|
.stdout(std::process::Stdio::null())
|
||||||
.status()
|
.stderr(std::process::Stdio::null())
|
||||||
.map(|s| s.success())
|
.status()
|
||||||
.unwrap_or(false);
|
.is_ok_and(|s| s.success());
|
||||||
map.insert(name, running);
|
map.insert(name, running);
|
||||||
}
|
}
|
||||||
map
|
map
|
||||||
}),
|
}).await.expect("blocking task panicked")
|
||||||
|
},
|
||||||
Message::PollDone,
|
Message::PollDone,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Message::PollDone(snapshot) => {
|
Message::PollDone(snapshot) => {
|
||||||
state.running = snapshot;
|
state.running = snapshot;
|
||||||
// Clear launch_busy for launchers that are now running
|
// Clear launch_busy for launchers that are now running
|
||||||
state.launch_busy.retain(|n| !state.running.get(n).copied().unwrap_or(false));
|
state.launch_busy.retain(|n| !state.running.get(n).copied().unwrap_or_default());
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::ReloadConfig => {
|
Message::ReloadConfig => {
|
||||||
@@ -198,8 +199,12 @@ fn update(state: &mut Dashboard, msg: Message) -> Task<Message> {
|
|||||||
}
|
}
|
||||||
Message::AddLauncher => {
|
Message::AddLauncher => {
|
||||||
state.context_menu = None;
|
state.context_menu = None;
|
||||||
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("umutray"));
|
let config = state.config.clone();
|
||||||
let _ = std::process::Command::new(exe).arg("setup").spawn();
|
std::thread::spawn(move || {
|
||||||
|
if let Err(e) = crate::setup::run_new(&config) {
|
||||||
|
eprintln!("umutray: setup picker failed: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::Launch(name) => {
|
Message::Launch(name) => {
|
||||||
@@ -212,7 +217,7 @@ fn update(state: &mut Dashboard, msg: Message) -> Task<Message> {
|
|||||||
let l = l.clone();
|
let l = l.clone();
|
||||||
let name2 = name.clone();
|
let name2 = name.clone();
|
||||||
Task::perform(
|
Task::perform(
|
||||||
async_blocking(move || launcher::launch(&config, &l).map_err(|e| e.to_string())),
|
async { tokio::task::spawn_blocking(move || launcher::launch(&config, &l).map_err(|e| e.to_string())).await.expect("blocking task panicked") },
|
||||||
move |res| Message::LaunchDone(name2.clone(), res),
|
move |res| Message::LaunchDone(name2.clone(), res),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -237,7 +242,7 @@ fn update(state: &mut Dashboard, msg: Message) -> Task<Message> {
|
|||||||
let name2 = name.clone();
|
let name2 = name.clone();
|
||||||
state.running.insert(name, false);
|
state.running.insert(name, false);
|
||||||
Task::perform(
|
Task::perform(
|
||||||
async_blocking(move || launcher::kill(&l).map_err(|e| e.to_string())),
|
async { tokio::task::spawn_blocking(move || { launcher::kill(&l); Ok::<(), String>(()) }).await.expect("blocking task panicked") },
|
||||||
move |res| Message::KillDone(name2.clone(), res),
|
move |res| Message::KillDone(name2.clone(), res),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -258,9 +263,11 @@ fn update(state: &mut Dashboard, msg: Message) -> Task<Message> {
|
|||||||
let lname2 = lname.clone();
|
let lname2 = lname.clone();
|
||||||
let gname2 = gname.clone();
|
let gname2 = gname.clone();
|
||||||
return Task::perform(
|
return Task::perform(
|
||||||
async_blocking(move || {
|
async {
|
||||||
launcher::play_game(&config, &l, &g).map_err(|e| e.to_string())
|
tokio::task::spawn_blocking(move || {
|
||||||
}),
|
launcher::play_game(&config, &l, &g).map_err(|e| e.to_string())
|
||||||
|
}).await.expect("blocking task panicked")
|
||||||
|
},
|
||||||
move |res| Message::PlayDone(lname2.clone(), gname2.clone(), res),
|
move |res| Message::PlayDone(lname2.clone(), gname2.clone(), res),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -274,11 +281,16 @@ fn update(state: &mut Dashboard, msg: Message) -> Task<Message> {
|
|||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::Setup(name) => {
|
Message::Setup(name) => {
|
||||||
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("umutray"));
|
let config = state.config.clone();
|
||||||
let _ = std::process::Command::new(exe)
|
let name2 = name.clone();
|
||||||
.arg("setup")
|
std::thread::spawn(move || {
|
||||||
.arg(&name)
|
if let Some(l) = config.find(&name2) {
|
||||||
.spawn();
|
let l = l.clone();
|
||||||
|
if let Err(e) = crate::setup::run(&config, &l) {
|
||||||
|
eprintln!("umutray: setup for {} failed: {e}", l.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::ToggleGameMode(lname, gname) => {
|
Message::ToggleGameMode(lname, gname) => {
|
||||||
@@ -305,9 +317,11 @@ fn update(state: &mut Dashboard, msg: Message) -> Task<Message> {
|
|||||||
state.last_error = None;
|
state.last_error = None;
|
||||||
let config = state.config.clone();
|
let config = state.config.clone();
|
||||||
Task::perform(
|
Task::perform(
|
||||||
async_blocking(move || {
|
async {
|
||||||
crate::proton::install_latest(&config).map_err(|e| e.to_string())
|
tokio::task::spawn_blocking(move || {
|
||||||
}),
|
crate::proton::install_latest(&config).map_err(|e| e.to_string())
|
||||||
|
}).await.expect("blocking task panicked")
|
||||||
|
},
|
||||||
Message::ProtonDone,
|
Message::ProtonDone,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -347,11 +361,16 @@ fn update(state: &mut Dashboard, msg: Message) -> Task<Message> {
|
|||||||
}
|
}
|
||||||
Message::RerunSetup(name) => {
|
Message::RerunSetup(name) => {
|
||||||
state.context_menu = None;
|
state.context_menu = None;
|
||||||
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("umutray"));
|
let config = state.config.clone();
|
||||||
let _ = std::process::Command::new(exe)
|
let name2 = name.clone();
|
||||||
.arg("setup")
|
std::thread::spawn(move || {
|
||||||
.arg(&name)
|
if let Some(l) = config.find(&name2) {
|
||||||
.spawn();
|
let l = l.clone();
|
||||||
|
if let Err(e) = crate::setup::run(&config, &l) {
|
||||||
|
eprintln!("umutray: setup for {} failed: {e}", l.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::RemoveLauncher(name) => {
|
Message::RemoveLauncher(name) => {
|
||||||
@@ -369,7 +388,7 @@ fn update(state: &mut Dashboard, msg: Message) -> Task<Message> {
|
|||||||
state.detect_result = "Scanning…".into();
|
state.detect_result = "Scanning…".into();
|
||||||
let config = state.config.clone();
|
let config = state.config.clone();
|
||||||
Task::perform(
|
Task::perform(
|
||||||
async_blocking(move || detect::scan_for_gui(&config)),
|
async { tokio::task::spawn_blocking(move || detect::scan_for_gui(&config)).await.expect("blocking task panicked") },
|
||||||
Message::DetectDone,
|
Message::DetectDone,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -400,14 +419,16 @@ fn update(state: &mut Dashboard, msg: Message) -> Task<Message> {
|
|||||||
let config = state.config.clone();
|
let config = state.config.clone();
|
||||||
let lname = name.clone();
|
let lname = name.clone();
|
||||||
Task::perform(
|
Task::perform(
|
||||||
async_blocking(move || {
|
async {
|
||||||
diagnose::run_checks(&config, Some(&name))
|
tokio::task::spawn_blocking(move || {
|
||||||
.unwrap_or_else(|e| vec![diagnose::CheckResult {
|
diagnose::run_checks(&config, Some(&name))
|
||||||
label: "error".into(),
|
.unwrap_or_else(|e| vec![diagnose::CheckResult {
|
||||||
pass: false,
|
label: "error".into(),
|
||||||
detail: e.to_string(),
|
pass: false,
|
||||||
}])
|
detail: e.to_string(),
|
||||||
}),
|
}])
|
||||||
|
}).await.expect("blocking task panicked")
|
||||||
|
},
|
||||||
move |checks| Message::DiagnoseDone(lname.clone(), checks),
|
move |checks| Message::DiagnoseDone(lname.clone(), checks),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -477,7 +498,7 @@ fn update(state: &mut Dashboard, msg: Message) -> Task<Message> {
|
|||||||
let lname2 = lname.clone();
|
let lname2 = lname.clone();
|
||||||
state.scan_busy.insert(lname);
|
state.scan_busy.insert(lname);
|
||||||
return Task::perform(
|
return Task::perform(
|
||||||
async_blocking(move || detect::scan_games_in_prefix(&l)),
|
async { tokio::task::spawn_blocking(move || detect::scan_games_in_prefix(&l)).await.expect("blocking task panicked") },
|
||||||
move |hits| Message::ScanGamesDone(lname2.clone(), hits),
|
move |hits| Message::ScanGamesDone(lname2.clone(), hits),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -517,7 +538,7 @@ fn update(state: &mut Dashboard, msg: Message) -> Task<Message> {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let lname2 = lname.clone();
|
let lname2 = lname.clone();
|
||||||
Task::perform(
|
Task::perform(
|
||||||
async_blocking(move || pick_file("Select game executable", &start)),
|
async { tokio::task::spawn_blocking(move || pick_file("Select game executable", &start)).await.expect("blocking task panicked") },
|
||||||
move |res| Message::BrowseGameExeDone(lname2.clone(), res),
|
move |res| Message::BrowseGameExeDone(lname2.clone(), res),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -551,7 +572,7 @@ fn update(state: &mut Dashboard, msg: Message) -> Task<Message> {
|
|||||||
state.settings_compat_dir =
|
state.settings_compat_dir =
|
||||||
state.config.proton_compat_dir.to_string_lossy().into_owned();
|
state.config.proton_compat_dir.to_string_lossy().into_owned();
|
||||||
state.proton_versions = proton::list_installed(&state.config);
|
state.proton_versions = proton::list_installed(&state.config);
|
||||||
state.service_status = String::new();
|
state.autostart_status = String::new();
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::HideSettings => {
|
Message::HideSettings => {
|
||||||
@@ -567,7 +588,7 @@ fn update(state: &mut Dashboard, msg: Message) -> Task<Message> {
|
|||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::BrowseCompatDir => Task::perform(
|
Message::BrowseCompatDir => Task::perform(
|
||||||
async_blocking(|| pick_folder("Choose GE-Proton compat directory")),
|
async { tokio::task::spawn_blocking(|| pick_folder("Choose GE-Proton compat directory")).await.expect("blocking task panicked") },
|
||||||
Message::BrowseCompatDirDone,
|
Message::BrowseCompatDirDone,
|
||||||
),
|
),
|
||||||
Message::BrowseCompatDirDone(path) => {
|
Message::BrowseCompatDirDone(path) => {
|
||||||
@@ -604,7 +625,7 @@ fn update(state: &mut Dashboard, msg: Message) -> Task<Message> {
|
|||||||
match state.config.set_globals(Some(version_key), Some(compat)) {
|
match state.config.set_globals(Some(version_key), Some(compat)) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
state.proton_versions = proton::list_installed(&state.config);
|
state.proton_versions = proton::list_installed(&state.config);
|
||||||
state.service_status = "Settings saved.".into();
|
state.autostart_status = "Settings saved.".into();
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
state.last_error = Some(format!("Save failed: {e}"));
|
state.last_error = Some(format!("Save failed: {e}"));
|
||||||
@@ -620,34 +641,34 @@ fn update(state: &mut Dashboard, msg: Message) -> Task<Message> {
|
|||||||
});
|
});
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::ServiceInstall => {
|
Message::AutostartInstall => {
|
||||||
state.service_busy = true;
|
state.autostart_busy = true;
|
||||||
state.service_status = "Installing autostart…".into();
|
state.autostart_status = "Installing autostart…".into();
|
||||||
Task::perform(
|
Task::perform(
|
||||||
async_blocking(|| service::install().map_err(|e| e.to_string())),
|
async { tokio::task::spawn_blocking(|| autostart::install().map_err(|e| e.to_string())).await.expect("blocking task panicked") },
|
||||||
Message::ServiceActionDone,
|
Message::AutostartDone,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Message::ServiceUninstall => {
|
Message::AutostartUninstall => {
|
||||||
state.service_busy = true;
|
state.autostart_busy = true;
|
||||||
state.service_status = "Removing autostart…".into();
|
state.autostart_status = "Removing autostart…".into();
|
||||||
Task::perform(
|
Task::perform(
|
||||||
async_blocking(|| service::uninstall().map_err(|e| e.to_string())),
|
async { tokio::task::spawn_blocking(|| autostart::uninstall().map_err(|e| e.to_string())).await.expect("blocking task panicked") },
|
||||||
Message::ServiceActionDone,
|
Message::AutostartDone,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Message::ServiceActionDone(res) => {
|
Message::AutostartDone(res) => {
|
||||||
state.service_busy = false;
|
state.autostart_busy = false;
|
||||||
match res {
|
match res {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
state.service_status = if service_is_installed() {
|
state.autostart_status = if autostart_is_installed() {
|
||||||
"Autostart enabled — starts on next login.".into()
|
"Autostart enabled — starts on next login.".into()
|
||||||
} else {
|
} else {
|
||||||
"Autostart removed.".into()
|
"Autostart removed.".into()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
state.service_status = format!("Failed: {e}");
|
state.autostart_status = format!("Failed: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Task::none()
|
Task::none()
|
||||||
@@ -663,13 +684,13 @@ fn update(state: &mut Dashboard, msg: Message) -> Task<Message> {
|
|||||||
if let Ok(mut a) = state.close_action.lock() {
|
if let Ok(mut a) = state.close_action.lock() {
|
||||||
*a = Some(CloseAction::Quit);
|
*a = Some(CloseAction::Quit);
|
||||||
}
|
}
|
||||||
iced::window::get_oldest().and_then(iced::window::close)
|
iced::window::oldest().and_then(iced::window::close)
|
||||||
}
|
}
|
||||||
Message::ConfirmMinimize => {
|
Message::ConfirmMinimize => {
|
||||||
if let Ok(mut a) = state.close_action.lock() {
|
if let Ok(mut a) = state.close_action.lock() {
|
||||||
*a = Some(CloseAction::MinimizeToTray);
|
*a = Some(CloseAction::MinimizeToTray);
|
||||||
}
|
}
|
||||||
iced::window::get_oldest().and_then(iced::window::close)
|
iced::window::oldest().and_then(iced::window::close)
|
||||||
}
|
}
|
||||||
Message::CancelClose => {
|
Message::CancelClose => {
|
||||||
state.close_dialog_open = false;
|
state.close_dialog_open = false;
|
||||||
@@ -698,17 +719,22 @@ fn toggle_flag(
|
|||||||
let _ = config.save();
|
let _ = config.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn service_is_installed() -> bool {
|
fn autostart_is_installed() -> bool {
|
||||||
dirs::home_dir()
|
dirs::home_dir()
|
||||||
.map(|h| h.join(".config/autostart/umutray.desktop").exists())
|
.is_some_and(|h| h.join(".config/autostart/umutray.desktop").exists())
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn subscription(_: &Dashboard) -> Subscription<Message> {
|
fn subscription(_: &Dashboard) -> Subscription<Message> {
|
||||||
Subscription::batch([
|
Subscription::batch([
|
||||||
iced::time::every(Duration::from_secs(2)).map(|_| Message::PollProcesses),
|
iced::time::every(Duration::from_secs(2)).map(|_| Message::PollProcesses),
|
||||||
iced::time::every(Duration::from_secs(5)).map(|_| Message::ReloadConfig),
|
iced::time::every(Duration::from_secs(5)).map(|_| Message::ReloadConfig),
|
||||||
iced::window::close_requests().map(Message::CloseRequested),
|
iced::event::listen_with(|event, _status, id| {
|
||||||
|
if let iced::Event::Window(iced::window::Event::CloseRequested) = event {
|
||||||
|
Some(Message::CloseRequested(id))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -741,7 +767,7 @@ fn view(state: &Dashboard) -> Element<'_, Message> {
|
|||||||
color: Some(DIM),
|
color: Some(DIM),
|
||||||
}),
|
}),
|
||||||
].spacing(2),
|
].spacing(2),
|
||||||
iced::widget::horizontal_space(),
|
Space::new().width(Length::Fill),
|
||||||
settings_btn,
|
settings_btn,
|
||||||
]
|
]
|
||||||
.align_y(Alignment::Center),
|
.align_y(Alignment::Center),
|
||||||
@@ -749,7 +775,7 @@ fn view(state: &Dashboard) -> Element<'_, Message> {
|
|||||||
.padding(Padding { top: 20.0, right: 24.0, bottom: 6.0, left: 24.0 });
|
.padding(Padding { top: 20.0, right: 24.0, bottom: 6.0, left: 24.0 });
|
||||||
|
|
||||||
// ── Accent bar under title ────────────────────────────────────────────
|
// ── Accent bar under title ────────────────────────────────────────────
|
||||||
let accent_bar = container(Space::new(0, 0))
|
let accent_bar = container(Space::new())
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.height(Length::Fixed(2.0))
|
.height(Length::Fixed(2.0))
|
||||||
.style(|_: &Theme| container::Style {
|
.style(|_: &Theme| container::Style {
|
||||||
@@ -772,14 +798,14 @@ fn view(state: &Dashboard) -> Element<'_, Message> {
|
|||||||
accent_bar,
|
accent_bar,
|
||||||
container(
|
container(
|
||||||
column![
|
column![
|
||||||
Space::new(0, 40),
|
Space::new().height(40),
|
||||||
text("No launchers configured").size(16).style(|_: &Theme| text::Style {
|
text("No launchers configured").size(16).style(|_: &Theme| text::Style {
|
||||||
color: Some(DIM),
|
color: Some(DIM),
|
||||||
}),
|
}),
|
||||||
text("Add a launcher to get started.").size(12).style(|_: &Theme| text::Style {
|
text("Add a launcher to get started.").size(12).style(|_: &Theme| text::Style {
|
||||||
color: Some(MUTED),
|
color: Some(MUTED),
|
||||||
}),
|
}),
|
||||||
Space::new(0, 12),
|
Space::new().height(12),
|
||||||
add_btn,
|
add_btn,
|
||||||
]
|
]
|
||||||
.spacing(6)
|
.spacing(6)
|
||||||
@@ -873,7 +899,7 @@ fn view(state: &Dashboard) -> Element<'_, Message> {
|
|||||||
detect_btn,
|
detect_btn,
|
||||||
proton_btn,
|
proton_btn,
|
||||||
add_btn,
|
add_btn,
|
||||||
iced::widget::horizontal_space(),
|
Space::new().width(Length::Fill),
|
||||||
footer_status,
|
footer_status,
|
||||||
]
|
]
|
||||||
.align_y(Alignment::Center)
|
.align_y(Alignment::Center)
|
||||||
@@ -918,7 +944,7 @@ fn view(state: &Dashboard) -> Element<'_, Message> {
|
|||||||
})
|
})
|
||||||
.into()
|
.into()
|
||||||
} else {
|
} else {
|
||||||
Space::new(0, 0).into()
|
Space::new().into()
|
||||||
};
|
};
|
||||||
|
|
||||||
let body = column![
|
let body = column![
|
||||||
@@ -1016,7 +1042,7 @@ fn launcher_card<'a>(
|
|||||||
})
|
})
|
||||||
.into()
|
.into()
|
||||||
} else {
|
} else {
|
||||||
Space::new(0, 0).into()
|
Space::new().into()
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Status pill ───────────────────────────────────────────────────────
|
// ── Status pill ───────────────────────────────────────────────────────
|
||||||
@@ -1045,7 +1071,7 @@ fn launcher_card<'a>(
|
|||||||
status_pill,
|
status_pill,
|
||||||
].align_y(Alignment::Center),
|
].align_y(Alignment::Center),
|
||||||
].spacing(6),
|
].spacing(6),
|
||||||
iced::widget::horizontal_space(),
|
Space::new().width(Length::Fill),
|
||||||
action,
|
action,
|
||||||
]
|
]
|
||||||
.align_y(Alignment::Center);
|
.align_y(Alignment::Center);
|
||||||
@@ -1054,7 +1080,7 @@ fn launcher_card<'a>(
|
|||||||
|
|
||||||
// ── Games section ─────────────────────────────────────────────────────
|
// ── Games section ─────────────────────────────────────────────────────
|
||||||
let has_games = !l.games.is_empty();
|
let has_games = !l.games.is_empty();
|
||||||
let has_scan = scan_results.map(|r| !r.is_empty()).unwrap_or(false);
|
let has_scan = scan_results.is_some_and(|r| !r.is_empty());
|
||||||
|
|
||||||
if has_games || has_scan || scan_busy {
|
if has_games || has_scan || scan_busy {
|
||||||
let game_count = l.games.len();
|
let game_count = l.games.len();
|
||||||
@@ -1075,7 +1101,7 @@ fn launcher_card<'a>(
|
|||||||
let section_header = row![
|
let section_header = row![
|
||||||
text(section_label).size(10)
|
text(section_label).size(10)
|
||||||
.style(move |_: &Theme| text::Style { color: Some(DIM) }),
|
.style(move |_: &Theme| text::Style { color: Some(DIM) }),
|
||||||
iced::widget::horizontal_space(),
|
Space::new().width(Length::Fill),
|
||||||
rescan_btn,
|
rescan_btn,
|
||||||
]
|
]
|
||||||
.align_y(Alignment::Center);
|
.align_y(Alignment::Center);
|
||||||
@@ -1119,7 +1145,7 @@ fn launcher_card<'a>(
|
|||||||
text(display).size(13),
|
text(display).size(13),
|
||||||
text(exe).size(9).style(move |_: &Theme| text::Style { color: Some(MUTED) }),
|
text(exe).size(9).style(move |_: &Theme| text::Style { color: Some(MUTED) }),
|
||||||
].spacing(2),
|
].spacing(2),
|
||||||
iced::widget::horizontal_space(),
|
Space::new().width(Length::Fill),
|
||||||
add_btn,
|
add_btn,
|
||||||
]
|
]
|
||||||
.align_y(Alignment::Center)
|
.align_y(Alignment::Center)
|
||||||
@@ -1180,6 +1206,7 @@ fn launcher_card<'a>(
|
|||||||
radius: 10.0.into(),
|
radius: 10.0.into(),
|
||||||
},
|
},
|
||||||
shadow: NO_SHADOW,
|
shadow: NO_SHADOW,
|
||||||
|
snap: false,
|
||||||
})
|
})
|
||||||
.padding([3, 9])
|
.padding([3, 9])
|
||||||
.into()
|
.into()
|
||||||
@@ -1202,7 +1229,7 @@ fn launcher_card<'a>(
|
|||||||
play,
|
play,
|
||||||
container(status_dot).padding(Padding { top: 0.0, left: 2.0, right: 2.0, bottom: 0.0 }),
|
container(status_dot).padding(Padding { top: 0.0, left: 2.0, right: 2.0, bottom: 0.0 }),
|
||||||
text(&g.display).size(14),
|
text(&g.display).size(14),
|
||||||
iced::widget::horizontal_space(),
|
Space::new().width(Length::Fill),
|
||||||
overlays,
|
overlays,
|
||||||
container(remove_game).padding(Padding { top: 0.0, left: 8.0, right: 0.0, bottom: 0.0 }),
|
container(remove_game).padding(Padding { top: 0.0, left: 8.0, right: 0.0, bottom: 0.0 }),
|
||||||
]
|
]
|
||||||
@@ -1237,7 +1264,7 @@ fn launcher_card<'a>(
|
|||||||
row![
|
row![
|
||||||
text("No games configured").size(12)
|
text("No games configured").size(12)
|
||||||
.style(move |_: &Theme| text::Style { color: Some(MUTED) }),
|
.style(move |_: &Theme| text::Style { color: Some(MUTED) }),
|
||||||
iced::widget::horizontal_space(),
|
Space::new().width(Length::Fill),
|
||||||
scan_btn,
|
scan_btn,
|
||||||
browse_btn,
|
browse_btn,
|
||||||
]
|
]
|
||||||
@@ -1319,7 +1346,7 @@ fn launcher_card<'a>(
|
|||||||
sections.push(
|
sections.push(
|
||||||
row![
|
row![
|
||||||
add_game_btn,
|
add_game_btn,
|
||||||
iced::widget::horizontal_space(),
|
Space::new().width(Length::Fill),
|
||||||
scan_btn,
|
scan_btn,
|
||||||
browse_btn,
|
browse_btn,
|
||||||
]
|
]
|
||||||
@@ -1340,7 +1367,7 @@ fn context_menu_card(l: &crate::config::Launcher) -> Element<'_, Message> {
|
|||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
.spacing(3),
|
.spacing(3),
|
||||||
iced::widget::horizontal_space(),
|
Space::new().width(Length::Fill),
|
||||||
button(icon("\u{f659}", 13))
|
button(icon("\u{f659}", 13))
|
||||||
.on_press(Message::HideContextMenu)
|
.on_press(Message::HideContextMenu)
|
||||||
.style(btn_ghost)
|
.style(btn_ghost)
|
||||||
@@ -1375,11 +1402,11 @@ fn context_menu_card(l: &crate::config::Launcher) -> Element<'_, Message> {
|
|||||||
|
|
||||||
column![
|
column![
|
||||||
header,
|
header,
|
||||||
Space::new(0, 4),
|
Space::new().height(4),
|
||||||
menu_btn("Open install folder", "\u{f3e8}", Message::OpenPrefix(l.name.clone())),
|
menu_btn("Open install folder", "\u{f3e8}", Message::OpenPrefix(l.name.clone())),
|
||||||
menu_btn("Re-run setup wizard", "\u{f130}", Message::RerunSetup(l.name.clone())),
|
menu_btn("Re-run setup wizard", "\u{f130}", Message::RerunSetup(l.name.clone())),
|
||||||
menu_btn("Run diagnostics", "\u{f52a}", Message::DiagnosePressed(l.name.clone())),
|
menu_btn("Run diagnostics", "\u{f52a}", Message::DiagnosePressed(l.name.clone())),
|
||||||
Space::new(0, 2),
|
Space::new().height(2),
|
||||||
remove,
|
remove,
|
||||||
]
|
]
|
||||||
.spacing(2)
|
.spacing(2)
|
||||||
@@ -1398,7 +1425,7 @@ fn diagnose_card<'a>(
|
|||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
.spacing(3),
|
.spacing(3),
|
||||||
iced::widget::horizontal_space(),
|
Space::new().width(Length::Fill),
|
||||||
button(icon("\u{f659}", 13))
|
button(icon("\u{f659}", 13))
|
||||||
.on_press(Message::HideDiagnose)
|
.on_press(Message::HideDiagnose)
|
||||||
.style(btn_ghost)
|
.style(btn_ghost)
|
||||||
@@ -1432,7 +1459,7 @@ fn diagnose_card<'a>(
|
|||||||
color: Some(color),
|
color: Some(color),
|
||||||
}),
|
}),
|
||||||
text(format!(" {}", c.label)).size(12),
|
text(format!(" {}", c.label)).size(12),
|
||||||
iced::widget::horizontal_space(),
|
Space::new().width(Length::Fill),
|
||||||
text(&c.detail).size(11).style(|_: &Theme| text::Style {
|
text(&c.detail).size(11).style(|_: &Theme| text::Style {
|
||||||
color: Some(DIM),
|
color: Some(DIM),
|
||||||
}),
|
}),
|
||||||
@@ -1449,7 +1476,7 @@ fn diagnose_card<'a>(
|
|||||||
|
|
||||||
column![
|
column![
|
||||||
header,
|
header,
|
||||||
Space::new(0, 6),
|
Space::new().height(6),
|
||||||
body,
|
body,
|
||||||
]
|
]
|
||||||
.spacing(4)
|
.spacing(4)
|
||||||
@@ -1463,7 +1490,7 @@ fn settings_section<'a>(title: &str, content: Element<'a, Message>) -> Element<'
|
|||||||
text(title.to_uppercase()).size(10).style(|_: &Theme| text::Style {
|
text(title.to_uppercase()).size(10).style(|_: &Theme| text::Style {
|
||||||
color: Some(DIM),
|
color: Some(DIM),
|
||||||
}),
|
}),
|
||||||
Space::new(0, 4),
|
Space::new().height(4),
|
||||||
content,
|
content,
|
||||||
]
|
]
|
||||||
.spacing(2),
|
.spacing(2),
|
||||||
@@ -1524,7 +1551,7 @@ fn view_close_dialog() -> Element<'static, Message> {
|
|||||||
column![
|
column![
|
||||||
title_row,
|
title_row,
|
||||||
description,
|
description,
|
||||||
Space::new(0, 8),
|
Space::new().height(8),
|
||||||
minimize_btn,
|
minimize_btn,
|
||||||
quit_btn,
|
quit_btn,
|
||||||
cancel_btn,
|
cancel_btn,
|
||||||
@@ -1558,7 +1585,7 @@ fn view_settings(state: &Dashboard) -> Element<'_, Message> {
|
|||||||
icon("\u{f3e2}", 20).style(|_: &Theme| text::Style { color: Some(ACCENT) }),
|
icon("\u{f3e2}", 20).style(|_: &Theme| text::Style { color: Some(ACCENT) }),
|
||||||
text(" Settings").size(22),
|
text(" Settings").size(22),
|
||||||
].align_y(Alignment::Center),
|
].align_y(Alignment::Center),
|
||||||
iced::widget::horizontal_space(),
|
Space::new().width(Length::Fill),
|
||||||
button(
|
button(
|
||||||
row![icon("\u{f12f}", 13), text(" Back").size(12)]
|
row![icon("\u{f12f}", 13), text(" Back").size(12)]
|
||||||
.align_y(Alignment::Center).spacing(4),
|
.align_y(Alignment::Center).spacing(4),
|
||||||
@@ -1606,10 +1633,10 @@ fn view_settings(state: &Dashboard) -> Element<'_, Message> {
|
|||||||
column![
|
column![
|
||||||
text("Version").size(12).style(|_: &Theme| text::Style { color: Some(DIM) }),
|
text("Version").size(12).style(|_: &Theme| text::Style { color: Some(DIM) }),
|
||||||
proton_version_picker,
|
proton_version_picker,
|
||||||
Space::new(0, 4),
|
Space::new().height(4),
|
||||||
text("Compat directory").size(12).style(|_: &Theme| text::Style { color: Some(DIM) }),
|
text("Compat directory").size(12).style(|_: &Theme| text::Style { color: Some(DIM) }),
|
||||||
row![compat_dir_input, browse_compat_btn].spacing(8).align_y(Alignment::Center),
|
row![compat_dir_input, browse_compat_btn].spacing(8).align_y(Alignment::Center),
|
||||||
Space::new(0, 4),
|
Space::new().height(4),
|
||||||
container(save_btn).width(Length::Fill).align_x(Alignment::End),
|
container(save_btn).width(Length::Fill).align_x(Alignment::End),
|
||||||
].spacing(6).into(),
|
].spacing(6).into(),
|
||||||
);
|
);
|
||||||
@@ -1628,25 +1655,25 @@ fn view_settings(state: &Dashboard) -> Element<'_, Message> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ── Autostart section ─────────────────────────────────────────────────
|
// ── Autostart section ─────────────────────────────────────────────────
|
||||||
let installed = service_is_installed();
|
let installed = autostart_is_installed();
|
||||||
|
|
||||||
let svc_status_color = if installed { GREEN } else { MUTED };
|
let svc_status_color = if installed { GREEN } else { MUTED };
|
||||||
let svc_status_text = if installed { "Enabled — starts on login" } else { "Disabled" };
|
let svc_status_text = if installed { "Enabled — starts on login" } else { "Disabled" };
|
||||||
let svc_status_icon = if installed { "\u{f26a}" } else { "\u{f28a}" };
|
let svc_status_icon = if installed { "\u{f26a}" } else { "\u{f28a}" };
|
||||||
|
|
||||||
let svc_install_btn = button(
|
let svc_install_btn = button(
|
||||||
row![icon("\u{f64d}", 12), text(if state.service_busy { " Working…" } else { " Enable" }).size(12)]
|
row![icon("\u{f64d}", 12), text(if state.autostart_busy { " Working…" } else { " Enable" }).size(12)]
|
||||||
.align_y(Alignment::Center).spacing(4),
|
.align_y(Alignment::Center).spacing(4),
|
||||||
)
|
)
|
||||||
.on_press_maybe((!state.service_busy && !installed).then_some(Message::ServiceInstall))
|
.on_press_maybe((!state.autostart_busy && !installed).then_some(Message::AutostartInstall))
|
||||||
.style(btn_accent)
|
.style(btn_accent)
|
||||||
.padding([7, 14]);
|
.padding([7, 14]);
|
||||||
|
|
||||||
let svc_uninstall_btn = button(
|
let svc_uninstall_btn = button(
|
||||||
row![icon("\u{f659}", 12), text(if state.service_busy { " Working…" } else { " Disable" }).size(12)]
|
row![icon("\u{f659}", 12), text(if state.autostart_busy { " Working…" } else { " Disable" }).size(12)]
|
||||||
.align_y(Alignment::Center).spacing(4),
|
.align_y(Alignment::Center).spacing(4),
|
||||||
)
|
)
|
||||||
.on_press_maybe((!state.service_busy && installed).then_some(Message::ServiceUninstall))
|
.on_press_maybe((!state.autostart_busy && installed).then_some(Message::AutostartUninstall))
|
||||||
.style(btn_danger)
|
.style(btn_danger)
|
||||||
.padding([7, 14]);
|
.padding([7, 14]);
|
||||||
|
|
||||||
@@ -1657,11 +1684,11 @@ fn view_settings(state: &Dashboard) -> Element<'_, Message> {
|
|||||||
text(format!(" {svc_status_text}")).size(12).style(move |_: &Theme| text::Style {
|
text(format!(" {svc_status_text}")).size(12).style(move |_: &Theme| text::Style {
|
||||||
color: Some(svc_status_color),
|
color: Some(svc_status_color),
|
||||||
}),
|
}),
|
||||||
iced::widget::horizontal_space(),
|
Space::new().width(Length::Fill),
|
||||||
svc_install_btn,
|
svc_install_btn,
|
||||||
svc_uninstall_btn,
|
svc_uninstall_btn,
|
||||||
].align_y(Alignment::Center).spacing(8),
|
].align_y(Alignment::Center).spacing(8),
|
||||||
text(&state.service_status).size(11).style(|_: &Theme| text::Style {
|
text(&state.autostart_status).size(11).style(|_: &Theme| text::Style {
|
||||||
color: Some(DIM),
|
color: Some(DIM),
|
||||||
}),
|
}),
|
||||||
].spacing(6).into(),
|
].spacing(6).into(),
|
||||||
@@ -1686,12 +1713,12 @@ fn view_settings(state: &Dashboard) -> Element<'_, Message> {
|
|||||||
})
|
})
|
||||||
.into()
|
.into()
|
||||||
} else {
|
} else {
|
||||||
Space::new(0, 0).into()
|
Space::new().into()
|
||||||
};
|
};
|
||||||
|
|
||||||
let body = column![
|
let body = column![
|
||||||
header,
|
header,
|
||||||
Space::new(0, 6),
|
Space::new().height(6),
|
||||||
proton_section,
|
proton_section,
|
||||||
tools_section,
|
tools_section,
|
||||||
autostart_section,
|
autostart_section,
|
||||||
@@ -1711,21 +1738,26 @@ pub fn run(config: &Config) -> Result<CloseAction> {
|
|||||||
let config = config.clone();
|
let config = config.clone();
|
||||||
let close_action: Arc<Mutex<Option<CloseAction>>> = Arc::new(Mutex::new(None));
|
let close_action: Arc<Mutex<Option<CloseAction>>> = Arc::new(Mutex::new(None));
|
||||||
let ca = close_action.clone();
|
let ca = close_action.clone();
|
||||||
iced::application(|_: &Dashboard| String::from("umutray"), update, view)
|
iced::application(
|
||||||
.subscription(subscription)
|
move || {
|
||||||
.theme(|_| Theme::Dark)
|
|
||||||
.window(iced::window::Settings {
|
|
||||||
size: iced::Size::new(640.0, 600.0),
|
|
||||||
min_size: Some(iced::Size::new(480.0, 400.0)),
|
|
||||||
exit_on_close_request: false,
|
|
||||||
..Default::default()
|
|
||||||
})
|
|
||||||
.run_with(move || {
|
|
||||||
let cfg = config.clone();
|
let cfg = config.clone();
|
||||||
let load_font = iced::font::load(std::borrow::Cow::Borrowed(iced_fonts::BOOTSTRAP_FONT_BYTES)).map(|_| Message::FontLoaded);
|
let load_font = iced::font::load(iced_fonts::BOOTSTRAP_FONT_BYTES).map(|_| Message::FontLoaded);
|
||||||
(Dashboard::new(cfg, ca.clone()), load_font)
|
(Dashboard::new(cfg, ca.clone()), load_font)
|
||||||
})
|
},
|
||||||
.map_err(|e| anyhow::anyhow!("iced: {e}"))?;
|
update,
|
||||||
|
view,
|
||||||
|
)
|
||||||
|
.title(|_: &Dashboard| String::from("umutray"))
|
||||||
|
.subscription(subscription)
|
||||||
|
.theme(Theme::Dark)
|
||||||
|
.window(iced::window::Settings {
|
||||||
|
size: iced::Size::new(640.0, 600.0),
|
||||||
|
min_size: Some(iced::Size::new(480.0, 400.0)),
|
||||||
|
exit_on_close_request: false,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
.map_err(|e| anyhow::anyhow!("iced: {e}"))?;
|
||||||
|
|
||||||
let action = close_action.lock().unwrap().unwrap_or(CloseAction::Quit);
|
let action = close_action.lock().unwrap().unwrap_or(CloseAction::Quit);
|
||||||
Ok(action)
|
Ok(action)
|
||||||
|
|||||||
+40
-17
@@ -25,20 +25,38 @@ pub fn launch(config: &Config, launcher: &Launcher) -> Result<()> {
|
|||||||
let exe = launcher.full_exe_path();
|
let exe = launcher.full_exe_path();
|
||||||
if !exe.exists() {
|
if !exe.exists() {
|
||||||
bail!(
|
bail!(
|
||||||
"launcher exe not found at {:?}\n\
|
"launcher exe not found at {}\n\
|
||||||
Run `umutray setup {}` for setup instructions.",
|
Run `umutray setup {}` for setup instructions.",
|
||||||
exe,
|
exe.display(),
|
||||||
launcher.name,
|
launcher.name,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let proton_path = resolve_proton_path(config, launcher);
|
let proton_path = resolve_proton_path(config, launcher);
|
||||||
|
|
||||||
std::process::Command::new("umu-run")
|
// Propagate overlay env vars from any configured game so that games
|
||||||
.env("WINEPREFIX", &launcher.prefix_dir)
|
// 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("GAMEID", &launcher.gameid)
|
||||||
.env("PROTONPATH", &proton_path)
|
.env("PROTONPATH", &proton_path);
|
||||||
.arg(&exe)
|
if any_mangohud {
|
||||||
|
cmd.env("MANGOHUD", "1");
|
||||||
|
}
|
||||||
|
cmd.args(&args)
|
||||||
.spawn()
|
.spawn()
|
||||||
.context(
|
.context(
|
||||||
"Failed to spawn umu-run. Is it installed?\n\
|
"Failed to spawn umu-run. Is it installed?\n\
|
||||||
@@ -48,21 +66,29 @@ pub fn launch(config: &Config, launcher: &Launcher) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Launch a game installed through `launcher`, wrapped in the per-game
|
/// Launch a game directly via umu-run, wrapped in the per-game overlays
|
||||||
/// overlays (gamescope, gamemoderun, MANGOHUD). The launcher itself is
|
/// (gamescope, gamemoderun, MANGOHUD). Ensures the parent launcher is
|
||||||
/// never wrapped — only games run through this path pick up overlays.
|
/// running first so the game can authenticate online.
|
||||||
pub fn play_game(config: &Config, launcher: &Launcher, game: &Game) -> Result<()> {
|
pub fn play_game(config: &Config, launcher: &Launcher, game: &Game) -> Result<()> {
|
||||||
let exe = game.full_exe_path(launcher);
|
let exe = game.full_exe_path(launcher);
|
||||||
if !exe.exists() {
|
if !exe.exists() {
|
||||||
bail!(
|
bail!(
|
||||||
"game exe not found at {:?}\n\
|
"game exe not found at {}\n\
|
||||||
Check exe_path for '{}/{}' in config, or install the game via the launcher first.",
|
Check exe_path for '{}/{}' in config, or install the game via the launcher first.",
|
||||||
exe,
|
exe.display(),
|
||||||
launcher.name,
|
launcher.name,
|
||||||
game.name,
|
game.name,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 proton_path = resolve_proton_path(config, launcher);
|
||||||
|
|
||||||
let (prog, args) = build_wrapped_argv(&exe, game);
|
let (prog, args) = build_wrapped_argv(&exe, game);
|
||||||
@@ -108,13 +134,12 @@ fn build_wrapped_argv(exe: &Path, game: &Game) -> (OsString, Vec<OsString>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// SIGTERM → wait 3 s → SIGKILL for a single launcher.
|
/// SIGTERM → wait 3 s → SIGKILL for a single launcher.
|
||||||
pub fn kill(launcher: &Launcher) -> Result<()> {
|
pub fn kill(launcher: &Launcher) {
|
||||||
kill_pattern(&launcher.process_pattern);
|
kill_pattern(&launcher.process_pattern);
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kill every configured launcher's processes.
|
/// Kill every configured launcher's processes.
|
||||||
pub fn kill_all(config: &Config) -> Result<()> {
|
pub fn kill_all(config: &Config) {
|
||||||
// Single SIGTERM pass across all launchers, then one sleep, then SIGKILL.
|
// Single SIGTERM pass across all launchers, then one sleep, then SIGKILL.
|
||||||
// This keeps the total wait at 3 s instead of 3 s × N.
|
// This keeps the total wait at 3 s instead of 3 s × N.
|
||||||
for l in &config.launchers {
|
for l in &config.launchers {
|
||||||
@@ -124,7 +149,6 @@ pub fn kill_all(config: &Config) -> Result<()> {
|
|||||||
for l in &config.launchers {
|
for l in &config.launchers {
|
||||||
send_signal("-9", &l.process_pattern);
|
send_signal("-9", &l.process_pattern);
|
||||||
}
|
}
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn kill_pattern(pattern: &str) {
|
fn kill_pattern(pattern: &str) {
|
||||||
@@ -149,6 +173,5 @@ pub fn is_running(launcher: &Launcher) -> bool {
|
|||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
.stderr(Stdio::null())
|
.stderr(Stdio::null())
|
||||||
.status()
|
.status()
|
||||||
.map(|s| s.success())
|
.is_ok_and(|s| s.success())
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-20
@@ -6,7 +6,7 @@ mod diagnose;
|
|||||||
mod gui;
|
mod gui;
|
||||||
mod launcher;
|
mod launcher;
|
||||||
mod proton;
|
mod proton;
|
||||||
mod service;
|
mod autostart;
|
||||||
mod setup;
|
mod setup;
|
||||||
mod theme;
|
mod theme;
|
||||||
mod tray;
|
mod tray;
|
||||||
@@ -109,10 +109,10 @@ enum Commands {
|
|||||||
action: ConfigAction,
|
action: ConfigAction,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Manage the XDG autostart entry that starts the tray on login
|
/// Manage the XDG autostart and desktop entries
|
||||||
Service {
|
Autostart {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
action: ServiceAction,
|
action: AutostartAction,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +222,7 @@ enum ConfigAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum ServiceAction {
|
enum AutostartAction {
|
||||||
/// Write ~/.config/autostart/umutray.desktop so the tray starts on login (includes app menu entry)
|
/// Write ~/.config/autostart/umutray.desktop so the tray starts on login (includes app menu entry)
|
||||||
Install,
|
Install,
|
||||||
/// Remove the autostart entry and app menu entry
|
/// Remove the autostart entry and app menu entry
|
||||||
@@ -239,6 +239,10 @@ fn main() -> Result<()> {
|
|||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
let config = config::Config::load()?;
|
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) {
|
match cli.command.unwrap_or(Commands::Tray) {
|
||||||
Commands::Tray => tray::run(&config)?,
|
Commands::Tray => tray::run(&config)?,
|
||||||
|
|
||||||
@@ -254,9 +258,9 @@ fn main() -> Result<()> {
|
|||||||
let l = config.find(&n).ok_or_else(|| {
|
let l = config.find(&n).ok_or_else(|| {
|
||||||
anyhow::anyhow!("unknown launcher '{n}' — try `umutray launchers`")
|
anyhow::anyhow!("unknown launcher '{n}' — try `umutray launchers`")
|
||||||
})?;
|
})?;
|
||||||
launcher::kill(l)?;
|
launcher::kill(l);
|
||||||
}
|
}
|
||||||
None => launcher::kill_all(&config)?,
|
None => launcher::kill_all(&config),
|
||||||
},
|
},
|
||||||
|
|
||||||
Commands::Diagnose { name } => {
|
Commands::Diagnose { name } => {
|
||||||
@@ -414,27 +418,26 @@ fn main() -> Result<()> {
|
|||||||
gamescope,
|
gamescope,
|
||||||
no_gamescope,
|
no_gamescope,
|
||||||
} => {
|
} => {
|
||||||
// gs_update is Option<Option<Vec<String>>> where:
|
|
||||||
// None = leave gamescope unchanged
|
|
||||||
// Some(None) = disable gamescope
|
|
||||||
// Some(Some(args)) = enable gamescope with these CLI args
|
|
||||||
let gs_update = if no_gamescope {
|
let gs_update = if no_gamescope {
|
||||||
Some(None)
|
config::GamescopeUpdate::Disable
|
||||||
|
} else if let Some(s) = gamescope {
|
||||||
|
config::GamescopeUpdate::Enable(
|
||||||
|
s.split_whitespace().map(String::from).collect(),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
gamescope
|
config::GamescopeUpdate::Unchanged
|
||||||
.map(|s| Some(s.split_whitespace().map(String::from).collect::<Vec<_>>()))
|
|
||||||
};
|
};
|
||||||
let mut c = config;
|
let mut c = config;
|
||||||
c.set_game_flags(&launcher, &name, gamemode, mangohud, gs_update)?;
|
c.set_game_flags(&launcher, &name, gamemode, mangohud, gs_update)?;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
Commands::Service { action } => match action {
|
Commands::Autostart { action } => match action {
|
||||||
ServiceAction::Install => service::install()?,
|
AutostartAction::Install => autostart::install()?,
|
||||||
ServiceAction::Uninstall => service::uninstall()?,
|
AutostartAction::Uninstall => autostart::uninstall()?,
|
||||||
ServiceAction::Status => service::status()?,
|
AutostartAction::Status => autostart::status()?,
|
||||||
ServiceAction::InstallDesktop => service::install_desktop()?,
|
AutostartAction::InstallDesktop => autostart::install_desktop()?,
|
||||||
ServiceAction::UninstallDesktop => service::uninstall_desktop()?,
|
AutostartAction::UninstallDesktop => autostart::uninstall_desktop()?,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -4,7 +4,7 @@ use owo_colors::OwoColorize;
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::PathBuf;
|
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";
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ fn fetch_release(tag: &str) -> Result<Release> {
|
|||||||
fn install_version(config: &Config, tag: &str) -> Result<()> {
|
fn install_version(config: &Config, tag: &str) -> Result<()> {
|
||||||
let install_path = config.proton_compat_dir.join(tag);
|
let install_path = config.proton_compat_dir.join(tag);
|
||||||
if install_path.exists() {
|
if install_path.exists() {
|
||||||
println!("{tag} is already installed at {install_path:?}");
|
println!("{tag} is already installed at {}", install_path.display());
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,13 +83,13 @@ fn install_version(config: &Config, tag: &str) -> Result<()> {
|
|||||||
.context("Download returned an error status")?;
|
.context("Download returned an error status")?;
|
||||||
let total = resp.content_length();
|
let total = resp.content_length();
|
||||||
let f = std::fs::File::create(&tmp_path)
|
let f = std::fs::File::create(&tmp_path)
|
||||||
.with_context(|| format!("Failed to create temp file {tmp_path:?}"))?;
|
.with_context(|| format!("Failed to create temp file {}", tmp_path.display()))?;
|
||||||
let mut progress = ProgressWriter::new(f, total);
|
let mut progress = ProgressWriter::new(f, total);
|
||||||
std::io::copy(&mut resp, &mut progress).context("Failed to stream archive to disk")?;
|
std::io::copy(&mut resp, &mut progress).context("Failed to stream archive to disk")?;
|
||||||
progress.finish();
|
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)?;
|
std::fs::create_dir_all(&config.proton_compat_dir)?;
|
||||||
|
|
||||||
let status = std::process::Command::new("tar")
|
let status = std::process::Command::new("tar")
|
||||||
@@ -110,10 +110,10 @@ fn install_version(config: &Config, tag: &str) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Return all GE-Proton* directories found in `dir`.
|
/// Return all GE-Proton* directories found in `dir`.
|
||||||
fn scan_ge_proton_in(dir: &PathBuf, seen: &mut HashSet<String>, out: &mut Vec<String>) {
|
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 };
|
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
|
if !entry.file_type().is_ok_and(|t| t.is_dir()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let name = entry.file_name().to_string_lossy().to_string();
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
|||||||
+59
-40
@@ -4,7 +4,7 @@ use crate::{
|
|||||||
btn_accent, btn_ghost, card_style, icon, sub_card_style, surface_bg, ACCENT,
|
btn_accent, btn_ghost, card_style, icon, sub_card_style, surface_bg, ACCENT,
|
||||||
DIM, GREEN, MUTED, RED, SURFACE_RAISED,
|
DIM, GREEN, MUTED, RED, SURFACE_RAISED,
|
||||||
},
|
},
|
||||||
util::{async_blocking, pick_folder},
|
util::pick_folder,
|
||||||
};
|
};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use iced::widget::{
|
use iced::widget::{
|
||||||
@@ -41,6 +41,7 @@ pub enum Message {
|
|||||||
LaunchNow,
|
LaunchNow,
|
||||||
Close,
|
Close,
|
||||||
ToggleLog,
|
ToggleLog,
|
||||||
|
FontLoaded,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -153,7 +154,7 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
|
|||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::BrowsePrefix => Task::perform(
|
Message::BrowsePrefix => Task::perform(
|
||||||
async_blocking(|| pick_folder("Choose install location (Wine prefix)")),
|
async { tokio::task::spawn_blocking(|| pick_folder("Choose install location (Wine prefix)")).await.expect("blocking task panicked") },
|
||||||
Message::BrowsePrefixDone,
|
Message::BrowsePrefixDone,
|
||||||
),
|
),
|
||||||
Message::BrowsePrefixDone(path) => {
|
Message::BrowsePrefixDone(path) => {
|
||||||
@@ -203,7 +204,7 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
|
|||||||
.clone();
|
.clone();
|
||||||
let progress = state.download.clone();
|
let progress = state.download.clone();
|
||||||
return Task::perform(
|
return Task::perform(
|
||||||
async_blocking(move || download_blocking(&url, &name, progress)),
|
async { tokio::task::spawn_blocking(move || download_blocking(&url, &name, progress)).await.expect("blocking task panicked") },
|
||||||
Message::PrepareDone,
|
Message::PrepareDone,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -258,7 +259,7 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
|
|||||||
.clone();
|
.clone();
|
||||||
let progress = state.download.clone();
|
let progress = state.download.clone();
|
||||||
Task::perform(
|
Task::perform(
|
||||||
async_blocking(move || download_blocking(&url, &name, progress)),
|
async { tokio::task::spawn_blocking(move || download_blocking(&url, &name, progress)).await.expect("blocking task panicked") },
|
||||||
Message::PrepareDone,
|
Message::PrepareDone,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -302,7 +303,7 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
|
|||||||
.clone();
|
.clone();
|
||||||
let progress = state.download.clone();
|
let progress = state.download.clone();
|
||||||
Task::perform(
|
Task::perform(
|
||||||
async_blocking(move || download_blocking(&src, &name, progress)),
|
async { tokio::task::spawn_blocking(move || download_blocking(&src, &name, progress)).await.expect("blocking task panicked") },
|
||||||
Message::PrepareDone,
|
Message::PrepareDone,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -345,7 +346,7 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
|
|||||||
.expect("launcher set before install");
|
.expect("launcher set before install");
|
||||||
let log = state.log.clone();
|
let log = state.log.clone();
|
||||||
Task::perform(
|
Task::perform(
|
||||||
async_blocking(move || run_installer(&config, &launcher, &installer, log)),
|
async { tokio::task::spawn_blocking(move || run_installer(&config, &launcher, &installer, log)).await.expect("blocking task panicked") },
|
||||||
Message::InstallDone,
|
Message::InstallDone,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -392,6 +393,7 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
|
|||||||
iced::exit()
|
iced::exit()
|
||||||
}
|
}
|
||||||
Message::Close => iced::exit(),
|
Message::Close => iced::exit(),
|
||||||
|
Message::FontLoaded => Task::none(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,7 +498,7 @@ fn view_picking(state: &State) -> Element<'_, Message> {
|
|||||||
.padding(Padding::from([6, 0]))
|
.padding(Padding::from([6, 0]))
|
||||||
.into()
|
.into()
|
||||||
} else {
|
} else {
|
||||||
Space::new(0, 0).into()
|
Space::new().into()
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Next button ───────────────────────────────────────────────────────────
|
// ── Next button ───────────────────────────────────────────────────────────
|
||||||
@@ -618,7 +620,7 @@ fn view_install(state: &State) -> Element<'_, Message> {
|
|||||||
.padding([6, 14])
|
.padding([6, 14])
|
||||||
.into()
|
.into()
|
||||||
} else {
|
} else {
|
||||||
Space::new(0, 0).into()
|
Space::new().into()
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Header ────────────────────────────────────────────────────────────────
|
// ── Header ────────────────────────────────────────────────────────────────
|
||||||
@@ -630,7 +632,7 @@ fn view_install(state: &State) -> Element<'_, Message> {
|
|||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
.spacing(0),
|
.spacing(0),
|
||||||
iced::widget::horizontal_space(),
|
Space::new().width(Length::Fill),
|
||||||
back_el,
|
back_el,
|
||||||
]
|
]
|
||||||
.align_y(Alignment::Start);
|
.align_y(Alignment::Start);
|
||||||
@@ -649,7 +651,7 @@ fn view_install(state: &State) -> Element<'_, Message> {
|
|||||||
.into();
|
.into();
|
||||||
section_card(inner)
|
section_card(inner)
|
||||||
} else {
|
} else {
|
||||||
Space::new(0, 0).into()
|
Space::new().into()
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Status card ───────────────────────────────────────────────────────────
|
// ── Status card ───────────────────────────────────────────────────────────
|
||||||
@@ -668,7 +670,7 @@ fn view_install(state: &State) -> Element<'_, Message> {
|
|||||||
StatusKind::Neutral,
|
StatusKind::Neutral,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Space::new(0, 0).into()
|
Space::new().into()
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Progress bar ──────────────────────────────────────────────────────────
|
// ── Progress bar ──────────────────────────────────────────────────────────
|
||||||
@@ -688,7 +690,7 @@ fn view_install(state: &State) -> Element<'_, Message> {
|
|||||||
.spacing(6),
|
.spacing(6),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Space::new(0, 0).into()
|
Space::new().into()
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Finished banner ───────────────────────────────────────────────────────
|
// ── Finished banner ───────────────────────────────────────────────────────
|
||||||
@@ -719,7 +721,7 @@ fn view_install(state: &State) -> Element<'_, Message> {
|
|||||||
})
|
})
|
||||||
.into()
|
.into()
|
||||||
} else {
|
} else {
|
||||||
Space::new(0, 0).into()
|
Space::new().into()
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Log toggle + pane ─────────────────────────────────────────────────────
|
// ── Log toggle + pane ─────────────────────────────────────────────────────
|
||||||
@@ -755,7 +757,7 @@ fn view_install(state: &State) -> Element<'_, Message> {
|
|||||||
toggle_btn.into()
|
toggle_btn.into()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Space::new(0, 0).into()
|
Space::new().into()
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Action button ─────────────────────────────────────────────────────────
|
// ── Action button ─────────────────────────────────────────────────────────
|
||||||
@@ -830,7 +832,7 @@ fn view_install(state: &State) -> Element<'_, Message> {
|
|||||||
.align_x(Alignment::End)
|
.align_x(Alignment::End)
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
Stage::Picking => Space::new(0, 0).into(),
|
Stage::Picking => Space::new().into(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Assembly ──────────────────────────────────────────────────────────────
|
// ── Assembly ──────────────────────────────────────────────────────────────
|
||||||
@@ -857,42 +859,59 @@ pub fn run(config: &Config, launcher: &Launcher) -> Result<()> {
|
|||||||
let config = config.clone();
|
let config = config.clone();
|
||||||
let launcher = launcher.clone();
|
let launcher = launcher.clone();
|
||||||
let title = format!("umutray — {}", launcher.display);
|
let title = format!("umutray — {}", launcher.display);
|
||||||
iced::application(move |_: &State| title.clone(), update, view)
|
let has_url = launcher.installer_url.is_some();
|
||||||
.subscription(subscription)
|
let url = launcher.installer_url.clone().unwrap_or_default();
|
||||||
.theme(|_| Theme::Dark)
|
iced::application(
|
||||||
.window(iced::window::Settings {
|
move || {
|
||||||
size: iced::Size::new(520.0, 440.0),
|
|
||||||
resizable: false,
|
|
||||||
..Default::default()
|
|
||||||
})
|
|
||||||
.run_with(move || {
|
|
||||||
let has_url = launcher.installer_url.is_some();
|
|
||||||
let mut state = State::new_install(config.clone(), launcher.clone());
|
let mut state = State::new_install(config.clone(), launcher.clone());
|
||||||
if has_url {
|
if has_url {
|
||||||
state.source = launcher.installer_url.unwrap_or_default();
|
state.source = url.clone();
|
||||||
}
|
}
|
||||||
|
let load_font = iced::font::load(iced_fonts::BOOTSTRAP_FONT_BYTES)
|
||||||
|
.map(|_| Message::FontLoaded);
|
||||||
let init_task = if has_url {
|
let init_task = if has_url {
|
||||||
Task::done(Message::AutoDownload)
|
Task::batch([load_font, Task::done(Message::AutoDownload)])
|
||||||
} else {
|
} else {
|
||||||
Task::none()
|
load_font
|
||||||
};
|
};
|
||||||
(state, init_task)
|
(state, init_task)
|
||||||
})
|
},
|
||||||
.map_err(|e| anyhow::anyhow!("iced: {e}"))
|
update,
|
||||||
|
view,
|
||||||
|
)
|
||||||
|
.title(move |_: &State| title.clone())
|
||||||
|
.subscription(subscription)
|
||||||
|
.theme(Theme::Dark)
|
||||||
|
.window(iced::window::Settings {
|
||||||
|
size: iced::Size::new(520.0, 440.0),
|
||||||
|
resizable: false,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
.map_err(|e| anyhow::anyhow!("iced: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run_new(config: &Config) -> Result<()> {
|
pub fn run_new(config: &Config) -> Result<()> {
|
||||||
let config = config.clone();
|
let config = config.clone();
|
||||||
iced::application(|_: &State| "umutray — Add Launcher".to_string(), update, view)
|
iced::application(
|
||||||
.subscription(subscription)
|
move || {
|
||||||
.theme(|_| Theme::Dark)
|
let load_font = iced::font::load(iced_fonts::BOOTSTRAP_FONT_BYTES)
|
||||||
.window(iced::window::Settings {
|
.map(|_| Message::FontLoaded);
|
||||||
size: iced::Size::new(520.0, 440.0),
|
(State::new_picking(config.clone()), load_font)
|
||||||
resizable: false,
|
},
|
||||||
..Default::default()
|
update,
|
||||||
})
|
view,
|
||||||
.run_with(move || (State::new_picking(config.clone()), Task::none()))
|
)
|
||||||
.map_err(|e| anyhow::anyhow!("iced: {e}"))
|
.title(|_: &State| "umutray — Add Launcher".to_string())
|
||||||
|
.subscription(subscription)
|
||||||
|
.theme(Theme::Dark)
|
||||||
|
.window(iced::window::Settings {
|
||||||
|
size: iced::Size::new(520.0, 440.0),
|
||||||
|
resizable: false,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
.map_err(|e| anyhow::anyhow!("iced: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -55,7 +55,7 @@ pub const BORDER_CLR: Color = Color {
|
|||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Bootstrap-icon helper — keeps call sites tidy.
|
/// Bootstrap-icon helper — keeps call sites tidy.
|
||||||
pub fn icon(codepoint: &str, size: u16) -> iced::widget::Text<'static> {
|
pub fn icon(codepoint: &str, size: u32) -> iced::widget::Text<'static> {
|
||||||
text(codepoint.to_owned())
|
text(codepoint.to_owned())
|
||||||
.font(iced::Font::with_name("bootstrap-icons"))
|
.font(iced::Font::with_name("bootstrap-icons"))
|
||||||
.size(size)
|
.size(size)
|
||||||
@@ -93,6 +93,7 @@ pub fn btn_accent(_theme: &Theme, status: button::Status) -> button::Style {
|
|||||||
radius: 8.0.into(),
|
radius: 8.0.into(),
|
||||||
},
|
},
|
||||||
shadow: NO_SHADOW,
|
shadow: NO_SHADOW,
|
||||||
|
snap: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +133,7 @@ pub fn btn_ghost(_theme: &Theme, status: button::Status) -> button::Style {
|
|||||||
radius: 8.0.into(),
|
radius: 8.0.into(),
|
||||||
},
|
},
|
||||||
shadow: NO_SHADOW,
|
shadow: NO_SHADOW,
|
||||||
|
snap: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +159,7 @@ pub fn btn_danger(_theme: &Theme, status: button::Status) -> button::Style {
|
|||||||
radius: 8.0.into(),
|
radius: 8.0.into(),
|
||||||
},
|
},
|
||||||
shadow: NO_SHADOW,
|
shadow: NO_SHADOW,
|
||||||
|
snap: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+39
-32
@@ -1,33 +1,39 @@
|
|||||||
use crate::{config::Config, launcher};
|
use crate::{config::Config, launcher};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
fn spawn_setup(name: &str) {
|
fn spawn_setup(config: &Config, name: &str) {
|
||||||
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("umutray"));
|
let config = config.clone();
|
||||||
if let Err(e) = std::process::Command::new(exe)
|
let name = name.to_owned();
|
||||||
.arg("setup")
|
thread::spawn(move || {
|
||||||
.arg(name)
|
if let Some(l) = config.find(&name) {
|
||||||
.spawn()
|
let l = l.clone();
|
||||||
{
|
if let Err(e) = crate::setup::run(&config, &l) {
|
||||||
eprintln!("umutray: failed to launch setup for {name}: {e}");
|
eprintln!("umutray: setup for {name} failed: {e}");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_gui() {
|
fn spawn_gui(config: &Config) {
|
||||||
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("umutray"));
|
let config = config.clone();
|
||||||
if let Err(e) = std::process::Command::new(exe).arg("gui").spawn() {
|
thread::spawn(move || {
|
||||||
eprintln!("umutray: failed to launch dashboard: {e}");
|
match crate::gui::run(&config) {
|
||||||
}
|
Ok(_) => {}
|
||||||
|
Err(e) => eprintln!("umutray: failed to launch dashboard: {e}"),
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_setup_picker() {
|
fn spawn_setup_picker(config: &Config) {
|
||||||
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("umutray"));
|
let config = config.clone();
|
||||||
if let Err(e) = std::process::Command::new(exe).arg("setup").spawn() {
|
thread::spawn(move || {
|
||||||
eprintln!("umutray: failed to launch setup picker: {e}");
|
if let Err(e) = crate::setup::run_new(&config) {
|
||||||
}
|
eprintln!("umutray: failed to launch setup picker: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
enum GameFlag {
|
enum GameFlag {
|
||||||
@@ -79,9 +85,9 @@ pub struct UmuTray {
|
|||||||
pub config: Config,
|
pub config: Config,
|
||||||
/// Per-launcher running state keyed by launcher.name
|
/// Per-launcher running state keyed by launcher.name
|
||||||
pub running: HashMap<String, bool>,
|
pub running: HashMap<String, bool>,
|
||||||
/// Set after the service spawns so Quit can shut down the SNI item
|
/// Set after the tray spawns so Quit can shut down the SNI item
|
||||||
/// cleanly instead of yanking it off the bus via exit().
|
/// cleanly instead of yanking it off the bus via exit().
|
||||||
pub handle: Option<ksni::Handle<UmuTray>>,
|
pub handle: Option<ksni::blocking::Handle<UmuTray>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ksni::Tray for UmuTray {
|
impl ksni::Tray for UmuTray {
|
||||||
@@ -90,7 +96,7 @@ impl ksni::Tray for UmuTray {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn icon_name(&self) -> String {
|
fn icon_name(&self) -> String {
|
||||||
"applications-games".into()
|
"umutray".into()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn title(&self) -> String {
|
fn title(&self) -> String {
|
||||||
@@ -107,7 +113,7 @@ impl ksni::Tray for UmuTray {
|
|||||||
StandardItem {
|
StandardItem {
|
||||||
label: "Open Dashboard".into(),
|
label: "Open Dashboard".into(),
|
||||||
icon_name: "applications-games".into(),
|
icon_name: "applications-games".into(),
|
||||||
activate: Box::new(|_: &mut Self| spawn_gui()),
|
activate: Box::new(|this: &mut Self| spawn_gui(&this.config)),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
.into(),
|
.into(),
|
||||||
@@ -126,8 +132,8 @@ impl ksni::Tray for UmuTray {
|
|||||||
StandardItem {
|
StandardItem {
|
||||||
label: format!("Setup {display}…"),
|
label: format!("Setup {display}…"),
|
||||||
icon_name: "document-new".into(),
|
icon_name: "document-new".into(),
|
||||||
activate: Box::new(move |_this: &mut Self| {
|
activate: Box::new(move |this: &mut Self| {
|
||||||
spawn_setup(&setup_name);
|
spawn_setup(&this.config, &setup_name);
|
||||||
}),
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
@@ -144,7 +150,7 @@ impl ksni::Tray for UmuTray {
|
|||||||
icon_name: "process-stop".into(),
|
icon_name: "process-stop".into(),
|
||||||
activate: Box::new(move |this: &mut Self| {
|
activate: Box::new(move |this: &mut Self| {
|
||||||
if let Some(l) = this.config.find(&kill_name) {
|
if let Some(l) = this.config.find(&kill_name) {
|
||||||
let _ = launcher::kill(l);
|
launcher::kill(l);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -245,7 +251,7 @@ impl ksni::Tray for UmuTray {
|
|||||||
StandardItem {
|
StandardItem {
|
||||||
label: "Add Launcher…".into(),
|
label: "Add Launcher…".into(),
|
||||||
icon_name: "list-add".into(),
|
icon_name: "list-add".into(),
|
||||||
activate: Box::new(|_: &mut Self| spawn_setup_picker()),
|
activate: Box::new(|this: &mut Self| spawn_setup_picker(&this.config)),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
.into(),
|
.into(),
|
||||||
@@ -306,7 +312,7 @@ pub fn run(config: &Config) -> Result<()> {
|
|||||||
|
|
||||||
/// A handle that can shut down the tray from another thread.
|
/// A handle that can shut down the tray from another thread.
|
||||||
pub struct TrayHandle {
|
pub struct TrayHandle {
|
||||||
inner: ksni::Handle<UmuTray>,
|
inner: ksni::blocking::Handle<UmuTray>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TrayHandle {
|
impl TrayHandle {
|
||||||
@@ -329,9 +335,10 @@ pub fn spawn(config: &Config) -> TrayHandle {
|
|||||||
handle: None,
|
handle: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let service = ksni::TrayService::new(tray);
|
let handle = {
|
||||||
let handle = service.handle();
|
use ksni::blocking::TrayMethods;
|
||||||
service.spawn();
|
tray.spawn().expect("Failed to spawn tray service")
|
||||||
|
};
|
||||||
|
|
||||||
// Hand the tray a clone of its own handle so Quit can shut down cleanly.
|
// Hand the tray a clone of its own handle so Quit can shut down cleanly.
|
||||||
let handle_for_self = handle.clone();
|
let handle_for_self = handle.clone();
|
||||||
|
|||||||
+11
-56
@@ -1,63 +1,18 @@
|
|||||||
use iced::futures::channel::oneshot;
|
|
||||||
|
|
||||||
/// Run a blocking closure on a thread pool thread and await its result.
|
|
||||||
/// Used to offload blocking work (HTTP, disk, process spawning) without
|
|
||||||
/// stalling the iced event loop.
|
|
||||||
pub async fn async_blocking<T, F>(f: F) -> T
|
|
||||||
where
|
|
||||||
T: Send + 'static,
|
|
||||||
F: FnOnce() -> T + Send + 'static,
|
|
||||||
{
|
|
||||||
let (tx, rx) = oneshot::channel();
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
let _ = tx.send(f());
|
|
||||||
});
|
|
||||||
rx.await.expect("blocking task panicked")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open a native folder picker dialog and return the chosen path, or None if
|
/// Open a native folder picker dialog and return the chosen path, or None if
|
||||||
/// the user cancelled. Tries zenity (GNOME/GTK) then kdialog (KDE) in order.
|
/// the user cancelled. Uses XDG Desktop Portal where available.
|
||||||
pub fn pick_folder(title: &str) -> Option<String> {
|
pub fn pick_folder(title: &str) -> Option<String> {
|
||||||
for (cmd, args) in [
|
rfd::FileDialog::new()
|
||||||
("zenity", vec!["--file-selection", "--directory", "--title", title]),
|
.set_title(title)
|
||||||
("kdialog", vec!["--getexistingdirectory", "/home", "--title", title]),
|
.pick_folder()
|
||||||
] {
|
.map(|p| p.to_string_lossy().into_owned())
|
||||||
let Ok(out) = std::process::Command::new(cmd).args(&args).output() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if out.status.success() {
|
|
||||||
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
|
||||||
if !s.is_empty() {
|
|
||||||
return Some(s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open a native file picker dialog starting in `start_dir`, or None if
|
/// Open a native file picker dialog starting in `start_dir`, or None if
|
||||||
/// the user cancelled. Tries zenity then kdialog.
|
/// the user cancelled. Uses XDG Desktop Portal where available.
|
||||||
pub fn pick_file(title: &str, start_dir: &str) -> Option<String> {
|
pub fn pick_file(title: &str, start_dir: &str) -> Option<String> {
|
||||||
// zenity uses --filename with a trailing slash to open a directory
|
rfd::FileDialog::new()
|
||||||
let start_slash = format!("{}/", start_dir.trim_end_matches('/'));
|
.set_title(title)
|
||||||
let zenity_args = vec![
|
.set_directory(start_dir)
|
||||||
"--file-selection",
|
.pick_file()
|
||||||
"--title",
|
.map(|p| p.to_string_lossy().into_owned())
|
||||||
title,
|
|
||||||
"--filename",
|
|
||||||
&start_slash,
|
|
||||||
];
|
|
||||||
let kdialog_args = vec!["--getopenfilename", start_dir, "--title", title];
|
|
||||||
for (cmd, args) in [("zenity", zenity_args.as_slice()), ("kdialog", kdialog_args.as_slice())] {
|
|
||||||
let Ok(out) = std::process::Command::new(cmd).args(args).output() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if out.status.success() {
|
|
||||||
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
|
||||||
if !s.is_empty() {
|
|
||||||
return Some(s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
Name=umutray
|
Name=umutray
|
||||||
Comment=Wine launcher manager for Windows game launchers
|
Comment=Wine launcher manager for Windows game launchers
|
||||||
Exec=umutray gui
|
Exec=umutray gui
|
||||||
Icon=applications-games
|
Icon=umutray
|
||||||
Type=Application
|
Type=Application
|
||||||
Categories=Game;
|
Categories=Game;
|
||||||
Keywords=wine;proton;gaming;launcher;
|
Keywords=wine;proton;gaming;launcher;
|
||||||
|
|||||||
Reference in New Issue
Block a user