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
This commit is contained in:
@@ -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
|
||||
+4
-6
@@ -48,13 +48,11 @@ build() {
|
||||
package() {
|
||||
cd "$pkgname"
|
||||
|
||||
# Binary — install to ~/.local/bin for user-local usage
|
||||
install -Dm755 target/release/umutray "${HOME}/.local/bin/umutray"
|
||||
# Binary
|
||||
install -Dm755 target/release/umutray "$pkgdir/usr/bin/umutray"
|
||||
|
||||
# App menu entry for the current user
|
||||
install -Dm644 umutray.desktop "${HOME}/.local/share/applications/umutray.desktop"
|
||||
sed -i "s|Exec=umutray|Exec=${HOME}/.local/bin/umutray|" \
|
||||
"${HOME}/.local/share/applications/umutray.desktop"
|
||||
# App menu entry
|
||||
install -Dm644 umutray.desktop "$pkgdir/usr/share/applications/umutray.desktop"
|
||||
|
||||
# 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(),
|
||||
),
|
||||
|
||||
+75
-5
@@ -191,16 +191,86 @@ fn scan_exe_dir(
|
||||
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<()> {
|
||||
|
||||
+670
-327
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);
|
||||
|
||||
|
||||
+15
-4
@@ -8,6 +8,7 @@ mod launcher;
|
||||
mod proton;
|
||||
mod service;
|
||||
mod setup;
|
||||
mod theme;
|
||||
mod tray;
|
||||
mod util;
|
||||
|
||||
@@ -322,10 +323,20 @@ fn main() -> Result<()> {
|
||||
},
|
||||
|
||||
Commands::Gui => {
|
||||
gui::run(&config)?;
|
||||
// After the GUI window closes, continue into the system tray.
|
||||
let config = config::Config::load().unwrap_or(config);
|
||||
tray::run(&config)?;
|
||||
// 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 } => {
|
||||
|
||||
+131
-123
@@ -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::{Alignment, Background, Border, Color, Element, Length, Padding, Subscription, Task, Theme};
|
||||
use std::ffi::OsString;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
@@ -347,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();
|
||||
@@ -406,18 +415,7 @@ fn section_card<'a>(content: impl Into<Element<'a, Message>>) -> Element<'a, Mes
|
||||
container(content)
|
||||
.padding(Padding::from([12, 16]))
|
||||
.width(Length::Fill)
|
||||
.style(|theme: &Theme| {
|
||||
let p = theme.extended_palette();
|
||||
container::Style {
|
||||
background: Some(Background::Color(p.background.weak.color)),
|
||||
border: Border {
|
||||
color: p.background.strong.color,
|
||||
width: 1.0,
|
||||
radius: 6.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
.style(card_style)
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -426,10 +424,12 @@ fn view_picking(state: &State) -> Element<'_, Message> {
|
||||
let header = container(
|
||||
column![
|
||||
step_indicator(1),
|
||||
text("Add a Launcher").size(26),
|
||||
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(Color::from_rgb(0.6, 0.6, 0.6)),
|
||||
color: Some(DIM),
|
||||
}),
|
||||
]
|
||||
.spacing(4),
|
||||
@@ -447,8 +447,8 @@ fn view_picking(state: &State) -> Element<'_, Message> {
|
||||
|
||||
let launcher_card = section_card(
|
||||
column![
|
||||
text("Launcher").size(12).style(|_: &Theme| text::Style {
|
||||
color: Some(Color::from_rgb(0.55, 0.75, 1.0)),
|
||||
text("LAUNCHER").size(10).style(|_: &Theme| text::Style {
|
||||
color: Some(DIM),
|
||||
}),
|
||||
picker,
|
||||
]
|
||||
@@ -461,14 +461,18 @@ fn view_picking(state: &State) -> Element<'_, Message> {
|
||||
.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(12).style(|_: &Theme| text::Style {
|
||||
color: Some(Color::from_rgb(0.55, 0.75, 1.0)),
|
||||
text("INSTALL LOCATION").size(10).style(|_: &Theme| text::Style {
|
||||
color: Some(DIM),
|
||||
}),
|
||||
row![prefix_input, browse_btn]
|
||||
.spacing(8)
|
||||
@@ -476,7 +480,7 @@ fn view_picking(state: &State) -> Element<'_, Message> {
|
||||
text("The folder where the launcher's Wine prefix will be created.")
|
||||
.size(11)
|
||||
.style(|_: &Theme| text::Style {
|
||||
color: Some(Color::from_rgb(0.45, 0.45, 0.45)),
|
||||
color: Some(MUTED),
|
||||
}),
|
||||
]
|
||||
.spacing(6),
|
||||
@@ -486,13 +490,13 @@ fn view_picking(state: &State) -> Element<'_, Message> {
|
||||
let status_el: Element<Message> = if !state.status.is_empty() {
|
||||
container(
|
||||
text(&state.status).size(13).style(|_: &Theme| text::Style {
|
||||
color: Some(Color::from_rgb(1.0, 0.55, 0.45)),
|
||||
color: Some(RED),
|
||||
}),
|
||||
)
|
||||
.padding(Padding::from([6, 0]))
|
||||
.into()
|
||||
} else {
|
||||
text("").into()
|
||||
Space::new(0, 0).into()
|
||||
};
|
||||
|
||||
// ── Next button ───────────────────────────────────────────────────────────
|
||||
@@ -500,9 +504,13 @@ fn view_picking(state: &State) -> Element<'_, Message> {
|
||||
state.selected_template.is_some() && !state.prefix_input.trim().is_empty();
|
||||
|
||||
let next_btn = container(
|
||||
button(text("Next →").size(14))
|
||||
.on_press_maybe(can_confirm.then_some(Message::ConfirmLauncher))
|
||||
.style(button::primary),
|
||||
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(btn_accent)
|
||||
.padding([8, 18]),
|
||||
)
|
||||
.padding(Padding { top: 4.0, right: 0.0, bottom: 0.0, left: 0.0 })
|
||||
.width(Length::Fill)
|
||||
@@ -520,13 +528,14 @@ fn view_picking(state: &State) -> Element<'_, Message> {
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(surface_bg)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn step_indicator<'a>(step: u8) -> Element<'a, Message> {
|
||||
let active = Color::from_rgb(0.55, 0.75, 1.0);
|
||||
let done = Color::from_rgb(0.4, 0.85, 0.4);
|
||||
let muted = Color::from_rgb(0.4, 0.4, 0.4);
|
||||
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 {
|
||||
@@ -534,7 +543,7 @@ fn step_indicator<'a>(step: u8) -> Element<'a, Message> {
|
||||
} else if n == step {
|
||||
(active, active)
|
||||
} else {
|
||||
(muted, muted)
|
||||
(muted_clr, muted_clr)
|
||||
};
|
||||
row![
|
||||
text(format!("{n}")).size(12).style(move |_: &Theme| text::Style { color: Some(num_color) }),
|
||||
@@ -551,9 +560,9 @@ fn step_indicator<'a>(step: u8) -> Element<'a, Message> {
|
||||
container(
|
||||
row![
|
||||
label(1, "Choose"),
|
||||
sep(if step > 1 { done } else { muted }),
|
||||
sep(if step > 1 { done } else { muted_clr }),
|
||||
label(2, "Install"),
|
||||
sep(if step > 2 { done } else { muted }),
|
||||
sep(if step > 2 { done } else { muted_clr }),
|
||||
label(3, "Done"),
|
||||
]
|
||||
.align_y(Alignment::Center),
|
||||
@@ -564,24 +573,17 @@ fn step_indicator<'a>(step: u8) -> Element<'a, Message> {
|
||||
|
||||
fn status_card(msg: String, kind: StatusKind) -> Element<'static, Message> {
|
||||
let color = match kind {
|
||||
StatusKind::Info => Color::from_rgb(0.55, 0.75, 1.0),
|
||||
StatusKind::Success => Color::from_rgb(0.35, 0.85, 0.45),
|
||||
StatusKind::Error => Color::from_rgb(1.0, 0.45, 0.35),
|
||||
StatusKind::Neutral => Color::from_rgb(0.6, 0.6, 0.6),
|
||||
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(|theme: &Theme| {
|
||||
let p = theme.extended_palette();
|
||||
container::Style {
|
||||
background: Some(Background::Color(p.background.weak.color)),
|
||||
border: Border { color: p.background.strong.color, width: 1.0, radius: 6.0.into() },
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
.style(card_style)
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -607,19 +609,25 @@ fn view_install(state: &State) -> Element<'_, Message> {
|
||||
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(text("← Back").size(12))
|
||||
.on_press_maybe(can_go_back.then_some(Message::Back))
|
||||
.style(button::secondary)
|
||||
.into()
|
||||
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 {
|
||||
text("").into()
|
||||
Space::new(0, 0).into()
|
||||
};
|
||||
|
||||
// ── Header ────────────────────────────────────────────────────────────────
|
||||
let header_row = row![
|
||||
column![
|
||||
steps,
|
||||
text(&launcher.display).size(22),
|
||||
text(&launcher.display).size(22).style(|_: &Theme| text::Style {
|
||||
color: Some(ACCENT),
|
||||
}),
|
||||
]
|
||||
.spacing(0),
|
||||
iced::widget::horizontal_space(),
|
||||
@@ -630,8 +638,8 @@ fn view_install(state: &State) -> Element<'_, Message> {
|
||||
// ── 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(12).style(|_: &Theme| text::Style {
|
||||
color: Some(Color::from_rgb(0.55, 0.75, 1.0)),
|
||||
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)
|
||||
@@ -641,7 +649,7 @@ fn view_install(state: &State) -> Element<'_, Message> {
|
||||
.into();
|
||||
section_card(inner)
|
||||
} else {
|
||||
text("").into()
|
||||
Space::new(0, 0).into()
|
||||
};
|
||||
|
||||
// ── Status card ───────────────────────────────────────────────────────────
|
||||
@@ -660,7 +668,7 @@ fn view_install(state: &State) -> Element<'_, Message> {
|
||||
StatusKind::Neutral,
|
||||
)
|
||||
} else {
|
||||
text("").into()
|
||||
Space::new(0, 0).into()
|
||||
};
|
||||
|
||||
// ── Progress bar ──────────────────────────────────────────────────────────
|
||||
@@ -680,46 +688,38 @@ fn view_install(state: &State) -> Element<'_, Message> {
|
||||
.spacing(6),
|
||||
)
|
||||
} else {
|
||||
text("").into()
|
||||
Space::new(0, 0).into()
|
||||
};
|
||||
|
||||
// ── Finished banner ───────────────────────────────────────────────────────
|
||||
let finished_banner: Element<Message> = if finished {
|
||||
let (icon, msg, kind) = if install_success {
|
||||
("✓", format!("{} is ready to use.", launcher.display), StatusKind::Success)
|
||||
let (sym, msg) = if install_success {
|
||||
("\u{f26a}", format!("{} is ready to use.", launcher.display))
|
||||
} else {
|
||||
("✗", "Installation did not complete successfully. See details below.".to_string(), StatusKind::Error)
|
||||
("\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(
|
||||
column![
|
||||
text(format!("{icon} {msg}")).size(15).style({
|
||||
let color = if install_success {
|
||||
Color::from_rgb(0.35, 0.85, 0.45)
|
||||
} else {
|
||||
Color::from_rgb(1.0, 0.45, 0.35)
|
||||
};
|
||||
move |_: &Theme| text::Style { color: Some(color) }
|
||||
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: &Theme| {
|
||||
let p = theme.extended_palette();
|
||||
let border_color = if install_success {
|
||||
Color::from_rgb(0.2, 0.5, 0.25)
|
||||
} else {
|
||||
Color::from_rgb(0.5, 0.2, 0.2)
|
||||
};
|
||||
container::Style {
|
||||
background: Some(Background::Color(p.background.weak.color)),
|
||||
border: Border { color: border_color, width: 1.0, radius: 6.0.into() },
|
||||
..Default::default()
|
||||
}
|
||||
.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()
|
||||
};
|
||||
|
||||
// ── Log toggle + pane ─────────────────────────────────────────────────────
|
||||
@@ -728,14 +728,15 @@ fn view_install(state: &State) -> Element<'_, Message> {
|
||||
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(button::secondary);
|
||||
.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(Color::from_rgb(0.55, 0.55, 0.55)),
|
||||
color: Some(MUTED),
|
||||
}).into())
|
||||
.collect();
|
||||
column![
|
||||
@@ -746,14 +747,7 @@ fn view_install(state: &State) -> Element<'_, Message> {
|
||||
)
|
||||
.padding(Padding::from([6, 10]))
|
||||
.width(Length::Fill)
|
||||
.style(|theme: &Theme| {
|
||||
let p = theme.extended_palette();
|
||||
container::Style {
|
||||
background: Some(Background::Color(p.background.weak.color)),
|
||||
border: Border { color: p.background.strong.color, width: 1.0, radius: 4.0.into() },
|
||||
..Default::default()
|
||||
}
|
||||
}),
|
||||
.style(sub_card_style),
|
||||
]
|
||||
.spacing(6)
|
||||
.into()
|
||||
@@ -761,7 +755,7 @@ fn view_install(state: &State) -> Element<'_, Message> {
|
||||
toggle_btn.into()
|
||||
}
|
||||
} else {
|
||||
text("").into()
|
||||
Space::new(0, 0).into()
|
||||
};
|
||||
|
||||
// ── Action button ─────────────────────────────────────────────────────────
|
||||
@@ -769,34 +763,50 @@ fn view_install(state: &State) -> Element<'_, Message> {
|
||||
Stage::Idle => {
|
||||
let ready = is_official || !state.source.trim().is_empty();
|
||||
container(
|
||||
button(text("Download →").size(14))
|
||||
.on_press_maybe(ready.then_some(Message::PreparePressed))
|
||||
.style(button::primary),
|
||||
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(text("Downloading…").size(14))
|
||||
.on_press_maybe(None::<Message>)
|
||||
.style(button::primary),
|
||||
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(text("Install →").size(14))
|
||||
.on_press(Message::InstallPressed)
|
||||
.style(button::primary),
|
||||
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(text("Installing…").size(14))
|
||||
.on_press_maybe(None::<Message>)
|
||||
.style(button::primary),
|
||||
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)
|
||||
@@ -804,10 +814,15 @@ fn view_install(state: &State) -> Element<'_, Message> {
|
||||
Stage::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))
|
||||
.on_press_maybe(install_success.then_some(Message::LaunchNow))
|
||||
.style(button::primary);
|
||||
.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(btn_accent)
|
||||
.padding([8, 14]);
|
||||
container(
|
||||
row![close_btn, launch_btn].spacing(10),
|
||||
)
|
||||
@@ -815,7 +830,7 @@ fn view_install(state: &State) -> Element<'_, Message> {
|
||||
.align_x(Alignment::End)
|
||||
.into()
|
||||
}
|
||||
Stage::Picking => text("").into(),
|
||||
Stage::Picking => Space::new(0, 0).into(),
|
||||
};
|
||||
|
||||
// ── Assembly ──────────────────────────────────────────────────────────────
|
||||
@@ -834,6 +849,7 @@ fn view_install(state: &State) -> Element<'_, Message> {
|
||||
container(scrollable(body))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(surface_bg)
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -934,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