Compare commits

..

27 Commits

Author SHA1 Message Date
funman300 4845ebe4f8 fix: update all dependencies, remove pelite and directories crates
- Update iced 0.13 -> 0.14 (API migration: application builder, theme, font loading)
- Update ksni 0.2 -> 0.3 (blocking API)
- Update dirs 5 -> 6, toml 0.8 -> 1, reqwest 0.12 -> 0.13, rfd 0.15 -> 0.17, iced_fonts 0.1 -> 0.3
- Remove pelite dependency (PE file parsing was unreliable and unnecessary)
- Remove directories dependency (consolidated with dirs crate)

Closes #8, #9, #10
2026-04-19 15:30:46 -07:00
funman300 c1893f9f64 refactor: rename service to autostart, fix fork bomb, add rfd file picker, use dirs crate, auto-start launcher, propagate overlays, ensure icon
- Rename service module to autostart (no systemd service is used)
- Fix fork bomb: replace subprocess spawning with thread::spawn
- Replace zenity/kdialog with rfd crate for XDG Portal file picker
- Use dirs crate instead of env::var("HOME")
- Auto-start launcher before game launch for online auth
- Propagate gamemode/mangohud env vars to launcher process
- Auto-install SVG icon on startup via ensure_icon()
- Add assets/umutray.svg
- Remove stale zenity/kdialog optdepends from PKGBUILD
- Update .gitignore for .claude/ and CLAUDE.md
2026-04-19 13:02:32 -07:00
funman300 2f4f1c64d2 refactor: idiomatic Rust cleanup and quality improvements
- Replace .map().unwrap_or(false) with .is_some_and()/.is_ok_and()
- Use path.display() instead of {:?} for user-facing messages
- Replace Option<Option<Vec<String>>> with GamescopeUpdate enum
- Replace manual parent-walking loops with .ancestors() iterators
- Simplify kill()/kill_all() signatures to return () instead of Result
- Use tokio::task::spawn_blocking instead of hand-rolled thread+oneshot
- Read /proc/self/status for UID instead of spawning id subprocess
- Build Exec= line directly in render_desktop instead of string-replace
- Bump PKGBUILD pkgrel to 6
2026-04-19 11:29:42 -07:00
funman300 8447581fe6 detect: fix SKIP_DIRS blocking game discovery for Epic, Ubisoft, Rockstar
SKIP_DIRS contained parent directories that also contain game installs,
which prevented those games from ever being scanned:
  - "epic games"     → all Epic games live inside this dir
  - "ubisoft"        → Ubisoft games at Ubisoft/Ubisoft Game Launcher/games/
  - "rockstar games" → Rockstar games live directly inside this dir
  - "electronic arts" → some EA games live here

SKIP_EXES already filters the individual launcher executables, so the
directory-level blocks are redundant and harmful. Trim SKIP_DIRS to only
directories that genuinely contain no game executables (battle.net,
ea desktop, gog galaxy, Wine infrastructure).

Add missing launcher path patterns to name_from_launcher_path:
  - "ea games"              → EA Games/<GameName>/
  - "ubisoft game launcher" → Ubisoft Game Launcher/games/<GameName>/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 10:56:42 -07:00
funman300 a0ee01cd5d detect: read Legendary/Heroic installed.json, remove name generation
Add stage 2: read Legendary and Heroic's installed.json files from all
known locations (native + Flatpak) to build a install-path → title map.
This covers Epic games (via Legendary) and GOG games (via Heroic's GOG
store) before any fallback is needed.

Remove CamelCase/digit-boundary splitting from the fallback entirely.
If stages 2-4 all miss, nearest_dir_name() returns the closest
non-generic parent directory name, or the raw exe stem as-is. No names
are fabricated from the exe filename.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 10:52:55 -07:00
funman300 aeed52d6dd setup: load Bootstrap Icons font so icons render in the wizard
Both run() and run_new() were starting with Task::none(), so the
Bootstrap font was never loaded. All icon() calls in the setup wizard
rendered as '?' glyphs. Load the font as the initial task (batched
with AutoDownload where needed) and handle FontLoaded as a no-op.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 10:45:42 -07:00
funman300 2b538a286a detect: resolve game names from install directory structure, not guesswork
Remove the hardcoded EXE_OVERRIDES lookup table and the unreliable PE
byte scanner. Game names are already present in the install directory —
we just need to read them from the right place.

