Compare commits

...

16 Commits

Author SHA1 Message Date
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
15 changed files with 1786 additions and 596 deletions
+7
View File
@@ -1 +1,8 @@
/target /target
.vscode/
# 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.
+5 -4
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. (no ~600 MB in-memory buffering), with a progress indicator.
- `diagnose` — sanity-checks umu-run, Vulkan, display server, per-launcher - `diagnose` — sanity-checks umu-run, Vulkan, display server, per-launcher
prefix / exe / ownership / running state. prefix / exe / ownership / running state.
- `service` — installs a `systemd --user` unit so the tray autostarts with - `service` — installs an XDG autostart entry so the tray autostarts with
the graphical session. the graphical session.
- `setup` — graphical wizard (iced) that downloads an installer URL - `setup` — graphical wizard (iced) that downloads an installer URL
(with progress bar) or accepts a local `.exe`, then runs it via (with progress bar) or accepts a local `.exe`, then runs it via
@@ -59,6 +59,7 @@ umutray service install
| Command | What it does | | Command | What it does |
| -------------------------------- | ------------------------------------------------------- | | -------------------------------- | ------------------------------------------------------- |
| `umutray` | Start the tray daemon (default) | | `umutray` | Start the tray daemon (default) |
| `umutray gui` | Open the graphical dashboard (with tray icon) |
| `umutray launchers` | List configured launchers and their state | | `umutray launchers` | List configured launchers and their state |
| `umutray launch <name>` | Launch a specific launcher (e.g. `umutray launch epic`) | | `umutray launch <name>` | Launch a specific launcher (e.g. `umutray launch epic`) |
| `umutray kill [<name>]` | Kill one launcher, or all if no name is given | | `umutray 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 add-game …` | Attach a game to a launcher (needs `--exe-path`) |
| `umutray config remove-game …` | Drop a game from a launcher | | `umutray config remove-game …` | Drop a game from a launcher |
| `umutray config set-game-flags …`| Per-game overlay toggles: gamemode/mangohud/gamescope | | `umutray config set-game-flags …`| Per-game overlay toggles: gamemode/mangohud/gamescope |
| `umutray service install` | Write + enable a `systemd --user` unit | | `umutray service install` | Write XDG autostart entry (tray starts on login) |
| `umutray service uninstall` | Stop, disable, and remove the unit | | `umutray service uninstall` | Remove the autostart and desktop entries |
| `umutray service status` | `systemctl --user status umutray.service` | | `umutray service status` | Show whether XDG autostart is enabled |
## Config ## 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
+2 -8
View File
@@ -12,7 +12,7 @@
pkgname=umutray pkgname=umutray
pkgver=0.1.0 pkgver=0.1.0
pkgrel=1 pkgrel=4
pkgdesc='Tray-based Wine launcher manager for Linux via umu/Proton-GE' pkgdesc='Tray-based Wine launcher manager for Linux via umu/Proton-GE'
arch=('x86_64') arch=('x86_64')
url='https://git.aleshym.co/funman300/umutray' url='https://git.aleshym.co/funman300/umutray'
@@ -51,14 +51,8 @@ package() {
# Binary # Binary
install -Dm755 target/release/umutray "$pkgdir/usr/bin/umutray" 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" 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"
# License # License
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
+1 -1
View File
@@ -126,7 +126,7 @@ pub fn presets() -> Vec<Launcher> {
"Program Files (x86)/Battle.net/Battle.net Launcher.exe", "Program Files (x86)/Battle.net/Battle.net Launcher.exe",
), ),
gameid: "umu-battlenet".into(), gameid: "umu-battlenet".into(),
process_pattern: r"Battle\.net".into(), process_pattern: r"Battle\.net|Blizzard.*Agent".into(),
installer_url: Some( installer_url: Some(
"https://www.battle.net/download/getInstallerForGame?os=win&gameProgram=BATTLENET_APP&version=Live".into(), "https://www.battle.net/download/getInstallerForGame?os=win&gameProgram=BATTLENET_APP&version=Live".into(),
), ),
+147 -5
View File
@@ -51,6 +51,66 @@ const SYSTEM_DIRS: &[&str] = &[
"windowsapps", "windowsapps",
]; ];
/// Directory names that contain launcher infrastructure, not games.
const SKIP_DIRS: &[&str] = &[
"battle.net",
"electronic arts",
"ea desktop",
"epic games",
"gog galaxy",
"ubisoft",
"rockstar 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",
];
/// Scan a launcher's Wine prefix for installed game executables. /// Scan a launcher's Wine prefix for installed game executables.
/// Returns (display_name, path_relative_to_drive_c) pairs, sorted alphabetically, /// Returns (display_name, path_relative_to_drive_c) pairs, sorted alphabetically,
/// excluding the launcher's own exe and any already-configured games. /// excluding the launcher's own exe and any already-configured games.
@@ -102,6 +162,9 @@ fn scan_exe_dir(
if SYSTEM_DIRS.iter().any(|s| lower.starts_with(s)) { if SYSTEM_DIRS.iter().any(|s| lower.starts_with(s)) {
continue; continue;
} }
if SKIP_DIRS.iter().any(|s| lower == *s) {
continue;
}
if path.is_dir() { if path.is_dir() {
scan_exe_dir(&path, drive_c, launcher_exe, already, out, seen, depth + 1); scan_exe_dir(&path, drive_c, launcher_exe, already, out, seen, depth + 1);
} else if path } else if path
@@ -116,19 +179,98 @@ fn scan_exe_dir(
if rel_lower == launcher_exe || already.contains(&rel_lower) { if rel_lower == launcher_exe || already.contains(&rel_lower) {
continue; 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) { if !seen.insert(rel_lower) {
continue; continue;
} }
let display = path let display = prettify_game_name(&path);
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Unknown")
.to_string();
out.push((display, rel_str)); out.push((display, rel_str));
} }
} }
} }
/// Derive a human-readable game name from an exe path.
///
/// Strategy: use the parent directory name (e.g. "Call of Duty" from
/// `Program Files/Call of Duty/game.exe`) unless it looks generic
/// (like "Bin", "x64", "Binaries"), in which case walk up. Falls back
/// to humanising the exe file stem by inserting spaces before capitals.
fn prettify_game_name(path: &Path) -> String {
// Generic directory names that don't make good game labels
const GENERIC_DIRS: &[&str] = &[
"bin", "binaries", "x64", "x86", "win64", "win32", "retail",
"shipping", "game", "runtime", "_retail_", "_commonredist",
"launcher", "engine", "client",
];
// Try parent directories (closest first, up to 3 levels)
let mut dir = path.parent();
for _ in 0..3 {
let Some(d) = dir else { break };
let name = d.file_name().and_then(|n| n.to_str()).unwrap_or("");
let lower = name.to_lowercase();
if !name.is_empty()
&& !GENERIC_DIRS.iter().any(|g| lower == *g)
&& !lower.starts_with("program files")
{
return name.to_string();
}
dir = d.parent();
}
// Fallback: humanise the exe stem ("BlackOps6" → "Black Ops 6")
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Unknown");
humanise_stem(stem)
}
/// Insert spaces before uppercase runs and digit boundaries.
/// "BlackOps6" → "Black Ops 6", "HITMAN3" → "HITMAN 3"
fn humanise_stem(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 4);
let chars: Vec<char> = s.chars().collect();
for (i, &c) in chars.iter().enumerate() {
if i > 0 {
let prev = chars[i - 1];
// Letter→digit or digit→letter boundary
if (prev.is_alphabetic() && c.is_ascii_digit())
|| (prev.is_ascii_digit() && c.is_alphabetic())
{
out.push(' ');
}
// lowercase→uppercase ("nO" in "BlackOps")
else if prev.is_lowercase() && c.is_uppercase() {
out.push(' ');
}
// UPPER run ending: "ABCdef" → "AB Cdef"
else if i + 1 < chars.len()
&& prev.is_uppercase()
&& c.is_uppercase()
&& chars[i + 1].is_lowercase()
{
out.push(' ');
}
}
// Replace underscores / hyphens with spaces
if c == '_' || c == '-' {
out.push(' ');
} else {
out.push(c);
}
}
out
}
const MAX_DEPTH: u32 = 3; const MAX_DEPTH: u32 = 3;
pub fn run(config: &Config, extra_dirs: &[PathBuf], apply: bool) -> Result<()> { pub fn run(config: &Config, extra_dirs: &[PathBuf], apply: bool) -> Result<()> {
+825 -270
View File
File diff suppressed because it is too large Load Diff
+16 -20
View File
@@ -6,6 +6,20 @@ use std::process::Stdio;
use std::thread; use std::thread;
use std::time::Duration; 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. /// Spawn the launcher via umu-run and return immediately.
pub fn launch(config: &Config, launcher: &Launcher) -> Result<()> { pub fn launch(config: &Config, launcher: &Launcher) -> Result<()> {
let exe = launcher.full_exe_path(); let exe = launcher.full_exe_path();
@@ -18,17 +32,7 @@ pub fn launch(config: &Config, launcher: &Launcher) -> Result<()> {
); );
} }
// PROTONPATH: umu-run accepts the literal "GE-Proton" to auto-fetch the let proton_path = resolve_proton_path(config, launcher);
// latest; for any pinned version it expects a full path to the install dir.
let version = launcher
.proton_version
.as_deref()
.unwrap_or(&config.proton_version);
let proton_path: std::ffi::OsString = if version == "GE-Proton" {
version.to_string().into()
} else {
config.proton_compat_dir.join(version).into_os_string()
};
std::process::Command::new("umu-run") std::process::Command::new("umu-run")
.env("WINEPREFIX", &launcher.prefix_dir) .env("WINEPREFIX", &launcher.prefix_dir)
@@ -59,15 +63,7 @@ pub fn play_game(config: &Config, launcher: &Launcher, game: &Game) -> Result<()
); );
} }
let version = launcher let proton_path = resolve_proton_path(config, 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 (prog, args) = build_wrapped_argv(&exe, game); let (prog, args) = build_wrapped_argv(&exe, game);
+17 -1
View File
@@ -8,6 +8,7 @@ mod launcher;
mod proton; mod proton;
mod service; mod service;
mod setup; mod setup;
mod theme;
mod tray; mod tray;
mod util; mod util;
@@ -321,7 +322,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 } => { Commands::Detect { dir, apply } => {
detect::run(&config, &dir, apply)?; detect::run(&config, &dir, apply)?;
+476 -175
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::{async_blocking, pick_folder},
};
use anyhow::Result; use anyhow::Result;
use iced::widget::{ 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::{Color, Element, Length, Subscription, Task, Theme};
use std::ffi::OsString;
use std::io::{BufRead, BufReader, Read, Write}; use std::io::{BufRead, BufReader, Read, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
@@ -22,6 +31,7 @@ pub enum Message {
// Install stage // Install stage
Back, Back,
SourceChanged(String), SourceChanged(String),
AutoDownload,
PreparePressed, PreparePressed,
PrepareDone(Result<PathBuf, String>), PrepareDone(Result<PathBuf, String>),
InstallPressed, InstallPressed,
@@ -30,6 +40,7 @@ pub enum Message {
// Finished stage // Finished stage
LaunchNow, LaunchNow,
Close, Close,
ToggleLog,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -65,6 +76,7 @@ struct State {
status: String, status: String,
download: Arc<Mutex<DownloadProgress>>, download: Arc<Mutex<DownloadProgress>>,
log: Arc<Mutex<Vec<String>>>, log: Arc<Mutex<Vec<String>>>,
show_log: bool,
} }
impl Drop for State { impl Drop for State {
@@ -94,14 +106,11 @@ impl State {
status: String::new(), status: String::new(),
download: Arc::new(Mutex::new(DownloadProgress::default())), download: Arc::new(Mutex::new(DownloadProgress::default())),
log: Arc::new(Mutex::new(Vec::new())), log: Arc::new(Mutex::new(Vec::new())),
show_log: false,
} }
} }
fn new_install(config: Config, launcher: Launcher) -> Self { fn new_install(config: Config, launcher: Launcher) -> Self {
let status = format!(
"Paste an installer URL or a local .exe path for {}.",
launcher.display
);
let template_options = config::presets() let template_options = config::presets()
.iter() .iter()
.map(|l| l.display.clone()) .map(|l| l.display.clone())
@@ -116,9 +125,10 @@ impl State {
installer: None, installer: None,
downloaded_temp: None, downloaded_temp: None,
stage: Stage::Idle, stage: Stage::Idle,
status, status: String::new(),
download: Arc::new(Mutex::new(DownloadProgress::default())), download: Arc::new(Mutex::new(DownloadProgress::default())),
log: Arc::new(Mutex::new(Vec::new())), log: Arc::new(Mutex::new(Vec::new())),
show_log: false,
} }
} }
} }
@@ -171,16 +181,31 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
preset.prefix_dir = PathBuf::from(&prefix); preset.prefix_dir = PathBuf::from(&prefix);
// If we know the official installer URL, pre-fill source and // If we know the official installer URL, pre-fill source and
// let the user confirm before downloading. // start the download automatically.
if let Some(url) = preset.installer_url.clone() { if let Some(url) = preset.installer_url.clone() {
state.source = url; state.source = url.clone();
state.stage = Stage::Idle;
state.status = format!(
"Ready to download the official {} installer. Press Download → to begin.",
preset.display
);
state.launcher = Some(preset); state.launcher = Some(preset);
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();
return Task::perform(
async_blocking(move || download_blocking(&url, &name, progress)),
Message::PrepareDone,
);
} }
state.status = format!( state.status = format!(
@@ -209,6 +234,34 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
state.source = s; state.source = s;
Task::none() 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_blocking(move || download_blocking(&url, &name, progress)),
Message::PrepareDone,
)
}
Message::PreparePressed => { Message::PreparePressed => {
let src = state.source.trim().to_string(); let src = state.source.trim().to_string();
if src.is_empty() { if src.is_empty() {
@@ -270,15 +323,21 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
state.status = format!("Download failed: {e}"); state.status = format!("Download failed: {e}");
Task::none() Task::none()
} }
Message::ToggleLog => {
state.show_log = !state.show_log;
Task::none()
}
Message::InstallPressed => { Message::InstallPressed => {
let Some(installer) = state.installer.clone() else { let Some(installer) = state.installer.clone() else {
return Task::none(); return Task::none();
}; };
state.stage = Stage::Installing; 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() { if let Ok(mut v) = state.log.lock() {
v.clear(); v.clear();
} }
state.show_log = false;
let config = state.config.clone(); let config = state.config.clone();
let launcher = state let launcher = state
.launcher .launcher
@@ -297,7 +356,7 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
.expect("launcher set before install"); .expect("launcher set before install");
let exe = launcher.full_exe_path(); let exe = launcher.full_exe_path();
// Save to config only on successful install // 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() { if state.config.find(&launcher.name).is_none() {
state.config.launchers.push(launcher.clone()); state.config.launchers.push(launcher.clone());
let _ = state.config.save(); let _ = state.config.save();
@@ -352,10 +411,32 @@ fn view(state: &State) -> Element<'_, Message> {
view_install(state) view_install(state)
} }
fn view_picking(state: &State) -> Element<'_, Message> { fn section_card<'a>(content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
let header = text("Add a Launcher").size(24); container(content)
let sub = text("Choose a launcher and where to install it.").size(13); .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( let picker = pick_list(
state.template_options.as_slice(), state.template_options.as_slice(),
state.selected_template.clone(), state.selected_template.clone(),
@@ -364,125 +445,235 @@ fn view_picking(state: &State) -> Element<'_, Message> {
.placeholder("Select a launcher…") .placeholder("Select a launcher…")
.width(Length::Fill); .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) .on_input(Message::PrefixChanged)
.padding(8) .padding(8)
.width(Length::Fill); .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) .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(0, 0).into()
};
// ── Next button ───────────────────────────────────────────────────────────
let can_confirm = let can_confirm =
state.selected_template.is_some() && !state.prefix_input.trim().is_empty(); 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)) .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, header,
sub, container(
text("Launcher:").size(13), column![launcher_card, location_card, status_el, next_btn]
picker, .spacing(12)
text("Install location (Wine prefix):").size(13), .padding(Padding { top: 8.0, right: 24.0, bottom: 24.0, left: 24.0 }),
row![prefix_input, browse_btn].spacing(8).align_y(iced::Alignment::Center), ),
confirm_btn,
] ]
.spacing(10) )
.padding(20); .width(Length::Fill)
.height(Length::Fill)
if !state.status.is_empty() { .style(surface_bg)
body = body.push(text(state.status.clone()).size(13)); .into()
}
container(body).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> { fn view_install(state: &State) -> Element<'_, Message> {
let launcher = state let launcher = state
.launcher .launcher
.as_ref() .as_ref()
.expect("launcher must be set in install stage"); .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 back_btn: Element<Message> = if state.selected_template.is_some() {
button(text("← Back").size(13))
.on_press_maybe(can_go_back.then_some(Message::Back))
.style(button::secondary)
.into()
} else {
text("").into()
};
let header = row![
text(format!("Setup: {}", launcher.display)).size(24),
iced::widget::horizontal_space(),
back_btn,
]
.align_y(iced::Alignment::Center);
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 detected — or paste a custom URL:"
} else {
"Installer URL or local .exe path:"
};
let input = text_input("https://… or /path/to/installer.exe", &state.source)
.on_input(Message::SourceChanged)
.padding(8);
let finished = matches!(state.stage, Stage::Finished); let finished = matches!(state.stage, Stage::Finished);
let install_success = finished && launcher.full_exe_path().exists(); 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);
// Single context-aware action button (changes 3 & 4) // ── Step indicator ────────────────────────────────────────────────────────
let action_btn: Option<Element<Message>> = match state.stage { let step = if finished { 3 } else { 2 };
Stage::Idle => { let steps = step_indicator(step);
let enabled = !state.source.trim().is_empty();
Some( // ── Back button ───────────────────────────────────────────────────────────
button(text("Download →")) let can_go_back = state.selected_template.is_some()
.on_press_maybe(enabled.then_some(Message::PreparePressed)) && matches!(state.stage, Stage::Idle | Stage::Ready);
.into(), 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))
Stage::Busy => Some( .style(btn_ghost)
button(text("Downloading…")) .padding([6, 14])
.on_press_maybe(None::<Message>) .into()
.into(), } else {
), Space::new(0, 0).into()
Stage::Ready => Some(
button(text("Install →"))
.on_press(Message::InstallPressed)
.into(),
),
Stage::Installing => Some(
button(text("Installing…"))
.on_press_maybe(None::<Message>)
.into(),
),
Stage::Finished => None,
Stage::Picking => None,
}; };
let status = text(state.status.clone()); // ── Header ────────────────────────────────────────────────────────────────
let header_row = row![
column![
steps,
text(&launcher.display).size(22).style(|_: &Theme| text::Style {
color: Some(ACCENT),
}),
]
.spacing(0),
iced::widget::horizontal_space(),
back_el,
]
.align_y(Alignment::Start);
let progress_row: Element<Message> = if matches!(state.stage, Stage::Busy) { // ── Source / installer card (only shown in Idle, for custom URLs) ────────
let p = state let source_card: Element<Message> = if matches!(state.stage, Stage::Idle) && !is_official {
.download let inner: Element<Message> = column![
.lock() text("INSTALLER").size(10).style(|_: &Theme| text::Style {
.map(|p| (p.bytes, p.total)) color: Some(DIM),
.unwrap_or((0, None)); }),
text_input("Paste a URL or local .exe path", &state.source)
.on_input(Message::SourceChanged)
.padding(8),
]
.spacing(6)
.into();
section_card(inner)
} else {
Space::new(0, 0).into()
};
// ── 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(0, 0).into()
};
// ── 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 (bytes, total) = p;
let fraction = match total { let fraction = match total {
Some(t) if t > 0 => (bytes as f32) / (t as f32), Some(t) if t > 0 => (bytes as f32) / (t as f32),
@@ -492,87 +683,200 @@ fn view_install(state: &State) -> Element<'_, Message> {
Some(t) => format!("{} / {}", fmt_bytes(bytes), fmt_bytes(t)), Some(t) => format!("{} / {}", fmt_bytes(bytes), fmt_bytes(t)),
None => format!("{} downloaded", fmt_bytes(bytes)), None => format!("{} downloaded", fmt_bytes(bytes)),
}; };
section_card(
column![progress_bar(0.0..=1.0, fraction), text(label).size(12)] column![progress_bar(0.0..=1.0, fraction), text(label).size(12)]
.spacing(4) .spacing(6),
.into() )
} else { } else {
text("").into() Space::new(0, 0).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(0, 0).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 lines: Vec<String> = state.log.lock().map(|v| v.clone()).unwrap_or_default();
let tail: Vec<Element<Message>> = lines let rows: Vec<Element<Message>> = lines
.iter() .iter().rev().take(80).rev()
.rev() .map(|l| text(l.clone()).size(11).style(|_: &Theme| text::Style {
.take(80) color: Some(MUTED),
.rev() }).into())
.map(|l| text(l.clone()).size(11).into())
.collect(); .collect();
scrollable(Column::with_children(tail).spacing(2)) column![
.height(Length::Fixed(220.0)) 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() .into()
} else { } else {
text("").into() toggle_btn.into()
}
} else {
Space::new(0, 0).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)) let close_btn = button(text("Close").size(13))
.on_press(Message::Close) .on_press(Message::Close)
.style(button::secondary); .style(btn_ghost)
let launch_btn = button(text("Open launcher").size(13)) .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)) .on_press_maybe(install_success.then_some(Message::LaunchNow))
.style(button::primary); .style(btn_accent)
row![close_btn, launch_btn].spacing(10).into() .padding([8, 14]);
} else { container(
text("").into() row![close_btn, launch_btn].spacing(10),
}; )
.width(Length::Fill)
// Success banner (change 7) .align_x(Alignment::End)
let success_banner: Element<Message> = if finished && install_success {
text(format!("{} installed successfully!", launcher.display))
.size(14)
.color(Color::from_rgb(0.4, 0.9, 0.4))
.into() .into()
} else { }
text("").into() Stage::Picking => Space::new(0, 0).into(),
}; };
let mut body = column![ // ── Assembly ──────────────────────────────────────────────────────────────
header, let body = column![
prefix, header_row,
expected, source_card,
text(source_label).size(12), status_el,
input, progress_el,
finished_banner,
log_el,
action_el,
] ]
.spacing(12) .spacing(12)
.padding(20); .padding(Padding { top: 24.0, right: 24.0, bottom: 24.0, left: 24.0 });
if let Some(btn) = action_btn { container(scrollable(body))
body = body.push(btn); .width(Length::Fill)
} .height(Length::Fill)
.style(surface_bg)
let body = body .into()
.push(progress_row)
.push(status)
.push(success_banner)
.push(finished_row)
.push(log_pane);
container(body).into()
} }
pub fn run(config: &Config, launcher: &Launcher) -> Result<()> { pub fn run(config: &Config, launcher: &Launcher) -> Result<()> {
let config = config.clone(); let config = config.clone();
let launcher = launcher.clone(); let launcher = launcher.clone();
let title = format!("umutray setup {}", launcher.display); let title = format!("umutray — {}", launcher.display);
iced::application(move |_: &State| title.clone(), update, view) iced::application(move |_: &State| title.clone(), update, view)
.subscription(subscription) .subscription(subscription)
.theme(|_| Theme::Dark) .theme(|_| Theme::Dark)
.window(iced::window::Settings {
size: iced::Size::new(520.0, 440.0),
resizable: false,
..Default::default()
})
.run_with(move || { .run_with(move || {
( let has_url = launcher.installer_url.is_some();
State::new_install(config.clone(), launcher.clone()), let mut state = State::new_install(config.clone(), launcher.clone());
Task::none(), if has_url {
) state.source = launcher.installer_url.unwrap_or_default();
}
let init_task = if has_url {
Task::done(Message::AutoDownload)
} else {
Task::none()
};
(state, init_task)
}) })
.map_err(|e| anyhow::anyhow!("iced: {e}")) .map_err(|e| anyhow::anyhow!("iced: {e}"))
} }
@@ -582,6 +886,11 @@ pub fn run_new(config: &Config) -> Result<()> {
iced::application(|_: &State| "umutray — Add Launcher".to_string(), update, view) iced::application(|_: &State| "umutray — Add Launcher".to_string(), update, view)
.subscription(subscription) .subscription(subscription)
.theme(|_| Theme::Dark) .theme(|_| Theme::Dark)
.window(iced::window::Settings {
size: iced::Size::new(520.0, 440.0),
resizable: false,
..Default::default()
})
.run_with(move || (State::new_picking(config.clone()), Task::none())) .run_with(move || (State::new_picking(config.clone()), Task::none()))
.map_err(|e| anyhow::anyhow!("iced: {e}")) .map_err(|e| anyhow::anyhow!("iced: {e}"))
} }
@@ -641,15 +950,7 @@ fn run_installer(
log: Arc<Mutex<Vec<String>>>, log: Arc<Mutex<Vec<String>>>,
) -> Result<i32, String> { ) -> Result<i32, String> {
std::fs::create_dir_all(&launcher.prefix_dir).map_err(|e| e.to_string())?; std::fs::create_dir_all(&launcher.prefix_dir).map_err(|e| e.to_string())?;
let version = launcher let proton_path = crate::launcher::resolve_proton_path(config, 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 mut child = Command::new("umu-run") let mut child = Command::new("umu-run")
.env("WINEPREFIX", &launcher.prefix_dir) .env("WINEPREFIX", &launcher.prefix_dir)
.env("GAMEID", &launcher.gameid) .env("GAMEID", &launcher.gameid)
+205
View File
@@ -0,0 +1,205 @@
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: u16) -> 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,
}
}
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,
}
}
/// 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,
}
}
// ── 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()
}
}
+22 -4
View File
@@ -298,6 +298,26 @@ impl ksni::Tray for UmuTray {
/// Start the system tray daemon. Blocks until the process is killed. /// Start the system tray daemon. Blocks until the process is killed.
pub fn run(config: &Config) -> Result<()> { pub fn run(config: &Config) -> Result<()> {
let _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::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(); let mut running = HashMap::new();
for l in &config.launchers { for l in &config.launchers {
running.insert(l.name.clone(), launcher::is_running(l)); running.insert(l.name.clone(), launcher::is_running(l));
@@ -321,7 +341,7 @@ pub fn run(config: &Config) -> Result<()> {
// Background thread: poll every configured launcher's state every 2 s // Background thread: poll every configured launcher's state every 2 s
// and push the snapshot to the tray. // and push the snapshot to the tray.
let poll_handle = handle; let poll_handle = handle.clone();
let launchers = config.launchers.clone(); let launchers = config.launchers.clone();
thread::spawn(move || loop { thread::spawn(move || loop {
let mut snapshot: HashMap<String, bool> = HashMap::new(); let mut snapshot: HashMap<String, bool> = HashMap::new();
@@ -334,7 +354,5 @@ pub fn run(config: &Config) -> Result<()> {
thread::sleep(Duration::from_secs(2)); thread::sleep(Duration::from_secs(2));
}); });
loop { TrayHandle { inner: handle }
thread::sleep(Duration::from_secs(60));
}
} }
-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