Compare commits
16 Commits
c4587b0729
...
f3f5046265
| Author | SHA1 | Date | |
|---|---|---|---|
| f3f5046265 | |||
| 4e204d4bf7 | |||
| d3ac300b91 | |||
| 3c1742174b | |||
| b81c7fd863 | |||
| a1afa59f1a | |||
| 32c6e1fce0 | |||
| 20509fb488 | |||
| f645b58470 | |||
| 9ad1e6a745 | |||
| f70498158a | |||
| 3c78e1586f | |||
| 0c22e23ad3 | |||
| 108f385973 | |||
| d97a13e289 | |||
| 2e51b2e788 |
@@ -1 +1,8 @@
|
||||
/target
|
||||
.vscode/
|
||||
|
||||
# Packaging build artifacts
|
||||
packaging/pkg/
|
||||
packaging/src/
|
||||
packaging/umutray/
|
||||
packaging/*.pkg.tar.zst
|
||||
|
||||
Vendored
-3
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"makefile.configureOnOpen": false
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
- `service` — installs an XDG autostart entry so the tray autostarts 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
|
||||
@@ -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 service install` | Write XDG autostart entry (tray starts on login) |
|
||||
| `umutray service uninstall` | Remove the autostart and desktop entries |
|
||||
| `umutray service status` | Show whether XDG autostart is enabled |
|
||||
|
||||
## Config
|
||||
|
||||
|
||||
@@ -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
@@ -12,7 +12,7 @@
|
||||
|
||||
pkgname=umutray
|
||||
pkgver=0.1.0
|
||||
pkgrel=1
|
||||
pkgrel=4
|
||||
pkgdesc='Tray-based Wine launcher manager for Linux via umu/Proton-GE'
|
||||
arch=('x86_64')
|
||||
url='https://git.aleshym.co/funman300/umutray'
|
||||
@@ -51,14 +51,8 @@ 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"
|
||||
|
||||
# License
|
||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
|
||||
+1
-1
@@ -126,7 +126,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(),
|
||||
),
|
||||
|
||||
+147
-5
@@ -51,6 +51,66 @@ const SYSTEM_DIRS: &[&str] = &[
|
||||
"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.
|
||||
/// Returns (display_name, path_relative_to_drive_c) pairs, sorted alphabetically,
|
||||
/// 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)) {
|
||||
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
|
||||
@@ -116,19 +179,98 @@ 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 = prettify_game_name(&path);
|
||||
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;
|
||||
|
||||
pub fn run(config: &Config, extra_dirs: &[PathBuf], apply: bool) -> Result<()> {
|
||||
|
||||
+878
-323
File diff suppressed because it is too large
Load Diff
+16
-20
@@ -6,6 +6,20 @@ 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();
|
||||
@@ -18,17 +32,7 @@ pub fn launch(config: &Config, launcher: &Launcher) -> Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
// PROTONPATH: umu-run accepts the literal "GE-Proton" to auto-fetch the
|
||||
// latest; for any pinned version it expects a full path to the install dir.
|
||||
let version = launcher
|
||||
.proton_version
|
||||
.as_deref()
|
||||
.unwrap_or(&config.proton_version);
|
||||
let proton_path: std::ffi::OsString = if version == "GE-Proton" {
|
||||
version.to_string().into()
|
||||
} else {
|
||||
config.proton_compat_dir.join(version).into_os_string()
|
||||
};
|
||||
let proton_path = resolve_proton_path(config, launcher);
|
||||
|
||||
std::process::Command::new("umu-run")
|
||||
.env("WINEPREFIX", &launcher.prefix_dir)
|
||||
@@ -59,15 +63,7 @@ pub fn play_game(config: &Config, launcher: &Launcher, game: &Game) -> Result<()
|
||||
);
|
||||
}
|
||||
|
||||
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 = resolve_proton_path(config, launcher);
|
||||
|
||||
let (prog, args) = build_wrapped_argv(&exe, game);
|
||||
|
||||
|
||||
+17
-1
@@ -8,6 +8,7 @@ mod launcher;
|
||||
mod proton;
|
||||
mod service;
|
||||
mod setup;
|
||||
mod theme;
|
||||
mod tray;
|
||||
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 } => {
|
||||
detect::run(&config, &dir, apply)?;
|
||||
|
||||
+486
-185
@@ -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 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::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,7 @@ pub enum Message {
|
||||
// Finished stage
|
||||
LaunchNow,
|
||||
Close,
|
||||
ToggleLog,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -65,6 +76,7 @@ struct State {
|
||||
status: String,
|
||||
download: Arc<Mutex<DownloadProgress>>,
|
||||
log: Arc<Mutex<Vec<String>>>,
|
||||
show_log: bool,
|
||||
}
|
||||
|
||||
impl Drop for State {
|
||||
@@ -94,14 +106,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 for {}.",
|
||||
launcher.display
|
||||
);
|
||||
let template_options = config::presets()
|
||||
.iter()
|
||||
.map(|l| l.display.clone())
|
||||
@@ -116,9 +125,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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,16 +181,31 @@ 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
|
||||
// let the user confirm before downloading.
|
||||
// start the download automatically.
|
||||
if let Some(url) = preset.installer_url.clone() {
|
||||
state.source = url;
|
||||
state.stage = Stage::Idle;
|
||||
state.status = format!(
|
||||
"Ready to download the official {} installer. Press Download → to begin.",
|
||||
preset.display
|
||||
);
|
||||
state.source = url.clone();
|
||||
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!(
|
||||
@@ -209,6 +234,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_blocking(move || download_blocking(&url, &name, progress)),
|
||||
Message::PrepareDone,
|
||||
)
|
||||
}
|
||||
Message::PreparePressed => {
|
||||
let src = state.source.trim().to_string();
|
||||
if src.is_empty() {
|
||||
@@ -270,15 +323,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
|
||||
@@ -297,7 +356,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();
|
||||
@@ -352,10 +411,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(),
|
||||
@@ -364,125 +445,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))
|
||||
.on_press(Message::BrowsePrefix)
|
||||
.style(button::secondary);
|
||||
let browse_btn = button(
|
||||
row![icon("\u{f3e8}", 12), text(" Browse…").size(13)]
|
||||
.align_y(Alignment::Center).spacing(4),
|
||||
)
|
||||
.on_press(Message::BrowsePrefix)
|
||||
.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 =
|
||||
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![
|
||||
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,
|
||||
]
|
||||
.spacing(10)
|
||||
.padding(20);
|
||||
|
||||
if !state.status.is_empty() {
|
||||
body = body.push(text(state.status.clone()).size(13));
|
||||
}
|
||||
|
||||
container(body).into()
|
||||
container(
|
||||
column![
|
||||
header,
|
||||
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 }),
|
||||
),
|
||||
]
|
||||
)
|
||||
.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 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 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)
|
||||
let action_btn: Option<Element<Message>> = match state.stage {
|
||||
Stage::Idle => {
|
||||
let enabled = !state.source.trim().is_empty();
|
||||
Some(
|
||||
button(text("Download →"))
|
||||
.on_press_maybe(enabled.then_some(Message::PreparePressed))
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
Stage::Busy => Some(
|
||||
button(text("Downloading…"))
|
||||
.on_press_maybe(None::<Message>)
|
||||
.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,
|
||||
// ── 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(btn_ghost)
|
||||
.padding([6, 14])
|
||||
.into()
|
||||
} else {
|
||||
Space::new(0, 0).into()
|
||||
};
|
||||
|
||||
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) {
|
||||
let p = state
|
||||
.download
|
||||
.lock()
|
||||
.map(|p| (p.bytes, p.total))
|
||||
.unwrap_or((0, None));
|
||||
// ── 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 {
|
||||
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 fraction = match total {
|
||||
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)),
|
||||
None => format!("{} downloaded", fmt_bytes(bytes)),
|
||||
};
|
||||
column![progress_bar(0.0..=1.0, fraction), text(label).size(12)]
|
||||
.spacing(4)
|
||||
.into()
|
||||
section_card(
|
||||
column![progress_bar(0.0..=1.0, fraction), text(label).size(12)]
|
||||
.spacing(6),
|
||||
)
|
||||
} else {
|
||||
text("").into()
|
||||
Space::new(0, 0).into()
|
||||
};
|
||||
|
||||
let log_pane: Element<Message> = if matches!(state.stage, Stage::Installing | Stage::Finished) {
|
||||
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())
|
||||
.collect();
|
||||
scrollable(Column::with_children(tail).spacing(2))
|
||||
.height(Length::Fixed(220.0))
|
||||
.into()
|
||||
// ── 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 {
|
||||
text("").into()
|
||||
Space::new(0, 0).into()
|
||||
};
|
||||
|
||||
let finished_row: Element<Message> = if finished {
|
||||
let close_btn = button(text("Close").size(13))
|
||||
.on_press(Message::Close)
|
||||
.style(button::secondary);
|
||||
let launch_btn = button(text("Open launcher").size(13))
|
||||
// ── 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 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();
|
||||
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 {
|
||||
toggle_btn.into()
|
||||
}
|
||||
} else {
|
||||
Space::new(0, 0).into()
|
||||
};
|
||||
|
||||
// ── 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(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()
|
||||
};
|
||||
|
||||
// Success banner (change 7)
|
||||
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))
|
||||
.style(btn_accent)
|
||||
.padding([8, 14]);
|
||||
container(
|
||||
row![close_btn, launch_btn].spacing(10),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.align_x(Alignment::End)
|
||||
.into()
|
||||
} else {
|
||||
text("").into()
|
||||
}
|
||||
Stage::Picking => Space::new(0, 0).into(),
|
||||
};
|
||||
|
||||
let mut body = column![
|
||||
header,
|
||||
prefix,
|
||||
expected,
|
||||
text(source_label).size(12),
|
||||
input,
|
||||
// ── Assembly ──────────────────────────────────────────────────────────────
|
||||
let body = column![
|
||||
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 });
|
||||
|
||||
if let Some(btn) = action_btn {
|
||||
body = body.push(btn);
|
||||
}
|
||||
|
||||
let body = body
|
||||
.push(progress_row)
|
||||
.push(status)
|
||||
.push(success_banner)
|
||||
.push(finished_row)
|
||||
.push(log_pane);
|
||||
|
||||
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);
|
||||
let title = format!("umutray — {}", launcher.display);
|
||||
iced::application(move |_: &State| title.clone(), update, view)
|
||||
.subscription(subscription)
|
||||
.theme(|_| Theme::Dark)
|
||||
.window(iced::window::Settings {
|
||||
size: iced::Size::new(520.0, 440.0),
|
||||
resizable: false,
|
||||
..Default::default()
|
||||
})
|
||||
.run_with(move || {
|
||||
(
|
||||
State::new_install(config.clone(), launcher.clone()),
|
||||
Task::none(),
|
||||
)
|
||||
let has_url = launcher.installer_url.is_some();
|
||||
let mut state = State::new_install(config.clone(), launcher.clone());
|
||||
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}"))
|
||||
}
|
||||
@@ -582,6 +886,11 @@ pub fn run_new(config: &Config) -> Result<()> {
|
||||
iced::application(|_: &State| "umutray — Add Launcher".to_string(), update, view)
|
||||
.subscription(subscription)
|
||||
.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()))
|
||||
.map_err(|e| anyhow::anyhow!("iced: {e}"))
|
||||
}
|
||||
@@ -641,15 +950,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)
|
||||
|
||||
+205
@@ -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
@@ -298,6 +298,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::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));
|
||||
@@ -321,7 +341,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 +354,5 @@ pub fn run(config: &Config) -> Result<()> {
|
||||
thread::sleep(Duration::from_secs(2));
|
||||
});
|
||||
|
||||
loop {
|
||||
thread::sleep(Duration::from_secs(60));
|
||||
}
|
||||
TrayHandle { inner: handle }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user