Resolution pipeline (first hit wins):
1. Explicit name supplied by the caller
2. Manifest walk: traverse up from the exe to the game root looking for
   GOG goggame-*.info (gameName) and Epic .egstore/*.item (DisplayName)
3. Launcher path: read the game name from known directory conventions
   laid down by the launcher itself:
   - Epic Games/<GameName>/…
   - GOG Games/<GameName>/…
   - steamapps/common/<GameName>/…
   - Rockstar Games/<GameName>/…
4. Heuristic: nearest non-generic parent directory name, or CamelCase
   stem split (unchanged, for truly custom/manual installs)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 10:40:09 -07:00
funman300 e213377a95 detect: replace exe-name heuristic with multi-stage resolution pipeline
Introduce resolve_game_name() as the single entry point for deriving a
display name from a game executable. Resolution order:
  1. Explicit name (caller-supplied)
  2. Static override table for known bad stems (FactoryGame, bg3, etc.)
  3. GOG goggame-*.info and Epic .egstore/*.item manifest JSON files
  4. PE VERSIONINFO scan (ProductName / FileDescription, first 8 MB)
  5. Heuristic fallback: parent directory name or CamelCase stem split

Remove prettify_game_name and humanise_stem; expose prettify_exe_name
as the public heuristic-only fallback. Resolved names are cached in a
process-wide LazyLock<Mutex<HashMap>> so repeated scans are free.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 10:31:43 -07:00
funman300 f3f5046265 chore: cleanup for push to main
- Remove CLAUDE.md, TODO.md (dev-only task trackers)
- Remove umutray.service (unused systemd unit)
- Remove .vscode/settings.json (stale Makefile ref)
- Add src/theme.rs (shared palette/styling module)
- Update .gitignore: exclude .vscode/, packaging build artifacts
- Fix README: add gui command, correct service description
- Delete ~1.3GB packaging build artifacts from working tree

Code changes from prior session (already committed locally):
- Tray icon launches alongside GUI, close dialog with minimize-to-tray
- Theme module extraction, button shadow fixes, UI polish
- Game detection filtering, prettify_game_name, Battle.net fix
2026-04-19 02:05:10 -07:00
funman300 4e204d4bf7 detect: filter blizzard tools, error/repair/diagnostic exes 2026-04-19 01:08:24 -07:00
funman300 d3ac300b91 Redesign launcher cards: icon buttons, proton badge, pill toggles, sub-cards, better header 2026-04-19 01:04:30 -07:00
funman300 3c1742174b gui: overhaul games section with polished professional layout 2026-04-19 00:56:52 -07:00
funman300 b81c7fd863 detect: filter out launcher tools and non-game exes from game scan 2026-04-19 00:53:36 -07:00
funman300 a1afa59f1a settings: add Launch Protontricks button 2026-04-19 00:47:26 -07:00
funman300 32c6e1fce0 setup: auto-download official installer for existing launchers too 2026-04-19 00:40:04 -07:00
funman300 20509fb488 gui: transition to tray on close, auto-download official installers in setup 2026-04-19 00:36:22 -07:00
funman300 f645b58470 chore(settings): remove self-update / Rebuild & Install feature
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 23:52:07 -07:00
funman300 9ad1e6a745 refactor(setup): complete UX overhaul of the install wizard
- Step indicator (1→2→3) on both screens showing current position
- Fixed window size (520×440, non-resizable) for consistent presentation
- view_install redesigned with card-based layout matching view_picking
- Status messages colour-coded: blue=info, green=success, red=error
- Official installer shown as a badge ("✓ Official installer") — URL hidden
- Download/install progress in a styled card with byte counter
- Finished state has distinct success (green border) / failure (red border) banners
- Installation log collapsed by default behind "Show details ▼" toggle
- Removed "via umu-run" and other internal tool references from user-facing text
- Removed raw "Paste a URL…" initial status — context is clear from the card
- Window title simplified to "umutray — <Launcher Name>"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 23:49:36 -07:00
funman300 f70498158a feat(settings): add Rebuild & Install self-update button
Settings panel now shows the current version and a "Rebuild & Install
Latest" button that:
  1. git pull from the embedded source directory (CARGO_MANIFEST_DIR)
  2. makepkg -sf in packaging/
  3. pkexec pacman -U (graphical polkit auth prompt)

Reports the installed version on success; surfaces the failing step on
error. Update runs off the UI thread so the window stays responsive.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 23:41:07 -07:00
funman300 3c78e1586f refactor(setup): redesign launcher picking screen
Replaced the flat form layout with a polished card-based design:
- Section cards with subtle borders matching the dashboard style
- Blue accent labels for section headings (Launcher, Install Location)
- Hint text explaining the Wine prefix folder
- Error status styled in orange-red instead of plain text
- Next button right-aligned with top spacing
- Header with muted subtitle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 23:37:26 -07:00
funman300 0c22e23ad3 fix(gui): load Bootstrap Icons font via Task instead of builder
The .font() builder method was silently failing to register the font
for named lookup. Using iced::font::load() as a startup Task ensures
the font is properly loaded before any text rendering occurs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 23:34:22 -07:00
funman300 108f385973 fix(gui): correct Bootstrap Icons codepoint for gear icon
Font version in iced_fonts 0.1.1 maps GearFill to U+F3E2, not U+F3F8.
Using the wrong codepoint rendered as a question-mark placeholder.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 23:28:12 -07:00
funman300 d97a13e289 chore(packaging): remove systemd service from PKGBUILD, bump pkgrel
The app now uses XDG autostart (~/.config/autostart/umutray.desktop)
managed via the Settings panel, not systemd. The service file is no
longer installed by the package.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 23:24:12 -07:00
funman300 2e51b2e788 refactor(ui): cleaner setup wizard and dashboard card visuals
- setup.rs: remove raw prefix/expected path labels from install view;
  hide URL input when official installer is pre-filled (show green tick
  instead), revealing an override field only when needed
- gui.rs: drop raw exe path from scan result rows; add per-state colour
  to status indicator (green=running, blue=installed, grey=not installed)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 23:19:30 -07:00
funman300 c4587b0729 fix: downgrade iced_fonts to 0.1 for iced 0.13 compatibility
iced_fonts 0.3 pulls in iced_widget 0.14 / iced_renderer 0.14 which
breaks release builds when used alongside iced 0.13. Pin to 0.1.x
which targets iced 0.12/0.13 and drop the unused iced_aw dependency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 23:12:52 -07:00
funman300 d4f0515a82 refactor(setup): clean up setup wizard UX
- Hide raw filesystem paths from all status messages; use launcher
  display names instead (e.g. "Downloading Battle.net installer…")
- Simplify installer source label: show "✓ Official installer detected"
  badge when URL is auto-filled
- Replace separate "Download / Prepare" + "Run installer" buttons with
  a single context-aware action button whose label tracks the stage:
  Download → / Downloading… / Install → / Installing…
- Remove auto-download on launcher confirm; show confirmation prompt
  "Ready to download the official X installer. Press Download → to begin."
- Soften the exe-not-found error message; remove raw path and config
  editing advice — direct users to the log instead
- Rename "Launch now" → "Open launcher" for clarity
- Show "✓ X installed successfully!" success banner above Close/Open
  buttons when install completes and the exe is present

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 23:06:32 -07:00
funman300 156bb460a0 feat(gui): replace settings ⚙ placeholder with Bootstrap cog icon
Add iced_aw + iced_fonts (bootstrap feature) to load the Bootstrap Icons
font. The settings button and settings header now render the gear-fill
glyph (U+F3F8) via text("\u{F3F8}").font("bootstrap-icons") instead of
the raw ⚙ character which was not in iced's bundled Inter font.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 23:06:22 -07:00
23 changed files with 3404 additions and 2103 deletions
+9
View File
@@ -1 +1,10 @@
/target
.vscode/
.claude/
CLAUDE.md
# Packaging build artifacts
packaging/pkg/
packaging/src/
packaging/umutray/
packaging/*.pkg.tar.zst
-3
View File
@@ -1,3 +0,0 @@
{
"makefile.configureOnOpen": false
}
-22
View File
@@ -1,22 +0,0 @@
## umutray refactor tasks
Work through these issues identified in code review. Address them one at a time and confirm before moving on.
### Packaging
- [ ] Remove the Makefile and replace with a proper Arch PKGBUILD following https://wiki.archlinux.org/title/Rust_package_guidelines
- [ ] Create a separate repo for the PKGBUILD (keeps packaging out of source repo and makes it AUR-uploadable). Reference the local repo path in the PKGBUILD so it always builds the latest version without pushing/pulling.
### Systemd / tray architecture
- [ ] Remove runtime systemd unit file generation from Rust code — unit files should be static files shipped in the AUR package, not generated at runtime by the app.
- [ ] Reconsider the `service install` command — the tray icon should use the StatusNotifierItem/AppIndicator XDG protocol (https://www.freedesktop.org/wiki/Specifications/StatusNotifierItem) rather than a systemd service.
### Code quality
- [ ] Add `#![forbid(unsafe_code)]` to the top of main.rs to enforce safe Rust project-wide.
- [ ] Replace manual terminal color escape codes (in main.rs and detect.rs) with a crate like `colored` or `owo-colors`.
- [ ] Replace manual home directory path construction in config.rs (~L88) with the `dirs` crate.
### UX / GUI
- [ ] Fix blocking UI on long-running button actions (launch, kill, download) — use iced Command/async tasks so the UI keeps rendering and shows a loading state.
### Misc
- [ ] Audit and document or refactor the unclear code at main.rs:307.
Generated
+1052 -1251
View File
File diff suppressed because it is too large Load Diff
+11 -9
View File
@@ -19,7 +19,7 @@ clap = { version = "4", features = ["derive"] }
# Config serialisation
serde = { version = "1", features = ["derive"] }
toml = "0.8"
toml = "1"
# GitHub API responses
serde_json = "1"
@@ -27,20 +27,22 @@ serde_json = "1"
# Error handling
anyhow = "1"
# XDG config / data paths
directories = "5"
# Home directory lookup
dirs = "5"
# XDG config / data / home paths
dirs = "6"
# Terminal colour output
owo-colors = "4"
# System tray via D-Bus StatusNotifierItem (KDE, GNOME+AppIndicator, Xfce, etc.)
ksni = "0.2"
ksni = { version = "0.3", features = ["blocking"] }
# HTTP for GE-Proton GitHub releases API
reqwest = { version = "0.12", features = ["blocking", "json"] }
reqwest = { version = "0.13", features = ["blocking", "json"] }
# GUI for the setup wizard
iced = { version = "0.13", features = ["tokio"] }
iced = { version = "0.14", features = ["tokio"] }
iced_fonts = { version = "0.3", features = ["bootstrap"] }
tokio = { version = "1.52.1", features = ["rt"] }
# Native file dialogs via XDG Desktop Portal
rfd = "0.17"
+6 -5
View File
@@ -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.
- `diagnose` — sanity-checks umu-run, Vulkan, display server, per-launcher
prefix / exe / ownership / running state.
- `service` — installs a `systemd --user` unit so the tray autostarts with
- `autostart` — manages the XDG autostart entry so the tray starts with
the graphical session.
- `setup` — graphical wizard (iced) that downloads an installer URL
(with progress bar) or accepts a local `.exe`, then runs it via
@@ -51,7 +51,7 @@ sudo pacman -S umu-launcher vulkan-tools
Then enable autostart:
```sh
umutray service install
umutray autostart install
```
## Usage
@@ -59,6 +59,7 @@ umutray service install
| Command | What it does |
| -------------------------------- | ------------------------------------------------------- |
| `umutray` | Start the tray daemon (default) |
| `umutray gui` | Open the graphical dashboard (with tray icon) |
| `umutray launchers` | List configured launchers and their state |
| `umutray launch <name>` | Launch a specific launcher (e.g. `umutray launch epic`) |
| `umutray kill [<name>]` | Kill one launcher, or all if no name is given |
@@ -78,9 +79,9 @@ umutray service install
| `umutray config add-game …` | Attach a game to a launcher (needs `--exe-path`) |
| `umutray config remove-game …` | Drop a game from a launcher |
| `umutray config set-game-flags …`| Per-game overlay toggles: gamemode/mangohud/gamescope |
| `umutray service install` | Write + enable a `systemd --user` unit |
| `umutray service uninstall` | Stop, disable, and remove the unit |
| `umutray service status` | `systemctl --user status umutray.service` |
| `umutray autostart install` | Write XDG autostart entry (tray starts on login) |
| `umutray autostart uninstall` | Remove the autostart and desktop entries |
| `umutray autostart status` | Show whether XDG autostart is enabled |
## Config
-8
View File
@@ -1,8 +0,0 @@
# Project Tasks
- [ ] automatically detect all wine and proton versions installed and have a drop down selection menu globally and for each launcher entry
- [ ] Change the settings button to a cog wheel icon
- [ ] Overhaul the settings menu
- [ ] Overhaul the main dashboard
- [ ] Prefix Dependancy Manager
- { } A speical option for world of warcraft game installs to let you install and launcher the curse forge mod manager within the world of warcraft prefix. Following the trent of modularity and a simplistic approach
+1
View File
@@ -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 -9
View File
@@ -12,7 +12,7 @@
pkgname=umutray
pkgver=0.1.0
pkgrel=1
pkgrel=6
pkgdesc='Tray-based Wine launcher manager for Linux via umu/Proton-GE'
arch=('x86_64')
url='https://git.aleshym.co/funman300/umutray'
@@ -20,8 +20,6 @@ license=('MIT')
depends=('umu-launcher')
makedepends=('rust' 'cargo')
optdepends=(
'zenity: folder picker in setup wizard (GNOME/GTK)'
'kdialog: folder picker in setup wizard (KDE)'
'gamemode: per-game GameMode support'
'mangohud: per-game MangoHud overlay'
'gamescope: per-game Gamescope compositor'
@@ -51,14 +49,11 @@ package() {
# Binary
install -Dm755 target/release/umutray "$pkgdir/usr/bin/umutray"
# App menu entry (.desktop uses /usr/bin/umutray as the exec path)
# App menu entry
install -Dm644 umutray.desktop "$pkgdir/usr/share/applications/umutray.desktop"
sed -i "s|Exec=umutray|Exec=/usr/bin/umutray|" \
"$pkgdir/usr/share/applications/umutray.desktop"
# Systemd user service (static file — no runtime generation needed)
install -Dm644 umutray.service \
"$pkgdir/usr/lib/systemd/user/umutray.service"
# Icon
install -Dm644 assets/umutray.svg "$pkgdir/usr/share/icons/hicolor/scalable/apps/umutray.svg"
# License
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
+59 -23
View File
@@ -1,8 +1,9 @@
use anyhow::{Context, Result};
use owo_colors::OwoColorize;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
const DESKTOP_NAME: &str = "umutray.desktop";
const ICON_SVG: &[u8] = include_bytes!("../assets/umutray.svg");
fn home() -> Result<PathBuf> {
dirs::home_dir().context("Cannot determine home directory")
@@ -17,42 +18,82 @@ fn desktop_path() -> Result<PathBuf> {
}
fn render_desktop(exe: &std::path::Path, autostart: bool) -> String {
let exec = if autostart {
format!("{}", exe.display())
} else {
format!("{} gui", exe.display())
};
let mut s = format!(
"[Desktop Entry]\n\
Name=umutray\n\
Comment=Wine launcher manager for Windows game launchers\n\
Exec={exe}\n\
Icon=applications-games\n\
Exec={exec}\n\
Icon=umutray\n\
Type=Application\n\
Categories=Game;\n\
Keywords=wine;proton;gaming;launcher;\n\
StartupNotify=false\n",
exe = exe.display(),
);
if autostart {
s.push_str("X-GNOME-Autostart-enabled=true\n");
s.push_str("Hidden=false\n");
} 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
}
fn ensure_parent(path: &Path) -> Result<()> {
if let Some(p) = path.parent() {
std::fs::create_dir_all(p)
.with_context(|| format!("Failed to create {}", p.display()))?;
}
Ok(())
}
fn write_file(path: &Path, contents: impl AsRef<[u8]>) -> Result<()> {
ensure_parent(path)?;
std::fs::write(path, contents)
.with_context(|| format!("Failed to write {}", path.display()))
}
fn remove_file(path: &Path) -> Result<()> {
if path.exists() {
std::fs::remove_file(path)
.with_context(|| format!("Failed to remove {}", path.display()))?;
}
Ok(())
}
fn icon_path() -> Result<PathBuf> {
Ok(home()?.join(".local/share/icons/hicolor/scalable/apps/umutray.svg"))
}
fn install_icon() -> Result<()> {
write_file(&icon_path()?, ICON_SVG)
}
/// Ensure the tray icon SVG is present in the XDG icon theme directory.
/// Called automatically on startup so the icon works without needing
/// a separate install step.
pub fn ensure_icon() {
let Ok(path) = icon_path() else { return };
if !path.exists() {
let _ = install_icon();
}
}
fn uninstall_icon() -> Result<()> {
remove_file(&icon_path()?)
}
/// Install only the .desktop file so umutray appears in the app menu.
/// Called automatically on first `umutray gui` run.
pub fn install_desktop() -> Result<()> {
let exe = std::env::current_exe().context("Cannot determine path to own executable")?;
let desktop = desktop_path()?;
if let Some(p) = desktop.parent() {
std::fs::create_dir_all(p).with_context(|| format!("Failed to create {p:?}"))?;
}
std::fs::write(&desktop, render_desktop(&exe, false))
.with_context(|| format!("Failed to write desktop file {desktop:?}"))?;
write_file(&desktop, render_desktop(&exe, false))?;
install_icon()?;
println!("{} App menu entry written: {}", "".green().bold(), desktop.display());
Ok(())
}
@@ -61,8 +102,7 @@ pub fn install_desktop() -> Result<()> {
pub fn uninstall_desktop() -> Result<()> {
let desktop = desktop_path()?;
if desktop.exists() {
std::fs::remove_file(&desktop)
.with_context(|| format!("Failed to remove {desktop:?}"))?;
remove_file(&desktop)?;
println!("Removed {}", desktop.display());
} else {
println!("No desktop file at {}", desktop.display());
@@ -76,11 +116,7 @@ pub fn install() -> Result<()> {
// XDG autostart
let autostart = autostart_path()?;
if let Some(p) = autostart.parent() {
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:?}"))?;
write_file(&autostart, render_desktop(&exe, true))?;
println!("Wrote autostart: {}", autostart.display());
// App-menu entry
@@ -97,14 +133,14 @@ pub fn install() -> Result<()> {
pub fn uninstall() -> Result<()> {
let autostart = autostart_path()?;
if autostart.exists() {
std::fs::remove_file(&autostart)
.with_context(|| format!("Failed to remove {autostart:?}"))?;
remove_file(&autostart)?;
println!("Removed {}", autostart.display());
} else {
println!("No autostart file at {}", autostart.display());
}
uninstall_desktop()?;
uninstall_icon()?;
println!("{} Autostart removed.", "".green().bold());
Ok(())
+23 -12
View File
@@ -1,9 +1,19 @@
use anyhow::{Context, Result};
use directories::ProjectDirs;
use owo_colors::OwoColorize;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Expresses the desired change to a game's gamescope setting.
#[derive(Debug, Clone)]
pub enum GamescopeUpdate {
/// Leave the current value unchanged.
Unchanged,
/// Disable gamescope.
Disable,
/// Enable gamescope with the given CLI arguments.
Enable(Vec<String>),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Launcher {
/// Short CLI name (e.g. "battlenet").
@@ -126,7 +136,7 @@ pub fn presets() -> Vec<Launcher> {
"Program Files (x86)/Battle.net/Battle.net Launcher.exe",
),
gameid: "umu-battlenet".into(),
process_pattern: r"Battle\.net".into(),
process_pattern: r"Battle\.net|Blizzard.*Agent".into(),
installer_url: Some(
"https://www.battle.net/download/getInstallerForGame?os=win&gameProgram=BATTLENET_APP&version=Live".into(),
),
@@ -223,9 +233,9 @@ impl Default for Config {
impl Config {
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")?;
Ok(dirs.config_dir().join("config.toml"))
Ok(config_dir.join("umutray").join("config.toml"))
}
pub fn load() -> Result<Self> {
@@ -236,7 +246,7 @@ impl Config {
return Ok(c);
}
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) {
Ok(c) => {
Ok(c)
@@ -244,7 +254,7 @@ impl Config {
Err(e) => {
let bak = path.with_extension("toml.bak");
std::fs::rename(&path, &bak)
.with_context(|| format!("Failed to back up stale config to {bak:?}"))?;
.with_context(|| format!("Failed to back up stale config to {}", bak.display()))?;
eprintln!("warning: couldn't parse {}: {e}", path.display());
eprintln!(
" backed up to {} — writing fresh config with presets",
@@ -264,7 +274,7 @@ impl Config {
}
let content = toml::to_string_pretty(self)?;
std::fs::write(&path, content)
.with_context(|| format!("Failed to write config to {path:?}"))
.with_context(|| format!("Failed to write config to {}", path.display()))
}
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.
/// `gamescope = Some(None)` disables it; `Some(Some(vec))` enables with args.
pub fn set_game_flags(
&mut self,
launcher: &str,
name: &str,
gamemode: Option<bool>,
mangohud: Option<bool>,
gamescope: Option<Option<Vec<String>>>,
gamescope: GamescopeUpdate,
) -> Result<()> {
if gamemode.is_none() && mangohud.is_none() && gamescope.is_none() {
if gamemode.is_none() && mangohud.is_none() && matches!(gamescope, GamescopeUpdate::Unchanged) {
anyhow::bail!(
"nothing to set — pass --gamemode, --mangohud, --gamescope, or --no-gamescope"
);
@@ -415,8 +424,10 @@ impl Config {
if let Some(v) = mangohud {
g.mangohud = v;
}
if let Some(v) = gamescope {
g.gamescope = v;
match gamescope {
GamescopeUpdate::Unchanged => {}
GamescopeUpdate::Disable => g.gamescope = None,
GamescopeUpdate::Enable(args) => g.gamescope = Some(args),
}
self.save()?;
println!("{} Updated flags for '{launcher}/{name}'.", "".green().bold());
+328 -11
View File
@@ -3,6 +3,7 @@ use anyhow::Result;
use owo_colors::OwoColorize;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex};
#[derive(Debug, Clone)]
pub struct DetectHit {
@@ -22,8 +23,7 @@ pub fn scan_for_gui(config: &Config) -> Vec<DetectHit> {
if prefix.join("drive_c").join(&preset.exe_path).exists() {
let configured = config
.find(&preset.name)
.map(|l| l.prefix_dir == *prefix)
.unwrap_or(false);
.is_some_and(|l| l.prefix_dir == *prefix);
hits.push(DetectHit {
display: preset.display.clone(),
prefix: prefix.clone(),
@@ -51,6 +51,138 @@ const SYSTEM_DIRS: &[&str] = &[
"windowsapps",
];
/// Directory names that are pure launcher infrastructure — no game executables
/// are ever installed here. Do NOT add parent dirs like "Epic Games" or
/// "Ubisoft" that also contain game subdirectories; use SKIP_EXES instead.
const SKIP_DIRS: &[&str] = &[
"battle.net", // Battle.net launcher dir; its games live elsewhere
"ea desktop", // EA Desktop launcher subfolder only
"gog galaxy", // GOG Galaxy launcher; games are normally in GOG Games/
"wine",
"mono",
"gecko",
];
/// Exe filename patterns that are launcher tools, not games.
const SKIP_EXES: &[&str] = &[
"uninstall",
"uninst",
"crash",
"error",
"reporter",
"update",
"updater",
"setup",
"installer",
"helper",
"agent",
"service",
"repair",
"diagnostic",
"redist",
"vcredist",
"dxsetup",
"dxwebsetup",
"dotnetfx",
"vc_redist",
"bootstrapper",
"launcher", // launcher tools, not games
"battlenet",
"blizzard",
"eadesktop",
"eabackgroundservice",
"ealink",
"epicgameslauncher",
"epicwebhelper",
"ubisoftconnect",
"ubisoftgamelauncher",
"upc",
"galaxyclient",
"galaxycommunication",
"galaxypeer",
"socialclubhelper",
"subprocess",
"cefprocess",
"webhelper",
"webview",
"7za",
"aria2c",
];
// --- Name resolution ---
/// Cache of absolute exe path → resolved display name (populated lazily).
static NAME_CACHE: LazyLock<Mutex<HashMap<PathBuf, String>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// Install-path → display title, built once from Legendary / Heroic metadata.
static STORE_TITLES: LazyLock<HashMap<PathBuf, String>> =
LazyLock::new(build_store_titles);
/// Read every `installed.json` that Legendary or Heroic may have written and
/// return a map of absolute install directory → game title.
fn build_store_titles() -> HashMap<PathBuf, String> {
let mut map = HashMap::new();
let Some(home) = dirs::home_dir() else { return map };
// Legendary standalone + Heroic's bundled copy (native and Flatpak).
let legendary_candidates = [
home.join(".config/legendary/installed.json"),
home.join(".config/heroic/legendaryConfig/legendary/installed.json"),
home.join(".var/app/com.heroicgameslauncher.hgl/config/legendary/installed.json"),
home.join(".var/app/com.heroicgameslauncher.hgl/config/heroic/legendaryConfig/legendary/installed.json"),
];
for path in &legendary_candidates {
if let Ok(text) = std::fs::read_to_string(path) {
parse_legendary_installed(&text, &mut map);
}
}
// Heroic GOG store (native and Flatpak).
let gog_candidates = [
home.join(".config/heroic/gog_store/installed.json"),
home.join(".var/app/com.heroicgameslauncher.hgl/config/heroic/gog_store/installed.json"),
];
for path in &gog_candidates {
if let Ok(text) = std::fs::read_to_string(path) {
parse_heroic_gog_installed(&text, &mut map);
}
}
map
}
/// Legendary `installed.json`: `{ "AppName": { "install_path": "...", "title": "..." } }`
fn parse_legendary_installed(text: &str, map: &mut HashMap<PathBuf, String>) {
let Ok(json) = serde_json::from_str::<serde_json::Value>(text) else { return };
let Some(obj) = json.as_object() else { return };
for entry in obj.values() {
let Some(path) = entry.get("install_path").and_then(|v| v.as_str()) else { continue };
let Some(title) = entry.get("title").and_then(|v| v.as_str()) else { continue };
if !title.is_empty() {
map.insert(PathBuf::from(path), title.to_string());
}
}
}
/// Heroic GOG `installed.json`: `{ "installed": [ { "install_path": "...", "title": "..." } ] }`
fn parse_heroic_gog_installed(text: &str, map: &mut HashMap<PathBuf, String>) {
let Ok(json) = serde_json::from_str::<serde_json::Value>(text) else { return };
let Some(arr) = json.get("installed").and_then(|v| v.as_array()) else { return };
for entry in arr {
let Some(path) = entry.get("install_path").and_then(|v| v.as_str()) else { continue };
let Some(title) = entry.get("title").and_then(|v| v.as_str()) else { continue };
if !title.is_empty() {
map.insert(PathBuf::from(path), title.to_string());
}
}
}
/// Walk up from `exe_path` checking each ancestor against `STORE_TITLES`.
fn store_title(exe_path: &Path) -> Option<String> {
exe_path.ancestors().skip(1).find_map(|d| STORE_TITLES.get(d).cloned())
}
/// Scan a launcher's Wine prefix for installed game executables.
/// Returns (display_name, path_relative_to_drive_c) pairs, sorted alphabetically,
/// excluding the launcher's own exe and any already-configured games.
@@ -102,13 +234,15 @@ fn scan_exe_dir(
if SYSTEM_DIRS.iter().any(|s| lower.starts_with(s)) {
continue;
}
if SKIP_DIRS.iter().any(|s| lower == *s) {
continue;
}
if path.is_dir() {
scan_exe_dir(&path, drive_c, launcher_exe, already, out, seen, depth + 1);
} else if path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("exe"))
.unwrap_or(false)
.is_some_and(|e| e.eq_ignore_ascii_case("exe"))
{
let Ok(rel) = path.strip_prefix(drive_c) else { continue };
let rel_str = rel.to_string_lossy().to_string();
@@ -116,19 +250,202 @@ fn scan_exe_dir(
if rel_lower == launcher_exe || already.contains(&rel_lower) {
continue;
}
// Skip launcher tools, updaters, and non-game executables
let stem_lower = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_lowercase();
if SKIP_EXES.iter().any(|s| stem_lower.contains(s)) {
continue;
}
if !seen.insert(rel_lower) {
continue;
}
let display = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Unknown")
.to_string();
let display = resolve_game_name(&path, None);
out.push((display, rel_str));
}
}
}
/// Resolve a human-readable display name for a game exe.
///
/// Resolution pipeline (first hit wins):
/// 1. Explicit name — if `explicit_name` is `Some`, return it immediately.
/// 2. Legendary / Heroic `installed.json` — maps install path → title,
/// covers both Epic (via Legendary) and GOG (via Heroic's GOG store).
/// 3. Manifest walk — walks up from the exe looking for GOG `.info` and
/// Epic `.egstore/*.item` JSON files at the game's installation root.
/// 4. Launcher path — reads the game name from well-known directory
/// structures laid down by the launcher (e.g. `Epic Games/<Name>/`).
/// 5. Nearest non-generic parent directory name, or raw exe stem.
/// No name generation — if the directory name is unknown, it is used
/// as-is rather than being fabricated from the exe filename.
///
/// Results from stages 25 are cached by path after first computation.
pub fn resolve_game_name(exe_path: &Path, explicit_name: Option<&str>) -> String {
if let Some(name) = explicit_name {
return name.to_string();
}
{
let cache = NAME_CACHE.lock().unwrap_or_else(|e| e.into_inner());
if let Some(cached) = cache.get(exe_path) {
return cached.clone();
}
}
let name = resolve_uncached(exe_path);
NAME_CACHE
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(exe_path.to_path_buf(), name.clone());
name
}
fn resolve_uncached(exe_path: &Path) -> String {
// Stage 2 Legendary / Heroic installed.json (install path → title)
if let Some(name) = store_title(exe_path) {
return name;
}
// Stage 3 manifest files at the game's installation root
if let Some(name) = read_manifest_name(exe_path) {
return name;
}
// Stage 4 game name from known launcher directory structures
if let Some(name) = name_from_launcher_path(exe_path) {
return name;
}
// Stage 5 nearest non-generic parent directory, or raw exe stem.
// No name generation: if we don't know, we say so honestly.
prettify_exe_name(exe_path)
}
/// Walk up from `exe_path` looking for platform manifest files that record the
/// game's display name. Manifests live at the game's installation *root*, which
/// can be several directories above the actual exe.
///
/// Supported formats:
/// - GOG: `goggame-<id>.info` → `{ "gameName": "..." }`
/// - Epic: `.egstore/<id>.item` → `{ "DisplayName": "..." }`
fn read_manifest_name(exe_path: &Path) -> Option<String> {
for d in exe_path.ancestors().skip(1) {
let dirname = d.file_name().and_then(|n| n.to_str()).unwrap_or("").to_lowercase();
// Stop once we reach drive_c root or the Program Files tier — manifests
// are never above the game's installation folder.
if dirname == "drive_c" || dirname.starts_with("program files") {
break;
}
if let Some(name) = read_gog_manifest(d).or_else(|| read_epic_manifest(d)) {
return Some(name);
}
}
None
}
fn read_gog_manifest(dir: &Path) -> Option<String> {
for entry in std::fs::read_dir(dir).ok()?.flatten() {
let fname = entry.file_name();
let fname = fname.to_string_lossy();
if fname.starts_with("goggame-") && fname.ends_with(".info") {
let text = std::fs::read_to_string(entry.path()).ok()?;
let json: serde_json::Value = serde_json::from_str(&text).ok()?;
let t = json.get("gameName")?.as_str()?.trim();
if !t.is_empty() {
return Some(t.to_string());
}
}
}
None
}
fn read_epic_manifest(dir: &Path) -> Option<String> {
let egstore = dir.join(".egstore");
if !egstore.is_dir() {
return None;
}
for entry in std::fs::read_dir(&egstore).ok()?.flatten() {
if entry.path().extension().and_then(|e| e.to_str()) == Some("item") {
let text = std::fs::read_to_string(entry.path()).ok()?;
let json: serde_json::Value = serde_json::from_str(&text).ok()?;
let t = json.get("DisplayName")?.as_str()?.trim();
if !t.is_empty() {
return Some(t.to_string());
}
}
}
None
}
/// Extract a game name from well-known launcher directory conventions.
///
/// Launchers install each game into a named subdirectory of their own folder.
/// That subdirectory name *is* the display name:
/// - Epic: `…/Epic Games/<GameName>/…`
/// - GOG: `…/GOG Games/<GameName>/…`
/// - Steam: `…/steamapps/common/<GameName>/…`
/// - Rockstar:`…/Rockstar Games/<GameName>/…`
/// - EA: `…/EA Games/<GameName>/…`
/// - Ubisoft: `…/Ubisoft Game Launcher/games/<GameName>/…`
fn name_from_launcher_path(exe_path: &Path) -> Option<String> {
let comps: Vec<&std::ffi::OsStr> = exe_path.components().map(|c| c.as_os_str()).collect();
for (i, comp) in comps.iter().enumerate() {
let lower = comp.to_str().unwrap_or("").to_lowercase();
match lower.as_str() {
"epic games" | "gog games" | "rockstar games" | "ea games" => {
return comps.get(i + 1).and_then(|c| c.to_str()).map(str::to_string);
}
// Ubisoft: …/Ubisoft Game Launcher/games/<GameName>/…
"ubisoft game launcher" => {
return comps.get(i + 2).and_then(|c| c.to_str()).map(str::to_string);
}
"common"
if i > 0
&& comps[i - 1].to_str().unwrap_or("").to_lowercase() == "steamapps" =>
{
return comps.get(i + 1).and_then(|c| c.to_str()).map(str::to_string);
}
_ => {}
}
}
None
}
/// Heuristic last-resort name derivation from an exe path.
///
/// Walks up parent directories looking for a non-generic name; falls back to
/// inserting spaces into the CamelCase / digit-boundary exe stem.
pub fn prettify_exe_name(path: &Path) -> String {
const GENERIC_DIRS: &[&str] = &[
"bin", "binaries", "x64", "x86", "win64", "win32", "retail",
"shipping", "game", "runtime", "_retail_", "_commonredist",
"launcher", "engine", "client",
];
for d in path.ancestors().skip(1) {
let name = d.file_name().and_then(|n| n.to_str()).unwrap_or("");
let lower = name.to_lowercase();
if !name.is_empty()
&& !GENERIC_DIRS.iter().any(|g| lower == *g)
&& !lower.starts_with("program files")
{
return name.to_string();
}
}
// Nothing useful in the path — return the exe stem as-is.
path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Unknown")
.to_string()
}
const MAX_DEPTH: u32 = 3;
pub fn run(config: &Config, extra_dirs: &[PathBuf], apply: bool) -> Result<()> {
@@ -159,7 +476,7 @@ pub fn run(config: &Config, extra_dirs: &[PathBuf], apply: bool) -> Result<()> {
}
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();
};
vec![
@@ -200,7 +517,7 @@ fn collect_prefixes(dir: &Path, depth: u32, out: &mut Vec<PathBuf>) {
return;
};
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);
}
}
+14 -14
View File
@@ -80,8 +80,7 @@ fn global_vulkan_check() -> CheckResult {
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
.is_ok_and(|s| s.success());
if ok {
CheckResult::pass("vulkan", "vulkaninfo OK")
} else {
@@ -218,8 +217,7 @@ fn count_ge_proton(dir: &Path) -> usize {
.filter(|e| {
e.file_name()
.to_str()
.map(|s| s.starts_with("GE-Proton"))
.unwrap_or(false)
.is_some_and(|s| s.starts_with("GE-Proton"))
})
.count()
})
@@ -236,19 +234,21 @@ fn which(cmd: &str) -> Option<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 {
let file_uid = match std::fs::metadata(path) {
Ok(m) => m.uid(),
Err(_) => return true,
};
let current_uid: Option<u32> = Command::new("id")
.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,
}
current_uid().map_or(true, |uid| uid == file_uid)
}
+921 -333
View File
File diff suppressed because it is too large Load Diff
+54 -35
View File
@@ -6,35 +6,57 @@ use std::process::Stdio;
use std::thread;
use std::time::Duration;
/// Resolve PROTONPATH for umu-run: the literal "GE-Proton" makes umu-run
/// auto-fetch the latest; a pinned version gets the full path in compat_dir.
pub fn resolve_proton_path(config: &Config, launcher: &Launcher) -> OsString {
let version = launcher
.proton_version
.as_deref()
.unwrap_or(&config.proton_version);
if version == "GE-Proton" {
version.to_string().into()
} else {
config.proton_compat_dir.join(version).into_os_string()
}
}
/// Spawn the launcher via umu-run and return immediately.
pub fn launch(config: &Config, launcher: &Launcher) -> Result<()> {
let exe = launcher.full_exe_path();
if !exe.exists() {
bail!(
"launcher exe not found at {:?}\n\
"launcher exe not found at {}\n\
Run `umutray setup {}` for setup instructions.",
exe,
exe.display(),
launcher.name,
);
}
// PROTONPATH: umu-run accepts the literal "GE-Proton" to auto-fetch the
// latest; for any pinned version it expects a full path to the install dir.
let version = launcher
.proton_version
.as_deref()
.unwrap_or(&config.proton_version);
let proton_path: std::ffi::OsString = if version == "GE-Proton" {
version.to_string().into()
let proton_path = resolve_proton_path(config, launcher);
// Propagate overlay env vars from any configured game so that games
// launched from within the launcher (e.g. Battle.net → Diablo) inherit
// them. gamemoderun wraps umu-run so the whole process tree gets
// gamemode; MANGOHUD=1 is inherited by all child processes.
let any_gamemode = launcher.games.iter().any(|g| g.gamemode);
let any_mangohud = launcher.games.iter().any(|g| g.mangohud);
let (prog, args): (OsString, Vec<OsString>) = if any_gamemode {
let mut a = vec![OsString::from("umu-run")];
a.push(exe.into_os_string());
("gamemoderun".into(), a)
} else {
config.proton_compat_dir.join(version).into_os_string()
(OsString::from("umu-run"), vec![exe.into_os_string()])
};
std::process::Command::new("umu-run")
.env("WINEPREFIX", &launcher.prefix_dir)
let mut cmd = std::process::Command::new(&prog);
cmd.env("WINEPREFIX", &launcher.prefix_dir)
.env("GAMEID", &launcher.gameid)
.env("PROTONPATH", &proton_path)
.arg(&exe)
.env("PROTONPATH", &proton_path);
if any_mangohud {
cmd.env("MANGOHUD", "1");
}
cmd.args(&args)
.spawn()
.context(
"Failed to spawn umu-run. Is it installed?\n\
@@ -44,30 +66,30 @@ pub fn launch(config: &Config, launcher: &Launcher) -> Result<()> {
Ok(())
}
/// Launch a game installed through `launcher`, wrapped in the per-game
/// overlays (gamescope, gamemoderun, MANGOHUD). The launcher itself is
/// never wrapped — only games run through this path pick up overlays.
/// Launch a game directly via umu-run, wrapped in the per-game overlays
/// (gamescope, gamemoderun, MANGOHUD). Ensures the parent launcher is
/// running first so the game can authenticate online.
pub fn play_game(config: &Config, launcher: &Launcher, game: &Game) -> Result<()> {
let exe = game.full_exe_path(launcher);
if !exe.exists() {
bail!(
"game exe not found at {:?}\n\
"game exe not found at {}\n\
Check exe_path for '{}/{}' in config, or install the game via the launcher first.",
exe,
exe.display(),
launcher.name,
game.name,
);
}
let version = launcher
.proton_version
.as_deref()
.unwrap_or(&config.proton_version);
let proton_path: OsString = if version == "GE-Proton" {
version.to_string().into()
} else {
config.proton_compat_dir.join(version).into_os_string()
};
// Start the launcher if it isn't already running so the game has an
// active authentication session (avoids offline-mode).
if !is_running(launcher) {
launch(config, launcher)?;
// Give the launcher a moment to initialise before spawning the game.
thread::sleep(Duration::from_secs(5));
}
let proton_path = resolve_proton_path(config, launcher);
let (prog, args) = build_wrapped_argv(&exe, game);
@@ -112,13 +134,12 @@ fn build_wrapped_argv(exe: &Path, game: &Game) -> (OsString, Vec<OsString>) {
}
/// 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);
Ok(())
}
/// 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.
// This keeps the total wait at 3 s instead of 3 s × N.
for l in &config.launchers {
@@ -128,7 +149,6 @@ pub fn kill_all(config: &Config) -> Result<()> {
for l in &config.launchers {
send_signal("-9", &l.process_pattern);
}
Ok(())
}
fn kill_pattern(pattern: &str) {
@@ -153,6 +173,5 @@ pub fn is_running(launcher: &Launcher) -> bool {
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
.is_ok_and(|s| s.success())
}
+40 -21
View File
@@ -6,8 +6,9 @@ mod diagnose;
mod gui;
mod launcher;
mod proton;
mod service;
mod autostart;
mod setup;
mod theme;
mod tray;
mod util;
@@ -108,10 +109,10 @@ enum Commands {
action: ConfigAction,
},
/// Manage the XDG autostart entry that starts the tray on login
Service {
/// Manage the XDG autostart and desktop entries
Autostart {
#[command(subcommand)]
action: ServiceAction,
action: AutostartAction,
},
}
@@ -221,7 +222,7 @@ enum ConfigAction {
}
#[derive(Subcommand)]
enum ServiceAction {
enum AutostartAction {
/// Write ~/.config/autostart/umutray.desktop so the tray starts on login (includes app menu entry)
Install,
/// Remove the autostart entry and app menu entry
@@ -238,6 +239,10 @@ fn main() -> Result<()> {
let cli = Cli::parse();
let config = config::Config::load()?;
// Ensure the SVG icon is present in the XDG icon theme so the tray
// and desktop entries can find it without a separate install step.
autostart::ensure_icon();
match cli.command.unwrap_or(Commands::Tray) {
Commands::Tray => tray::run(&config)?,
@@ -253,9 +258,9 @@ fn main() -> Result<()> {
let l = config.find(&n).ok_or_else(|| {
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 } => {
@@ -321,7 +326,22 @@ fn main() -> Result<()> {
}
},
Commands::Gui => gui::run(&config)?,
Commands::Gui => {
// Start the tray icon immediately alongside the GUI.
let tray_handle = tray::spawn(&config);
match gui::run(&config)? {
gui::CloseAction::Quit => {
tray_handle.shutdown();
}
gui::CloseAction::MinimizeToTray => {
// GUI closed, tray keeps running. Block until killed.
loop {
std::thread::sleep(std::time::Duration::from_secs(60));
}
}
}
}
Commands::Detect { dir, apply } => {
detect::run(&config, &dir, apply)?;
@@ -398,27 +418,26 @@ fn main() -> Result<()> {
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 {
Some(None)
config::GamescopeUpdate::Disable
} else if let Some(s) = gamescope {
config::GamescopeUpdate::Enable(
s.split_whitespace().map(String::from).collect(),
)
} else {
gamescope
.map(|s| Some(s.split_whitespace().map(String::from).collect::<Vec<_>>()))
config::GamescopeUpdate::Unchanged
};
let mut c = config;
c.set_game_flags(&launcher, &name, gamemode, mangohud, gs_update)?;
}
},
Commands::Service { action } => match action {
ServiceAction::Install => service::install()?,
ServiceAction::Uninstall => service::uninstall()?,
ServiceAction::Status => service::status()?,
ServiceAction::InstallDesktop => service::install_desktop()?,
ServiceAction::UninstallDesktop => service::uninstall_desktop()?,
Commands::Autostart { action } => match action {
AutostartAction::Install => autostart::install()?,
AutostartAction::Uninstall => autostart::uninstall()?,
AutostartAction::Status => autostart::status()?,
AutostartAction::InstallDesktop => autostart::install_desktop()?,
AutostartAction::UninstallDesktop => autostart::uninstall_desktop()?,
},
}
+6 -6
View File
@@ -4,7 +4,7 @@ use owo_colors::OwoColorize;
use serde::Deserialize;
use std::collections::HashSet;
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";
@@ -57,7 +57,7 @@ fn fetch_release(tag: &str) -> Result<Release> {
fn install_version(config: &Config, tag: &str) -> Result<()> {
let install_path = config.proton_compat_dir.join(tag);
if install_path.exists() {
println!("{tag} is already installed at {install_path:?}");
println!("{tag} is already installed at {}", install_path.display());
return Ok(());
}
@@ -83,13 +83,13 @@ fn install_version(config: &Config, tag: &str) -> Result<()> {
.context("Download returned an error status")?;
let total = resp.content_length();
let f = std::fs::File::create(&tmp_path)
.with_context(|| format!("Failed to create temp file {tmp_path:?}"))?;
.with_context(|| format!("Failed to create temp file {}", tmp_path.display()))?;
let mut progress = ProgressWriter::new(f, total);
std::io::copy(&mut resp, &mut progress).context("Failed to stream archive to disk")?;
progress.finish();
}
println!("Extracting to {:?}...", config.proton_compat_dir);
println!("Extracting to {}...", config.proton_compat_dir.display());
std::fs::create_dir_all(&config.proton_compat_dir)?;
let status = std::process::Command::new("tar")
@@ -110,10 +110,10 @@ fn install_version(config: &Config, tag: &str) -> Result<()> {
}
/// 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 };
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;
}
let name = entry.file_name().to_string_lossy().to_string();
+514 -155
View File
@@ -1,10 +1,19 @@
use crate::{config::{self, Config, Launcher}, util::{async_blocking, pick_folder}};
use crate::{
config::{self, Config, Launcher},
theme::{
btn_accent, btn_ghost, card_style, icon, sub_card_style, surface_bg, ACCENT,
DIM, GREEN, MUTED, RED, SURFACE_RAISED,
},
util::pick_folder,
};
use anyhow::Result;
use iced::widget::{
button, column, container, pick_list, progress_bar, row, scrollable, text, text_input, Column,
button, column, container, pick_list, progress_bar, row, scrollable, text, text_input, Space,
Column,
};
use iced::{
Alignment, Background, Border, Color, Element, Length, Padding, Subscription, Task, Theme,
};
use iced::{Element, Length, Subscription, Task, Theme};
use std::ffi::OsString;
use std::io::{BufRead, BufReader, Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
@@ -22,6 +31,7 @@ pub enum Message {
// Install stage
Back,
SourceChanged(String),
AutoDownload,
PreparePressed,
PrepareDone(Result<PathBuf, String>),
InstallPressed,
@@ -30,6 +40,8 @@ pub enum Message {
// Finished stage
LaunchNow,
Close,
ToggleLog,
FontLoaded,
}
#[derive(Debug, Clone)]
@@ -65,6 +77,7 @@ struct State {
status: String,
download: Arc<Mutex<DownloadProgress>>,
log: Arc<Mutex<Vec<String>>>,
show_log: bool,
}
impl Drop for State {
@@ -94,14 +107,11 @@ impl State {
status: String::new(),
download: Arc::new(Mutex::new(DownloadProgress::default())),
log: Arc::new(Mutex::new(Vec::new())),
show_log: false,
}
}
fn new_install(config: Config, launcher: Launcher) -> Self {
let status = format!(
"Paste an installer URL or a local .exe path. It will install into {}.",
launcher.prefix_dir.display()
);
let template_options = config::presets()
.iter()
.map(|l| l.display.clone())
@@ -116,9 +126,10 @@ impl State {
installer: None,
downloaded_temp: None,
stage: Stage::Idle,
status,
status: String::new(),
download: Arc::new(Mutex::new(DownloadProgress::default())),
log: Arc::new(Mutex::new(Vec::new())),
show_log: false,
}
}
}
@@ -143,7 +154,7 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
Task::none()
}
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(path) => {
@@ -171,30 +182,36 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
preset.prefix_dir = PathBuf::from(&prefix);
// If we know the official installer URL, pre-fill source and
// kick off the download automatically so the user doesn't have
// to find or paste anything.
// start the download automatically.
if let Some(url) = preset.installer_url.clone() {
state.source = url.clone();
state.launcher = Some(preset);
state.stage = Stage::Busy;
state.status = format!(
"Found official installer — downloading to {}",
preset.prefix_dir.display()
);
let display_name = state
.launcher
.as_ref()
.map(|l| l.display.clone())
.unwrap_or_else(|| "installer".to_string());
state.status = format!("Downloading {} installer…", display_name);
if let Ok(mut p) = state.download.lock() {
*p = DownloadProgress::default();
}
let name = preset.name.clone();
let name = state
.launcher
.as_ref()
.expect("launcher set")
.name
.clone();
let progress = state.download.clone();
state.launcher = Some(preset);
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,
);
}
state.status = format!(
"Paste an installer URL or a local .exe path. It will install into {}.",
preset.prefix_dir.display()
"Paste an installer URL or a local .exe path for {}.",
preset.display
);
state.launcher = Some(preset);
state.stage = Stage::Idle;
@@ -218,6 +235,34 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
state.source = s;
Task::none()
}
Message::AutoDownload => {
// Auto-start download for launchers with an official installer URL
let url = state.source.clone();
if url.is_empty() {
return Task::none();
}
state.stage = Stage::Busy;
let display_name = state
.launcher
.as_ref()
.map(|l| l.display.clone())
.unwrap_or_else(|| "installer".to_string());
state.status = format!("Downloading {} installer…", display_name);
if let Ok(mut p) = state.download.lock() {
*p = DownloadProgress::default();
}
let name = state
.launcher
.as_ref()
.expect("launcher set")
.name
.clone();
let progress = state.download.clone();
Task::perform(
async { tokio::task::spawn_blocking(move || download_blocking(&url, &name, progress)).await.expect("blocking task panicked") },
Message::PrepareDone,
)
}
Message::PreparePressed => {
let src = state.source.trim().to_string();
if src.is_empty() {
@@ -228,7 +273,12 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
if path.is_file() {
state.installer = Some(path.clone());
state.stage = Stage::Ready;
state.status = format!("Ready: {}", path.display());
let display = state
.launcher
.as_ref()
.map(|l| l.display.as_str())
.unwrap_or("installer");
state.status = format!("Local installer ready for {}. Press Install → to continue.", display);
return Task::none();
}
if !src.starts_with("http://") && !src.starts_with("https://") {
@@ -236,7 +286,12 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
return Task::none();
}
state.stage = Stage::Busy;
state.status = format!("Downloading {src}");
let display_name = state
.launcher
.as_ref()
.map(|l| l.display.clone())
.unwrap_or_else(|| "installer".to_string());
state.status = format!("Downloading {} installer…", display_name);
if let Ok(mut p) = state.download.lock() {
*p = DownloadProgress::default();
}
@@ -248,7 +303,7 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
.clone();
let progress = state.download.clone();
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,
)
}
@@ -256,7 +311,12 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
state.downloaded_temp = Some(path.clone());
state.installer = Some(path.clone());
state.stage = Stage::Ready;
state.status = format!("Downloaded to {}", path.display());
let display_name = state
.launcher
.as_ref()
.map(|l| l.display.as_str())
.unwrap_or("installer");
state.status = format!("Download complete. Press Install → to run the {} installer.", display_name);
Task::none()
}
Message::PrepareDone(Err(e)) => {
@@ -264,15 +324,21 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
state.status = format!("Download failed: {e}");
Task::none()
}
Message::ToggleLog => {
state.show_log = !state.show_log;
Task::none()
}
Message::InstallPressed => {
let Some(installer) = state.installer.clone() else {
return Task::none();
};
state.stage = Stage::Installing;
state.status = "Running installer via umu-run (this may take several minutes)…".into();
let display_name = state.launcher.as_ref().map(|l| l.display.clone()).unwrap_or_default();
state.status = format!("Installing {}. This may take a few minutes…", display_name);
if let Ok(mut v) = state.log.lock() {
v.clear();
}
state.show_log = false;
let config = state.config.clone();
let launcher = state
.launcher
@@ -280,7 +346,7 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
.expect("launcher set before install");
let log = state.log.clone();
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,
)
}
@@ -291,7 +357,7 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
.expect("launcher set before install");
let exe = launcher.full_exe_path();
// Save to config only on successful install
if matches!(&res, Ok(_)) && exe.exists() {
if res.is_ok() && exe.exists() {
if state.config.find(&launcher.name).is_none() {
state.config.launchers.push(launcher.clone());
let _ = state.config.save();
@@ -309,11 +375,9 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
"✓ Installer finished (exit {code}). {} is ready.",
launcher.display,
),
Ok(code) => format!(
"umu-run exited {code} but the expected exe is not at {}.\n\
Check the installer's destination path, or edit exe_path in config.",
exe.display(),
),
Ok(_) => "Installation finished but the launcher wasn't found where expected. \
Check the log below for details."
.to_string(),
Err(e) => format!("Install failed: {e}"),
};
Task::none()
@@ -329,6 +393,7 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
iced::exit()
}
Message::Close => iced::exit(),
Message::FontLoaded => Task::none(),
}
}
@@ -348,10 +413,32 @@ fn view(state: &State) -> Element<'_, Message> {
view_install(state)
}
fn view_picking(state: &State) -> Element<'_, Message> {
let header = text("Add a Launcher").size(24);
let sub = text("Choose a launcher and where to install it.").size(13);
fn section_card<'a>(content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
container(content)
.padding(Padding::from([12, 16]))
.width(Length::Fill)
.style(card_style)
.into()
}
fn view_picking(state: &State) -> Element<'_, Message> {
// ── Header ───────────────────────────────────────────────────────────────
let header = container(
column![
step_indicator(1),
text("Add a Launcher").size(26).style(|_: &Theme| text::Style {
color: Some(ACCENT),
}),
text("Choose a launcher and set its install location.").size(13)
.style(|_: &Theme| text::Style {
color: Some(DIM),
}),
]
.spacing(4),
)
.padding(Padding { top: 24.0, right: 24.0, bottom: 8.0, left: 24.0 });
// ── Launcher picker card ──────────────────────────────────────────────────
let picker = pick_list(
state.template_options.as_slice(),
state.selected_template.clone(),
@@ -360,106 +447,235 @@ fn view_picking(state: &State) -> Element<'_, Message> {
.placeholder("Select a launcher…")
.width(Length::Fill);
let prefix_input = text_input("/home/user/Games/battlenet", &state.prefix_input)
let launcher_card = section_card(
column![
text("LAUNCHER").size(10).style(|_: &Theme| text::Style {
color: Some(DIM),
}),
picker,
]
.spacing(6),
);
// ── Install location card ─────────────────────────────────────────────────
let prefix_input = text_input("e.g. /home/user/Games/battlenet", &state.prefix_input)
.on_input(Message::PrefixChanged)
.padding(8)
.width(Length::Fill);
let browse_btn = button(text("Browse…").size(13))
let browse_btn = button(
row![icon("\u{f3e8}", 12), text(" Browse…").size(13)]
.align_y(Alignment::Center).spacing(4),
)
.on_press(Message::BrowsePrefix)
.style(button::secondary);
.style(btn_ghost)
.padding([8, 14]);
let location_card = section_card(
column![
text("INSTALL LOCATION").size(10).style(|_: &Theme| text::Style {
color: Some(DIM),
}),
row![prefix_input, browse_btn]
.spacing(8)
.align_y(Alignment::Center),
text("The folder where the launcher's Wine prefix will be created.")
.size(11)
.style(|_: &Theme| text::Style {
color: Some(MUTED),
}),
]
.spacing(6),
);
// ── Status / error ────────────────────────────────────────────────────────
let status_el: Element<Message> = if !state.status.is_empty() {
container(
text(&state.status).size(13).style(|_: &Theme| text::Style {
color: Some(RED),
}),
)
.padding(Padding::from([6, 0]))
.into()
} else {
Space::new().into()
};
// ── Next button ───────────────────────────────────────────────────────────
let can_confirm =
state.selected_template.is_some() && !state.prefix_input.trim().is_empty();
let confirm_btn = button(text("Next →"))
let next_btn = container(
button(
row![text("Next").size(14), icon("\u{f138}", 14)]
.align_y(Alignment::Center).spacing(6),
)
.on_press_maybe(can_confirm.then_some(Message::ConfirmLauncher))
.style(button::primary);
.style(btn_accent)
.padding([8, 18]),
)
.padding(Padding { top: 4.0, right: 0.0, bottom: 0.0, left: 0.0 })
.width(Length::Fill)
.align_x(Alignment::End);
let mut body = column![
container(
column![
header,
sub,
text("Launcher:").size(13),
picker,
text("Install location (Wine prefix):").size(13),
row![prefix_input, browse_btn].spacing(8).align_y(iced::Alignment::Center),
confirm_btn,
container(
column![launcher_card, location_card, status_el, next_btn]
.spacing(12)
.padding(Padding { top: 8.0, right: 24.0, bottom: 24.0, left: 24.0 }),
),
]
.spacing(10)
.padding(20);
if !state.status.is_empty() {
body = body.push(text(state.status.clone()).size(13));
}
container(body).into()
)
.width(Length::Fill)
.height(Length::Fill)
.style(surface_bg)
.into()
}
fn step_indicator<'a>(step: u8) -> Element<'a, Message> {
let active = ACCENT;
let done = GREEN;
let muted_clr = MUTED;
let label = |n: u8, label: &'static str| -> Element<'a, Message> {
let (num_color, text_color) = if n < step {
(done, done)
} else if n == step {
(active, active)
} else {
(muted_clr, muted_clr)
};
row![
text(format!("{n}")).size(12).style(move |_: &Theme| text::Style { color: Some(num_color) }),
text(format!(" {label}")).size(12).style(move |_: &Theme| text::Style { color: Some(text_color) }),
]
.align_y(Alignment::Center)
.into()
};
let sep = |c: Color| -> Element<'a, Message> {
text("").size(12).style(move |_: &Theme| text::Style { color: Some(c) }).into()
};
container(
row![
label(1, "Choose"),
sep(if step > 1 { done } else { muted_clr }),
label(2, "Install"),
sep(if step > 2 { done } else { muted_clr }),
label(3, "Done"),
]
.align_y(Alignment::Center),
)
.padding(Padding { top: 0.0, right: 0.0, bottom: 12.0, left: 0.0 })
.into()
}
fn status_card(msg: String, kind: StatusKind) -> Element<'static, Message> {
let color = match kind {
StatusKind::Info => ACCENT,
StatusKind::Success => GREEN,
StatusKind::Error => RED,
StatusKind::Neutral => DIM,
};
container(
text(msg).size(13).style(move |_: &Theme| text::Style { color: Some(color) }),
)
.padding(Padding::from([10, 14]))
.width(Length::Fill)
.style(card_style)
.into()
}
enum StatusKind { Info, Success, Error, Neutral }
fn view_install(state: &State) -> Element<'_, Message> {
let launcher = state
.launcher
.as_ref()
.expect("launcher must be set in install stage");
let can_go_back = state.selected_template.is_some()
&& matches!(state.stage, Stage::Idle | Stage::Ready | Stage::Finished);
let finished = matches!(state.stage, Stage::Finished);
let install_success = finished && launcher.full_exe_path().exists();
let is_official = launcher.installer_url.as_deref()
.map(|u| state.source == u)
.unwrap_or(false);
let back_btn: Element<Message> = if state.selected_template.is_some() {
button(text("← Back").size(13))
// ── Step indicator ────────────────────────────────────────────────────────
let step = if finished { 3 } else { 2 };
let steps = step_indicator(step);
// ── Back button ───────────────────────────────────────────────────────────
let can_go_back = state.selected_template.is_some()
&& matches!(state.stage, Stage::Idle | Stage::Ready);
let back_el: Element<Message> = if state.selected_template.is_some() && !finished {
button(
row![icon("\u{f12f}", 12), text(" Back").size(12)]
.align_y(Alignment::Center).spacing(4),
)
.on_press_maybe(can_go_back.then_some(Message::Back))
.style(button::secondary)
.style(btn_ghost)
.padding([6, 14])
.into()
} else {
text("").into()
Space::new().into()
};
let header = row![
text(format!("Setup: {}", launcher.display)).size(24),
iced::widget::horizontal_space(),
back_btn,
// ── Header ────────────────────────────────────────────────────────────────
let header_row = row![
column![
steps,
text(&launcher.display).size(22).style(|_: &Theme| text::Style {
color: Some(ACCENT),
}),
]
.align_y(iced::Alignment::Center);
.spacing(0),
Space::new().width(Length::Fill),
back_el,
]
.align_y(Alignment::Start);
let prefix = text(format!("Prefix: {}", launcher.prefix_dir.display())).size(13);
let expected = text(format!(
"Expected: {}",
launcher.full_exe_path().display()
))
.size(13);
let source_label = if launcher.installer_url.is_some()
&& state.source == launcher.installer_url.as_deref().unwrap_or("")
{
"Official installer URL (auto-filled — or paste your own):"
// ── Source / installer card (only shown in Idle, for custom URLs) ────────
let source_card: Element<Message> = if matches!(state.stage, Stage::Idle) && !is_official {
let inner: Element<Message> = column![
text("INSTALLER").size(10).style(|_: &Theme| text::Style {
color: Some(DIM),
}),
text_input("Paste a URL or local .exe path", &state.source)
.on_input(Message::SourceChanged)
.padding(8),
]
.spacing(6)
.into();
section_card(inner)
} else {
"Installer URL or local .exe path:"
Space::new().into()
};
let input = text_input("https://… or /path/to/installer.exe", &state.source)
.on_input(Message::SourceChanged)
.padding(8);
// ── Status card ───────────────────────────────────────────────────────────
let status_el: Element<Message> = if !state.status.is_empty() {
let kind = match state.stage {
Stage::Finished if install_success => StatusKind::Success,
Stage::Finished => StatusKind::Error,
Stage::Busy | Stage::Installing => StatusKind::Info,
Stage::Ready => StatusKind::Success,
_ => StatusKind::Neutral,
};
status_card(state.status.clone(), kind)
} else if matches!(state.stage, Stage::Idle) && is_official {
status_card(
format!("Ready to download the official {} installer.", launcher.display),
StatusKind::Neutral,
)
} else {
Space::new().into()
};
let prepare_enabled = matches!(state.stage, Stage::Idle | Stage::Ready | Stage::Finished);
let install_enabled = matches!(state.stage, Stage::Ready);
let finished = matches!(state.stage, Stage::Finished);
let install_success = finished
&& launcher
.full_exe_path()
.exists();
let prepare_btn = button(text("Download / Prepare"))
.on_press_maybe(prepare_enabled.then_some(Message::PreparePressed));
let install_btn = button(text("Run installer"))
.on_press_maybe(install_enabled.then_some(Message::InstallPressed));
let status = text(state.status.clone());
let progress_row: Element<Message> = if matches!(state.stage, Stage::Busy) {
let p = state
.download
.lock()
.map(|p| (p.bytes, p.total))
.unwrap_or((0, None));
// ── Progress bar ──────────────────────────────────────────────────────────
let progress_el: Element<Message> = if matches!(state.stage, Stage::Busy) {
let p = state.download.lock().map(|p| (p.bytes, p.total)).unwrap_or((0, None));
let (bytes, total) = p;
let fraction = match total {
Some(t) if t > 0 => (bytes as f32) / (t as f32),
@@ -469,81 +685,232 @@ fn view_install(state: &State) -> Element<'_, Message> {
Some(t) => format!("{} / {}", fmt_bytes(bytes), fmt_bytes(t)),
None => format!("{} downloaded", fmt_bytes(bytes)),
};
section_card(
column![progress_bar(0.0..=1.0, fraction), text(label).size(12)]
.spacing(4)
.into()
.spacing(6),
)
} else {
text("").into()
Space::new().into()
};
let log_pane: Element<Message> = if matches!(state.stage, Stage::Installing | Stage::Finished) {
// ── Finished banner ───────────────────────────────────────────────────────
let finished_banner: Element<Message> = if finished {
let (sym, msg) = if install_success {
("\u{f26a}", format!("{} is ready to use.", launcher.display))
} else {
("\u{f28a}", "Installation did not complete successfully. See details below.".to_string())
};
let banner_color = if install_success { GREEN } else { RED };
let border_tint = Color { a: 0.35, ..banner_color };
container(
row![
crate::theme::icon(sym, 16).style(move |_: &Theme| text::Style {
color: Some(banner_color),
}),
text(format!(" {msg}")).size(15).style(move |_: &Theme| text::Style {
color: Some(banner_color),
}),
].align_y(Alignment::Center),
)
.padding(Padding::from([12, 14]))
.width(Length::Fill)
.style(move |_: &Theme| container::Style {
background: Some(Background::Color(SURFACE_RAISED)),
border: Border { color: border_tint, width: 1.0, radius: 8.0.into() },
..Default::default()
})
.into()
} else {
Space::new().into()
};
// ── Log toggle + pane ─────────────────────────────────────────────────────
let has_log = matches!(state.stage, Stage::Installing | Stage::Finished);
let log_el: Element<Message> = if has_log {
let toggle_label = if state.show_log { "Hide details ▲" } else { "Show details ▼" };
let toggle_btn = button(text(toggle_label).size(12))
.on_press(Message::ToggleLog)
.style(btn_ghost)
.padding([6, 12]);
if state.show_log {
let lines: Vec<String> = state.log.lock().map(|v| v.clone()).unwrap_or_default();
let tail: Vec<Element<Message>> = lines
.iter()
.rev()
.take(80)
.rev()
.map(|l| text(l.clone()).size(11).into())
let rows: Vec<Element<Message>> = lines
.iter().rev().take(80).rev()
.map(|l| text(l.clone()).size(11).style(|_: &Theme| text::Style {
color: Some(MUTED),
}).into())
.collect();
scrollable(Column::with_children(tail).spacing(2))
.height(Length::Fixed(220.0))
column![
toggle_btn,
container(
scrollable(Column::with_children(rows).spacing(1))
.height(Length::Fixed(180.0)),
)
.padding(Padding::from([6, 10]))
.width(Length::Fill)
.style(sub_card_style),
]
.spacing(6)
.into()
} else {
text("").into()
toggle_btn.into()
}
} else {
Space::new().into()
};
let finished_row: Element<Message> = if finished {
// ── Action button ─────────────────────────────────────────────────────────
let action_el: Element<Message> = match state.stage {
Stage::Idle => {
let ready = is_official || !state.source.trim().is_empty();
container(
button(
row![text("Download").size(14), text("").size(14)]
.align_y(Alignment::Center),
)
.on_press_maybe(ready.then_some(Message::PreparePressed))
.style(btn_accent)
.padding([8, 18]),
)
.width(Length::Fill)
.align_x(Alignment::End)
.into()
}
Stage::Busy => container(
button(
row![crate::theme::icon("\u{f130}", 13), text(" Downloading…").size(14)]
.align_y(Alignment::Center).spacing(4),
)
.on_press_maybe(None::<Message>)
.style(btn_ghost)
.padding([8, 18]),
)
.width(Length::Fill)
.align_x(Alignment::End)
.into(),
Stage::Ready => container(
button(
row![text("Install").size(14), text("").size(14)]
.align_y(Alignment::Center),
)
.on_press(Message::InstallPressed)
.style(btn_accent)
.padding([8, 18]),
)
.width(Length::Fill)
.align_x(Alignment::End)
.into(),
Stage::Installing => container(
button(
row![crate::theme::icon("\u{f130}", 13), text(" Installing…").size(14)]
.align_y(Alignment::Center).spacing(4),
)
.on_press_maybe(None::<Message>)
.style(btn_ghost)
.padding([8, 18]),
)
.width(Length::Fill)
.align_x(Alignment::End)
.into(),
Stage::Finished => {
let close_btn = button(text("Close").size(13))
.on_press(Message::Close)
.style(button::secondary);
let launch_btn = button(text("Launch now").size(13))
.style(btn_ghost)
.padding([8, 14]);
let launch_btn = button(
row![crate::theme::icon("\u{f4f4}", 13), text(" Open Launcher").size(13)]
.align_y(Alignment::Center).spacing(4),
)
.on_press_maybe(install_success.then_some(Message::LaunchNow))
.style(button::primary);
row![close_btn, launch_btn].spacing(10).into()
} else {
text("").into()
.style(btn_accent)
.padding([8, 14]);
container(
row![close_btn, launch_btn].spacing(10),
)
.width(Length::Fill)
.align_x(Alignment::End)
.into()
}
Stage::Picking => Space::new().into(),
};
// ── Assembly ──────────────────────────────────────────────────────────────
let body = column![
header,
prefix,
expected,
text(source_label).size(12),
input,
row![prepare_btn, install_btn].spacing(12),
progress_row,
status,
finished_row,
log_pane,
header_row,
source_card,
status_el,
progress_el,
finished_banner,
log_el,
action_el,
]
.spacing(12)
.padding(20);
.padding(Padding { top: 24.0, right: 24.0, bottom: 24.0, left: 24.0 });
container(body).into()
container(scrollable(body))
.width(Length::Fill)
.height(Length::Fill)
.style(surface_bg)
.into()
}
pub fn run(config: &Config, launcher: &Launcher) -> Result<()> {
let config = config.clone();
let launcher = launcher.clone();
let title = format!("umutray setup {}", launcher.display);
iced::application(move |_: &State| title.clone(), update, view)
.subscription(subscription)
.theme(|_| Theme::Dark)
.run_with(move || {
(
State::new_install(config.clone(), launcher.clone()),
Task::none(),
let title = format!("umutray — {}", launcher.display);
let has_url = launcher.installer_url.is_some();
let url = launcher.installer_url.clone().unwrap_or_default();
iced::application(
move || {
let mut state = State::new_install(config.clone(), launcher.clone());
if has_url {
state.source = url.clone();
}
let load_font = iced::font::load(iced_fonts::BOOTSTRAP_FONT_BYTES)
.map(|_| Message::FontLoaded);
let init_task = if has_url {
Task::batch([load_font, Task::done(Message::AutoDownload)])
} else {
load_font
};
(state, init_task)
},
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<()> {
let config = config.clone();
iced::application(|_: &State| "umutray — Add Launcher".to_string(), update, view)
iced::application(
move || {
let load_font = iced::font::load(iced_fonts::BOOTSTRAP_FONT_BYTES)
.map(|_| Message::FontLoaded);
(State::new_picking(config.clone()), load_font)
},
update,
view,
)
.title(|_: &State| "umutray — Add Launcher".to_string())
.subscription(subscription)
.theme(|_| Theme::Dark)
.run_with(move || (State::new_picking(config.clone()), Task::none()))
.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}"))
}
@@ -602,15 +969,7 @@ fn run_installer(
log: Arc<Mutex<Vec<String>>>,
) -> Result<i32, String> {
std::fs::create_dir_all(&launcher.prefix_dir).map_err(|e| e.to_string())?;
let version = launcher
.proton_version
.as_deref()
.unwrap_or(&config.proton_version);
let proton_path: OsString = if version == "GE-Proton" {
version.to_string().into()
} else {
config.proton_compat_dir.join(version).into_os_string()
};
let proton_path = crate::launcher::resolve_proton_path(config, launcher);
let mut child = Command::new("umu-run")
.env("WINEPREFIX", &launcher.prefix_dir)
.env("GAMEID", &launcher.gameid)
+208
View File
@@ -0,0 +1,208 @@
use iced::widget::{button, container, text};
use iced::{Background, Border, Color, Element, Shadow, Theme, Vector};
// ── Palette ────────────────────────────────────────────────────────────────
pub const ACCENT: Color = Color {
r: 0.49,
g: 0.55,
b: 0.97,
a: 1.0,
};
pub const GREEN: Color = Color {
r: 0.29,
g: 0.87,
b: 0.50,
a: 1.0,
};
pub const RED: Color = Color {
r: 0.97,
g: 0.44,
b: 0.44,
a: 1.0,
};
pub const MUTED: Color = Color {
r: 0.42,
g: 0.44,
b: 0.50,
a: 1.0,
};
pub const DIM: Color = Color {
r: 0.55,
g: 0.58,
b: 0.64,
a: 1.0,
};
pub const SURFACE: Color = Color {
r: 0.12,
g: 0.13,
b: 0.16,
a: 1.0,
};
pub const SURFACE_RAISED: Color = Color {
r: 0.15,
g: 0.16,
b: 0.19,
a: 1.0,
};
pub const BORDER_CLR: Color = Color {
r: 0.20,
g: 0.21,
b: 0.26,
a: 1.0,
};
// ── Helpers ────────────────────────────────────────────────────────────────
/// Bootstrap-icon helper — keeps call sites tidy.
pub fn icon(codepoint: &str, size: u32) -> iced::widget::Text<'static> {
text(codepoint.to_owned())
.font(iced::Font::with_name("bootstrap-icons"))
.size(size)
}
/// Styled section heading (uppercase, dimmed).
pub fn section_heading<'a, M: 'a>(label: &str) -> Element<'a, M> {
text(label.to_uppercase())
.size(10)
.style(move |_: &Theme| text::Style { color: Some(DIM) })
.into()
}
// ── Button styles ──────────────────────────────────────────────────────────
/// Accent-filled primary button style.
pub fn btn_accent(_theme: &Theme, status: button::Status) -> button::Style {
let (bg, fg) = match status {
button::Status::Active => (ACCENT, Color::WHITE),
button::Status::Hovered => (Color { a: 0.85, ..ACCENT }, Color::WHITE),
_ => (
Color { a: 0.5, ..ACCENT },
Color {
a: 0.7,
..Color::WHITE
},
),
};
button::Style {
background: Some(Background::Color(bg)),
text_color: fg,
border: Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: 8.0.into(),
},
shadow: NO_SHADOW,
snap: false,
}
}
pub const NO_SHADOW: Shadow = Shadow {
color: Color::TRANSPARENT,
offset: Vector::ZERO,
blur_radius: 0.0,
};
/// Ghost / outline secondary button style.
pub fn btn_ghost(_theme: &Theme, status: button::Status) -> button::Style {
let (bg, border_a) = match status {
button::Status::Hovered => (
Color { r: 0.22, g: 0.23, b: 0.28, a: 1.0 },
0.40,
),
button::Status::Pressed => (
Color { r: 0.25, g: 0.26, b: 0.31, a: 1.0 },
0.50,
),
_ => (
Color { r: 0.18, g: 0.19, b: 0.23, a: 1.0 },
0.25,
),
};
button::Style {
background: Some(Background::Color(bg)),
text_color: Color {
r: 0.78,
g: 0.80,
b: 0.85,
a: 1.0,
},
border: Border {
color: Color { a: border_a, ..BORDER_CLR },
width: 1.0,
radius: 8.0.into(),
},
shadow: NO_SHADOW,
snap: false,
}
}
/// Red danger button style.
pub fn btn_danger(_theme: &Theme, status: button::Status) -> button::Style {
let (bg, fg) = match status {
button::Status::Active => (RED, Color::WHITE),
button::Status::Hovered => (Color { a: 0.85, ..RED }, Color::WHITE),
_ => (
Color { a: 0.5, ..RED },
Color {
a: 0.7,
..Color::WHITE
},
),
};
button::Style {
background: Some(Background::Color(bg)),
text_color: fg,
border: Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: 8.0.into(),
},
shadow: NO_SHADOW,
snap: false,
}
}
// ── Container styles ───────────────────────────────────────────────────────
/// Full-window background style.
pub fn surface_bg(_theme: &Theme) -> container::Style {
container::Style {
background: Some(Background::Color(SURFACE)),
..Default::default()
}
}
/// Raised card style (12px radius, 1px border).
pub fn card_style(_theme: &Theme) -> container::Style {
container::Style {
background: Some(Background::Color(SURFACE_RAISED)),
border: Border {
color: BORDER_CLR,
width: 1.0,
radius: 12.0.into(),
},
..Default::default()
}
}
/// Inner sub-card style (darker background, subtle border, 8px radius).
pub fn sub_card_style(_theme: &Theme) -> container::Style {
container::Style {
background: Some(Background::Color(Color {
r: 0.11,
g: 0.12,
b: 0.15,
a: 0.8,
})),
border: Border {
color: Color {
a: 0.15,
..BORDER_CLR
},
width: 1.0,
radius: 8.0.into(),
},
..Default::default()
}
}
+56 -31
View File
@@ -1,33 +1,39 @@
use crate::{config::Config, launcher};
use anyhow::Result;
use std::collections::HashMap;
use std::path::PathBuf;
use std::thread;
use std::time::Duration;
fn spawn_setup(name: &str) {
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("umutray"));
if let Err(e) = std::process::Command::new(exe)
.arg("setup")
.arg(name)
.spawn()
{
eprintln!("umutray: failed to launch setup for {name}: {e}");
fn spawn_setup(config: &Config, name: &str) {
let config = config.clone();
let name = name.to_owned();
thread::spawn(move || {
if let Some(l) = config.find(&name) {
let l = l.clone();
if let Err(e) = crate::setup::run(&config, &l) {
eprintln!("umutray: setup for {name} failed: {e}");
}
}
});
}
fn spawn_gui() {
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("umutray"));
if let Err(e) = std::process::Command::new(exe).arg("gui").spawn() {
eprintln!("umutray: failed to launch dashboard: {e}");
fn spawn_gui(config: &Config) {
let config = config.clone();
thread::spawn(move || {
match crate::gui::run(&config) {
Ok(_) => {}
Err(e) => eprintln!("umutray: failed to launch dashboard: {e}"),
}
});
}
fn spawn_setup_picker() {
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("umutray"));
if let Err(e) = std::process::Command::new(exe).arg("setup").spawn() {
fn spawn_setup_picker(config: &Config) {
let config = config.clone();
thread::spawn(move || {
if let Err(e) = crate::setup::run_new(&config) {
eprintln!("umutray: failed to launch setup picker: {e}");
}
});
}
enum GameFlag {
@@ -79,9 +85,9 @@ pub struct UmuTray {
pub config: Config,
/// Per-launcher running state keyed by launcher.name
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().
pub handle: Option<ksni::Handle<UmuTray>>,
pub handle: Option<ksni::blocking::Handle<UmuTray>>,
}
impl ksni::Tray for UmuTray {
@@ -90,7 +96,7 @@ impl ksni::Tray for UmuTray {
}
fn icon_name(&self) -> String {
"applications-games".into()
"umutray".into()
}
fn title(&self) -> String {
@@ -107,7 +113,7 @@ impl ksni::Tray for UmuTray {
StandardItem {
label: "Open Dashboard".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()
}
.into(),
@@ -126,8 +132,8 @@ impl ksni::Tray for UmuTray {
StandardItem {
label: format!("Setup {display}…"),
icon_name: "document-new".into(),
activate: Box::new(move |_this: &mut Self| {
spawn_setup(&setup_name);
activate: Box::new(move |this: &mut Self| {
spawn_setup(&this.config, &setup_name);
}),
..Default::default()
}
@@ -144,7 +150,7 @@ impl ksni::Tray for UmuTray {
icon_name: "process-stop".into(),
activate: Box::new(move |this: &mut Self| {
if let Some(l) = this.config.find(&kill_name) {
let _ = launcher::kill(l);
launcher::kill(l);
}
}),
..Default::default()
@@ -245,7 +251,7 @@ impl ksni::Tray for UmuTray {
StandardItem {
label: "Add Launcher…".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()
}
.into(),
@@ -298,6 +304,26 @@ impl ksni::Tray for UmuTray {
/// Start the system tray daemon. Blocks until the process is killed.
pub fn run(config: &Config) -> Result<()> {
let _handle = spawn(config);
loop {
thread::sleep(Duration::from_secs(60));
}
}
/// A handle that can shut down the tray from another thread.
pub struct TrayHandle {
inner: ksni::blocking::Handle<UmuTray>,
}
impl TrayHandle {
pub fn shutdown(&self) {
let h = self.inner.clone();
thread::spawn(move || h.shutdown());
}
}
/// Spawn the tray icon in the background and return a handle to shut it down.
pub fn spawn(config: &Config) -> TrayHandle {
let mut running = HashMap::new();
for l in &config.launchers {
running.insert(l.name.clone(), launcher::is_running(l));
@@ -309,9 +335,10 @@ pub fn run(config: &Config) -> Result<()> {
handle: None,
};
let service = ksni::TrayService::new(tray);
let handle = service.handle();
service.spawn();
let handle = {
use ksni::blocking::TrayMethods;
tray.spawn().expect("Failed to spawn tray service")
};
// Hand the tray a clone of its own handle so Quit can shut down cleanly.
let handle_for_self = handle.clone();
@@ -321,7 +348,7 @@ pub fn run(config: &Config) -> Result<()> {
// Background thread: poll every configured launcher's state every 2 s
// and push the snapshot to the tray.
let poll_handle = handle;
let poll_handle = handle.clone();
let launchers = config.launchers.clone();
thread::spawn(move || loop {
let mut snapshot: HashMap<String, bool> = HashMap::new();
@@ -334,7 +361,5 @@ pub fn run(config: &Config) -> Result<()> {
thread::sleep(Duration::from_secs(2));
});
loop {
thread::sleep(Duration::from_secs(60));
}
TrayHandle { inner: handle }
}
+11 -56
View File
@@ -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
/// 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> {
for (cmd, args) in [
("zenity", vec!["--file-selection", "--directory", "--title", title]),
("kdialog", vec!["--getexistingdirectory", "/home", "--title", title]),
] {
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
rfd::FileDialog::new()
.set_title(title)
.pick_folder()
.map(|p| p.to_string_lossy().into_owned())
}
/// Open a native file picker dialog starting in `start_dir`, or None if
/// the user cancelled. Tries zenity then kdialog.
/// the user cancelled. Uses XDG Desktop Portal where available.
pub fn pick_file(title: &str, start_dir: &str) -> Option<String> {
// zenity uses --filename with a trailing slash to open a directory
let start_slash = format!("{}/", start_dir.trim_end_matches('/'));
let zenity_args = vec![
"--file-selection",
"--title",
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
rfd::FileDialog::new()
.set_title(title)
.set_directory(start_dir)
.pick_file()
.map(|p| p.to_string_lossy().into_owned())
}
+1 -1
View File
@@ -2,7 +2,7 @@
Name=umutray
Comment=Wine launcher manager for Windows game launchers
Exec=umutray gui
Icon=applications-games
Icon=umutray
Type=Application
Categories=Game;
Keywords=wine;proton;gaming;launcher;
-12
View File
@@ -1,12 +0,0 @@
[Unit]
Description=umutray Wine launcher manager
After=graphical-session.target
PartOf=graphical-session.target
[Service]
ExecStart=/usr/bin/umutray
Restart=on-failure
RestartSec=5
[Install]
WantedBy=graphical-session.target