new file: Makefile
new file: TODO.md modified: src/config.rs modified: src/detect.rs modified: src/diagnose.rs new file: src/gui.rs modified: src/main.rs modified: src/service.rs modified: src/setup.rs modified: src/tray.rs new file: src/util.rs new file: umutray.desktop
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
PREFIX ?= $(HOME)/.local
|
||||
BIN_DIR = $(PREFIX)/bin
|
||||
APP_DIR = $(PREFIX)/share/applications
|
||||
|
||||
.PHONY: build install uninstall
|
||||
|
||||
build:
|
||||
cargo build --release
|
||||
|
||||
install: build
|
||||
install -Dm755 target/release/umutray $(BIN_DIR)/umutray
|
||||
@mkdir -p $(APP_DIR)
|
||||
@sed "s|Exec=umutray|Exec=$(BIN_DIR)/umutray|" umutray.desktop > $(APP_DIR)/umutray.desktop
|
||||
@echo ""
|
||||
@echo "Installed umutray to $(BIN_DIR)/umutray"
|
||||
@echo "App menu entry written to $(APP_DIR)/umutray.desktop"
|
||||
@echo ""
|
||||
@echo "Optional: run 'umutray service install' to autostart the tray on login."
|
||||
|
||||
uninstall:
|
||||
rm -f $(BIN_DIR)/umutray
|
||||
rm -f $(APP_DIR)/umutray.desktop
|
||||
@echo "Uninstalled umutray"
|
||||
@@ -0,0 +1,8 @@
|
||||
# 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
-6
@@ -205,7 +205,7 @@ impl Default for Config {
|
||||
Self {
|
||||
proton_compat_dir: default_compat_dir(),
|
||||
proton_version: default_proton_version(),
|
||||
launchers: presets(),
|
||||
launchers: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -227,11 +227,7 @@ impl Config {
|
||||
let content = std::fs::read_to_string(&path)
|
||||
.with_context(|| format!("Failed to read config from {path:?}"))?;
|
||||
match toml::from_str::<Self>(&content) {
|
||||
Ok(mut c) => {
|
||||
if c.launchers.is_empty() {
|
||||
c.launchers = presets();
|
||||
c.save().context("Failed to write presets")?;
|
||||
}
|
||||
Ok(c) => {
|
||||
Ok(c)
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -3,6 +3,38 @@ use anyhow::Result;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DetectHit {
|
||||
pub display: String,
|
||||
pub prefix: PathBuf,
|
||||
/// True if this launcher is already in config with this exact prefix.
|
||||
pub configured: bool,
|
||||
}
|
||||
|
||||
/// Scan default Wine prefix locations and return hits against known presets.
|
||||
pub fn scan_for_gui(config: &Config) -> Vec<DetectHit> {
|
||||
let roots: Vec<PathBuf> = default_roots().into_iter().filter(|r| r.is_dir()).collect();
|
||||
let prefixes = scan_prefixes(&roots);
|
||||
let mut hits = Vec::new();
|
||||
for preset in crate::config::presets() {
|
||||
for prefix in &prefixes {
|
||||
if prefix.join("drive_c").join(&preset.exe_path).exists() {
|
||||
let configured = config
|
||||
.find(&preset.name)
|
||||
.map(|l| l.prefix_dir == *prefix)
|
||||
.unwrap_or(false);
|
||||
hits.push(DetectHit {
|
||||
display: preset.display.clone(),
|
||||
prefix: prefix.clone(),
|
||||
configured,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
hits
|
||||
}
|
||||
|
||||
const MAX_DEPTH: u32 = 3;
|
||||
|
||||
pub fn run(config: &Config, extra_dirs: &[PathBuf], apply: bool) -> Result<()> {
|
||||
|
||||
+42
-46
@@ -4,38 +4,30 @@ use std::os::unix::fs::MetadataExt;
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
struct Check {
|
||||
label: String,
|
||||
pass: bool,
|
||||
detail: String,
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CheckResult {
|
||||
pub label: String,
|
||||
pub pass: bool,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl Check {
|
||||
impl CheckResult {
|
||||
fn pass(label: impl Into<String>, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
label: label.into(),
|
||||
pass: true,
|
||||
detail: detail.into(),
|
||||
}
|
||||
Self { label: label.into(), pass: true, detail: detail.into() }
|
||||
}
|
||||
fn fail(label: impl Into<String>, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
label: label.into(),
|
||||
pass: false,
|
||||
detail: detail.into(),
|
||||
}
|
||||
Self { label: label.into(), pass: false, detail: detail.into() }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(config: &Config, name: Option<&str>) -> Result<()> {
|
||||
let mut checks: Vec<Check> = vec![
|
||||
pub fn run_checks(config: &Config, name: Option<&str>) -> Result<Vec<CheckResult>> {
|
||||
let mut checks = vec![
|
||||
global_umu_check(),
|
||||
global_vulkan_check(),
|
||||
global_display_check(),
|
||||
compat_dir_check(config),
|
||||
wineserver_check(config),
|
||||
];
|
||||
|
||||
let launchers: Vec<&Launcher> = if let Some(n) = name {
|
||||
let l = config
|
||||
.find(n)
|
||||
@@ -47,7 +39,11 @@ pub fn run(config: &Config, name: Option<&str>) -> Result<()> {
|
||||
for l in launchers {
|
||||
checks.extend(launcher_checks(l));
|
||||
}
|
||||
Ok(checks)
|
||||
}
|
||||
|
||||
pub fn run(config: &Config, name: Option<&str>) -> Result<()> {
|
||||
let checks = run_checks(config, name)?;
|
||||
let mut issues = 0u32;
|
||||
println!();
|
||||
for c in &checks {
|
||||
@@ -71,14 +67,14 @@ pub fn run(config: &Config, name: Option<&str>) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn global_umu_check() -> Check {
|
||||
fn global_umu_check() -> CheckResult {
|
||||
match which("umu-run") {
|
||||
Some(p) => Check::pass("umu-run", format!("found at {p}")),
|
||||
None => Check::fail("umu-run", "not found — install umu-launcher"),
|
||||
Some(p) => CheckResult::pass("umu-run", format!("found at {p}")),
|
||||
None => CheckResult::fail("umu-run", "not found — install umu-launcher"),
|
||||
}
|
||||
}
|
||||
|
||||
fn global_vulkan_check() -> Check {
|
||||
fn global_vulkan_check() -> CheckResult {
|
||||
let ok = Command::new("vulkaninfo")
|
||||
.arg("--summary")
|
||||
.stdout(Stdio::null())
|
||||
@@ -87,33 +83,33 @@ fn global_vulkan_check() -> Check {
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
if ok {
|
||||
Check::pass("vulkan", "vulkaninfo OK")
|
||||
CheckResult::pass("vulkan", "vulkaninfo OK")
|
||||
} else {
|
||||
Check::fail(
|
||||
CheckResult::fail(
|
||||
"vulkan",
|
||||
"vulkaninfo failed — check GPU drivers / vulkan-tools",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn global_display_check() -> Check {
|
||||
fn global_display_check() -> CheckResult {
|
||||
let display = std::env::var("DISPLAY").ok();
|
||||
let wayland = std::env::var("WAYLAND_DISPLAY").ok();
|
||||
match (display, wayland) {
|
||||
(Some(d), Some(_)) => Check::pass("display", format!("XWayland (DISPLAY={d})")),
|
||||
(Some(d), None) => Check::pass("display", format!("X11 (DISPLAY={d})")),
|
||||
(None, Some(_)) => Check::fail(
|
||||
(Some(d), Some(_)) => CheckResult::pass("display", format!("XWayland (DISPLAY={d})")),
|
||||
(Some(d), None) => CheckResult::pass("display", format!("X11 (DISPLAY={d})")),
|
||||
(None, Some(_)) => CheckResult::fail(
|
||||
"display",
|
||||
"Wayland session but DISPLAY unset; XWayland needed",
|
||||
),
|
||||
(None, None) => Check::fail("display", "neither DISPLAY nor WAYLAND_DISPLAY set"),
|
||||
(None, None) => CheckResult::fail("display", "neither DISPLAY nor WAYLAND_DISPLAY set"),
|
||||
}
|
||||
}
|
||||
|
||||
fn compat_dir_check(config: &Config) -> Check {
|
||||
fn compat_dir_check(config: &Config) -> CheckResult {
|
||||
let n = count_ge_proton(&config.proton_compat_dir);
|
||||
if config.proton_version == "GE-Proton" {
|
||||
Check::pass(
|
||||
CheckResult::pass(
|
||||
"proton",
|
||||
format!(
|
||||
"tracking latest; {n} version(s) in {}",
|
||||
@@ -123,9 +119,9 @@ fn compat_dir_check(config: &Config) -> Check {
|
||||
} else {
|
||||
let path = config.proton_compat_dir.join(&config.proton_version);
|
||||
if path.exists() {
|
||||
Check::pass("proton", format!("{} installed", config.proton_version))
|
||||
CheckResult::pass("proton", format!("{} installed", config.proton_version))
|
||||
} else {
|
||||
Check::fail(
|
||||
CheckResult::fail(
|
||||
"proton",
|
||||
format!(
|
||||
"{} missing — run: umutray update-proton --version={}",
|
||||
@@ -136,19 +132,19 @@ fn compat_dir_check(config: &Config) -> Check {
|
||||
}
|
||||
}
|
||||
|
||||
fn wineserver_check(config: &Config) -> Check {
|
||||
fn wineserver_check(config: &Config) -> CheckResult {
|
||||
let count = wineserver_count();
|
||||
if count == 0 {
|
||||
return Check::pass("wine procs", "no wineserver running");
|
||||
return CheckResult::pass("wine procs", "no wineserver running");
|
||||
}
|
||||
let any_running = config.launchers.iter().any(crate::launcher::is_running);
|
||||
if any_running {
|
||||
Check::pass(
|
||||
CheckResult::pass(
|
||||
"wine procs",
|
||||
format!("{count} wineserver process(es); launcher active"),
|
||||
)
|
||||
} else {
|
||||
Check::fail(
|
||||
CheckResult::fail(
|
||||
"wine procs",
|
||||
format!("{count} stale wineserver process(es) — try: umutray kill"),
|
||||
)
|
||||
@@ -165,12 +161,12 @@ fn wineserver_count() -> usize {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn launcher_checks(l: &Launcher) -> Vec<Check> {
|
||||
fn launcher_checks(l: &Launcher) -> Vec<CheckResult> {
|
||||
let mut out = Vec::new();
|
||||
let tag = format!("[{}]", l.name);
|
||||
|
||||
if !l.prefix_dir.exists() {
|
||||
out.push(Check::fail(
|
||||
out.push(CheckResult::fail(
|
||||
format!("{tag} prefix"),
|
||||
format!(
|
||||
"{} missing — run: umutray setup {}",
|
||||
@@ -180,34 +176,34 @@ fn launcher_checks(l: &Launcher) -> Vec<Check> {
|
||||
));
|
||||
return out;
|
||||
}
|
||||
out.push(Check::pass(
|
||||
out.push(CheckResult::pass(
|
||||
format!("{tag} prefix"),
|
||||
l.prefix_dir.display().to_string(),
|
||||
));
|
||||
|
||||
let exe = l.full_exe_path();
|
||||
if exe.exists() {
|
||||
out.push(Check::pass(format!("{tag} exe"), "installed"));
|
||||
out.push(CheckResult::pass(format!("{tag} exe"), "installed"));
|
||||
} else {
|
||||
out.push(Check::fail(
|
||||
out.push(CheckResult::fail(
|
||||
format!("{tag} exe"),
|
||||
format!("missing — run: umutray setup {}", l.name),
|
||||
));
|
||||
}
|
||||
|
||||
if is_owned_by_current_user(&l.prefix_dir) {
|
||||
out.push(Check::pass(format!("{tag} owner"), "owned by current user"));
|
||||
out.push(CheckResult::pass(format!("{tag} owner"), "owned by current user"));
|
||||
} else {
|
||||
out.push(Check::fail(
|
||||
out.push(CheckResult::fail(
|
||||
format!("{tag} owner"),
|
||||
"not owned by current user",
|
||||
));
|
||||
}
|
||||
|
||||
if crate::launcher::is_running(l) {
|
||||
out.push(Check::pass(format!("{tag} process"), "currently running"));
|
||||
out.push(CheckResult::pass(format!("{tag} process"), "currently running"));
|
||||
} else {
|
||||
out.push(Check::pass(format!("{tag} process"), "not running"));
|
||||
out.push(CheckResult::pass(format!("{tag} process"), "not running"));
|
||||
}
|
||||
|
||||
out
|
||||
|
||||
+941
@@ -0,0 +1,941 @@
|
||||
use crate::{config::Config, detect, diagnose, launcher, service, util::async_blocking};
|
||||
use anyhow::Result;
|
||||
use iced::widget::{
|
||||
button, column, container, mouse_area, row, scrollable, text, text_input, Column,
|
||||
};
|
||||
use iced::{
|
||||
Alignment, Background, Border, Color, Element, Length, Padding, Subscription, Task, Theme,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
PollProcesses,
|
||||
ReloadConfig,
|
||||
AddLauncher,
|
||||
Launch(String),
|
||||
Kill(String),
|
||||
Play(String, String),
|
||||
Setup(String),
|
||||
ToggleGameMode(String, String),
|
||||
ToggleMangoHud(String, String),
|
||||
ToggleGamescope(String, String),
|
||||
UpdateProton,
|
||||
ProtonDone(Result<(), String>),
|
||||
// Context menu
|
||||
ShowContextMenu(String),
|
||||
HideContextMenu,
|
||||
OpenPrefix(String),
|
||||
RerunSetup(String),
|
||||
RemoveLauncher(String),
|
||||
// Detect
|
||||
DetectPressed,
|
||||
DetectDone(Vec<detect::DetectHit>),
|
||||
// Diagnose
|
||||
DiagnosePressed(String),
|
||||
DiagnoseDone(String, Vec<diagnose::CheckResult>),
|
||||
HideDiagnose,
|
||||
// Games
|
||||
AddGamePressed(String),
|
||||
AddGameNameChanged(String, String),
|
||||
AddGameExeChanged(String, String),
|
||||
AddGameConfirm(String),
|
||||
RemoveGame(String, String),
|
||||
// Settings
|
||||
ShowSettings,
|
||||
HideSettings,
|
||||
SettingsProtonVersionChanged(String),
|
||||
SettingsCompatDirChanged(String),
|
||||
SaveSettings,
|
||||
ServiceInstall,
|
||||
ServiceUninstall,
|
||||
ServiceActionDone(Result<(), String>),
|
||||
}
|
||||
|
||||
struct Dashboard {
|
||||
config: Config,
|
||||
running: HashMap<String, bool>,
|
||||
proton_busy: bool,
|
||||
proton_status: String,
|
||||
last_error: Option<String>,
|
||||
context_menu: Option<String>,
|
||||
// Detect
|
||||
detect_busy: bool,
|
||||
detect_result: String,
|
||||
// Diagnose
|
||||
diagnose_open: Option<String>,
|
||||
diagnose_result: Option<(String, Vec<diagnose::CheckResult>)>,
|
||||
// Games
|
||||
adding_game: HashMap<String, (String, String)>,
|
||||
// Settings
|
||||
settings_open: bool,
|
||||
settings_proton_version: String,
|
||||
settings_compat_dir: String,
|
||||
service_busy: bool,
|
||||
service_status: String,
|
||||
}
|
||||
|
||||
impl Dashboard {
|
||||
fn new(config: Config) -> Self {
|
||||
let mut running = HashMap::new();
|
||||
for l in &config.launchers {
|
||||
running.insert(l.name.clone(), launcher::is_running(l));
|
||||
}
|
||||
let settings_proton_version = config.proton_version.clone();
|
||||
let settings_compat_dir = config.proton_compat_dir.to_string_lossy().into_owned();
|
||||
Self {
|
||||
config,
|
||||
running,
|
||||
proton_busy: false,
|
||||
proton_status: String::new(),
|
||||
last_error: None,
|
||||
context_menu: None,
|
||||
detect_busy: false,
|
||||
detect_result: String::new(),
|
||||
diagnose_open: None,
|
||||
diagnose_result: None,
|
||||
adding_game: HashMap::new(),
|
||||
settings_open: false,
|
||||
settings_proton_version,
|
||||
settings_compat_dir,
|
||||
service_busy: false,
|
||||
service_status: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update(state: &mut Dashboard, msg: Message) -> Task<Message> {
|
||||
match msg {
|
||||
Message::PollProcesses => {
|
||||
for l in &state.config.launchers {
|
||||
state.running.insert(l.name.clone(), launcher::is_running(l));
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::ReloadConfig => {
|
||||
if let Ok(fresh) = Config::load() {
|
||||
state
|
||||
.running
|
||||
.retain(|k, _| fresh.launchers.iter().any(|l| &l.name == k));
|
||||
state.config = fresh;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::AddLauncher => {
|
||||
state.context_menu = None;
|
||||
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("umutray"));
|
||||
let _ = std::process::Command::new(exe).arg("setup").spawn();
|
||||
Task::none()
|
||||
}
|
||||
Message::Launch(name) => {
|
||||
state.last_error = None;
|
||||
state.running.insert(name.clone(), true);
|
||||
let Some(l) = state.config.find(&name) else {
|
||||
return Task::none();
|
||||
};
|
||||
let config = state.config.clone();
|
||||
let l = l.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = launcher::launch(&config, &l) {
|
||||
eprintln!("umutray: launch {name} failed: {e}");
|
||||
}
|
||||
});
|
||||
Task::none()
|
||||
}
|
||||
Message::Kill(name) => {
|
||||
state.last_error = None;
|
||||
if let Some(l) = state.config.find(&name) {
|
||||
let l = l.clone();
|
||||
match launcher::kill(&l) {
|
||||
Ok(()) => {
|
||||
state.running.insert(name, false);
|
||||
}
|
||||
Err(e) => {
|
||||
state.last_error = Some(format!("Kill failed: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::Play(lname, gname) => {
|
||||
state.last_error = None;
|
||||
if let Some(l) = state.config.find(&lname) {
|
||||
if let Some(g) = l.find_game(&gname) {
|
||||
let config = state.config.clone();
|
||||
let l = l.clone();
|
||||
let g = g.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = launcher::play_game(&config, &l, &g) {
|
||||
eprintln!("umutray: play {gname} failed: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::Setup(name) => {
|
||||
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("umutray"));
|
||||
let _ = std::process::Command::new(exe)
|
||||
.arg("setup")
|
||||
.arg(&name)
|
||||
.spawn();
|
||||
Task::none()
|
||||
}
|
||||
Message::ToggleGameMode(lname, gname) => {
|
||||
toggle_flag(&mut state.config, &lname, &gname, |g| {
|
||||
g.gamemode = !g.gamemode
|
||||
});
|
||||
Task::none()
|
||||
}
|
||||
Message::ToggleMangoHud(lname, gname) => {
|
||||
toggle_flag(&mut state.config, &lname, &gname, |g| {
|
||||
g.mangohud = !g.mangohud
|
||||
});
|
||||
Task::none()
|
||||
}
|
||||
Message::ToggleGamescope(lname, gname) => {
|
||||
toggle_flag(&mut state.config, &lname, &gname, |g| {
|
||||
g.gamescope = if g.gamescope.is_some() { None } else { Some(vec![]) };
|
||||
});
|
||||
Task::none()
|
||||
}
|
||||
Message::UpdateProton => {
|
||||
state.proton_busy = true;
|
||||
state.proton_status = "Downloading latest GE-Proton…".into();
|
||||
state.last_error = None;
|
||||
let config = state.config.clone();
|
||||
Task::perform(
|
||||
async_blocking(move || {
|
||||
crate::proton::install_latest(&config).map_err(|e| e.to_string())
|
||||
}),
|
||||
Message::ProtonDone,
|
||||
)
|
||||
}
|
||||
Message::ProtonDone(res) => {
|
||||
state.proton_busy = false;
|
||||
match res {
|
||||
Ok(()) => state.proton_status = "GE-Proton updated successfully.".into(),
|
||||
Err(e) => {
|
||||
state.proton_status = String::new();
|
||||
state.last_error = Some(format!("Proton update failed: {e}"));
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
// ── Context menu ───────────────────────────────────────────────────
|
||||
Message::ShowContextMenu(name) => {
|
||||
state.context_menu = if state.context_menu.as_deref() == Some(&name) {
|
||||
None
|
||||
} else {
|
||||
Some(name)
|
||||
};
|
||||
Task::none()
|
||||
}
|
||||
Message::HideContextMenu => {
|
||||
state.context_menu = None;
|
||||
Task::none()
|
||||
}
|
||||
Message::OpenPrefix(name) => {
|
||||
state.context_menu = None;
|
||||
if let Some(l) = state.config.find(&name) {
|
||||
let path = l.prefix_dir.clone();
|
||||
std::thread::spawn(move || {
|
||||
let _ = std::process::Command::new("xdg-open").arg(path).spawn();
|
||||
});
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::RerunSetup(name) => {
|
||||
state.context_menu = None;
|
||||
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("umutray"));
|
||||
let _ = std::process::Command::new(exe)
|
||||
.arg("setup")
|
||||
.arg(&name)
|
||||
.spawn();
|
||||
Task::none()
|
||||
}
|
||||
Message::RemoveLauncher(name) => {
|
||||
state.context_menu = None;
|
||||
state.running.remove(&name);
|
||||
state.adding_game.remove(&name);
|
||||
if let Err(e) = state.config.remove_launcher(&name) {
|
||||
state.last_error = Some(format!("Remove failed: {e}"));
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
// ── Detect ─────────────────────────────────────────────────────────
|
||||
Message::DetectPressed => {
|
||||
state.detect_busy = true;
|
||||
state.detect_result = "Scanning…".into();
|
||||
let config = state.config.clone();
|
||||
Task::perform(
|
||||
async_blocking(move || detect::scan_for_gui(&config)),
|
||||
Message::DetectDone,
|
||||
)
|
||||
}
|
||||
Message::DetectDone(hits) => {
|
||||
state.detect_busy = false;
|
||||
if hits.is_empty() {
|
||||
state.detect_result = "No known launchers found on disk.".into();
|
||||
} else {
|
||||
let parts: Vec<String> = hits
|
||||
.iter()
|
||||
.map(|h| {
|
||||
if h.configured {
|
||||
format!("{} ✓", h.display)
|
||||
} else {
|
||||
format!("{} (not in config — use Setup to add)", h.display)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
state.detect_result = format!("Found: {}", parts.join(", "));
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
// ── Diagnose ───────────────────────────────────────────────────────
|
||||
Message::DiagnosePressed(name) => {
|
||||
state.context_menu = None;
|
||||
state.diagnose_open = Some(name.clone());
|
||||
state.diagnose_result = None;
|
||||
let config = state.config.clone();
|
||||
let lname = name.clone();
|
||||
Task::perform(
|
||||
async_blocking(move || {
|
||||
diagnose::run_checks(&config, Some(&name))
|
||||
.unwrap_or_else(|e| vec![diagnose::CheckResult {
|
||||
label: "error".into(),
|
||||
pass: false,
|
||||
detail: e.to_string(),
|
||||
}])
|
||||
}),
|
||||
move |checks| Message::DiagnoseDone(lname.clone(), checks),
|
||||
)
|
||||
}
|
||||
Message::DiagnoseDone(name, checks) => {
|
||||
if state.diagnose_open.as_deref() == Some(&name) {
|
||||
state.diagnose_result = Some((name, checks));
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::HideDiagnose => {
|
||||
state.diagnose_open = None;
|
||||
state.diagnose_result = None;
|
||||
Task::none()
|
||||
}
|
||||
// ── Games ──────────────────────────────────────────────────────────
|
||||
Message::AddGamePressed(lname) => {
|
||||
if state.adding_game.contains_key(&lname) {
|
||||
state.adding_game.remove(&lname);
|
||||
} else {
|
||||
state.adding_game.insert(lname, (String::new(), String::new()));
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::AddGameNameChanged(lname, val) => {
|
||||
if let Some(entry) = state.adding_game.get_mut(&lname) {
|
||||
entry.0 = val;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::AddGameExeChanged(lname, val) => {
|
||||
if let Some(entry) = state.adding_game.get_mut(&lname) {
|
||||
entry.1 = val;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::AddGameConfirm(lname) => {
|
||||
let Some((name, exe)) = state.adding_game.get(&lname).cloned() else {
|
||||
return Task::none();
|
||||
};
|
||||
let name = name.trim().to_string();
|
||||
let exe = exe.trim().to_string();
|
||||
if name.is_empty() || exe.is_empty() {
|
||||
state.last_error = Some("Game name and exe path are required.".into());
|
||||
return Task::none();
|
||||
}
|
||||
state.last_error = None;
|
||||
match state.config.add_game(&lname, name, None, PathBuf::from(exe), false, false, None) {
|
||||
Ok(()) => {
|
||||
state.adding_game.remove(&lname);
|
||||
}
|
||||
Err(e) => {
|
||||
state.last_error = Some(format!("Add game failed: {e}"));
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::RemoveGame(lname, gname) => {
|
||||
state.last_error = None;
|
||||
if let Err(e) = state.config.remove_game(&lname, &gname) {
|
||||
state.last_error = Some(format!("Remove game failed: {e}"));
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
// ── Settings ───────────────────────────────────────────────────────
|
||||
Message::ShowSettings => {
|
||||
state.settings_open = true;
|
||||
state.settings_proton_version = state.config.proton_version.clone();
|
||||
state.settings_compat_dir =
|
||||
state.config.proton_compat_dir.to_string_lossy().into_owned();
|
||||
state.service_status = String::new();
|
||||
Task::none()
|
||||
}
|
||||
Message::HideSettings => {
|
||||
state.settings_open = false;
|
||||
Task::none()
|
||||
}
|
||||
Message::SettingsProtonVersionChanged(v) => {
|
||||
state.settings_proton_version = v;
|
||||
Task::none()
|
||||
}
|
||||
Message::SettingsCompatDirChanged(v) => {
|
||||
state.settings_compat_dir = v;
|
||||
Task::none()
|
||||
}
|
||||
Message::SaveSettings => {
|
||||
state.last_error = None;
|
||||
let version = state.settings_proton_version.trim().to_string();
|
||||
let compat = PathBuf::from(state.settings_compat_dir.trim());
|
||||
match state.config.set_globals(Some(version), Some(compat)) {
|
||||
Ok(()) => {
|
||||
state.service_status = "Settings saved.".into();
|
||||
}
|
||||
Err(e) => {
|
||||
state.last_error = Some(format!("Save failed: {e}"));
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::ServiceInstall => {
|
||||
state.service_busy = true;
|
||||
state.service_status = "Installing service…".into();
|
||||
Task::perform(
|
||||
async_blocking(|| service::install().map_err(|e| e.to_string())),
|
||||
Message::ServiceActionDone,
|
||||
)
|
||||
}
|
||||
Message::ServiceUninstall => {
|
||||
state.service_busy = true;
|
||||
state.service_status = "Removing service…".into();
|
||||
Task::perform(
|
||||
async_blocking(|| service::uninstall().map_err(|e| e.to_string())),
|
||||
Message::ServiceActionDone,
|
||||
)
|
||||
}
|
||||
Message::ServiceActionDone(res) => {
|
||||
state.service_busy = false;
|
||||
match res {
|
||||
Ok(()) => {
|
||||
state.service_status = if service_is_installed() {
|
||||
"Service installed — autostarts on login.".into()
|
||||
} else {
|
||||
"Service removed.".into()
|
||||
};
|
||||
}
|
||||
Err(e) => {
|
||||
state.service_status = format!("Failed: {e}");
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_flag(
|
||||
config: &mut Config,
|
||||
lname: &str,
|
||||
gname: &str,
|
||||
f: impl FnOnce(&mut crate::config::Game),
|
||||
) {
|
||||
for l in config.launchers.iter_mut() {
|
||||
if l.name == lname {
|
||||
for g in l.games.iter_mut() {
|
||||
if g.name == gname {
|
||||
f(g);
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = config.save();
|
||||
}
|
||||
|
||||
fn service_is_installed() -> bool {
|
||||
std::env::var("HOME")
|
||||
.ok()
|
||||
.map(|h| {
|
||||
PathBuf::from(h)
|
||||
.join(".config/systemd/user/umutray.service")
|
||||
.exists()
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn subscription(_: &Dashboard) -> Subscription<Message> {
|
||||
Subscription::batch([
|
||||
iced::time::every(Duration::from_secs(2)).map(|_| Message::PollProcesses),
|
||||
iced::time::every(Duration::from_secs(5)).map(|_| Message::ReloadConfig),
|
||||
])
|
||||
}
|
||||
|
||||
// ── Top-level view ─────────────────────────────────────────────────────────
|
||||
|
||||
fn view(state: &Dashboard) -> Element<'_, Message> {
|
||||
if state.settings_open {
|
||||
return view_settings(state);
|
||||
}
|
||||
|
||||
let settings_btn = button(text("⚙").size(16))
|
||||
.on_press(Message::ShowSettings)
|
||||
.style(button::secondary);
|
||||
|
||||
let title = container(
|
||||
row![
|
||||
text("umutray").size(28),
|
||||
iced::widget::horizontal_space(),
|
||||
settings_btn,
|
||||
]
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.padding(Padding { top: 16.0, right: 20.0, bottom: 8.0, left: 20.0 });
|
||||
|
||||
let add_btn = button(text("+ Add Launcher").size(13))
|
||||
.on_press(Message::AddLauncher)
|
||||
.style(button::secondary);
|
||||
|
||||
if state.config.launchers.is_empty() {
|
||||
let body = column![
|
||||
title,
|
||||
container(
|
||||
column![text("No launchers configured.").size(15), add_btn,]
|
||||
.spacing(12)
|
||||
.padding([20, 20]),
|
||||
)
|
||||
.width(Length::Fill),
|
||||
];
|
||||
return container(body)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into();
|
||||
}
|
||||
|
||||
let mut cards: Vec<Element<Message>> = Vec::new();
|
||||
|
||||
for l in &state.config.launchers {
|
||||
let installed = l.full_exe_path().exists();
|
||||
let running = *state.running.get(&l.name).unwrap_or(&false);
|
||||
let diagnose_open = state.diagnose_open.as_deref() == Some(&l.name);
|
||||
let menu_open = state.context_menu.as_deref() == Some(&l.name);
|
||||
|
||||
let card_inner: Element<Message> = if diagnose_open {
|
||||
let checks = state
|
||||
.diagnose_result
|
||||
.as_ref()
|
||||
.filter(|(n, _)| n == &l.name)
|
||||
.map(|(_, v)| v.as_slice());
|
||||
diagnose_card(l, checks)
|
||||
} else if menu_open {
|
||||
context_menu_card(l)
|
||||
} else {
|
||||
launcher_card(l, installed, running, state.adding_game.get(&l.name))
|
||||
};
|
||||
|
||||
let card = mouse_area(
|
||||
container(card_inner)
|
||||
.padding([10, 14])
|
||||
.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()
|
||||
}
|
||||
})
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.on_right_press(Message::ShowContextMenu(l.name.clone()));
|
||||
|
||||
cards.push(card.into());
|
||||
}
|
||||
|
||||
let proton_btn = button(
|
||||
text(if state.proton_busy { "Updating…" } else { "Update GE-Proton" }).size(13),
|
||||
)
|
||||
.on_press_maybe((!state.proton_busy).then_some(Message::UpdateProton))
|
||||
.style(button::secondary);
|
||||
|
||||
let detect_btn = button(
|
||||
text(if state.detect_busy { "Scanning…" } else { "Detect Installed" }).size(13),
|
||||
)
|
||||
.on_press_maybe((!state.detect_busy).then_some(Message::DetectPressed))
|
||||
.style(button::secondary);
|
||||
|
||||
let footer = container(
|
||||
column![
|
||||
row![detect_btn, text(&state.detect_result).size(12),]
|
||||
.align_y(Alignment::Center)
|
||||
.spacing(12),
|
||||
row![proton_btn, text(&state.proton_status).size(12),]
|
||||
.align_y(Alignment::Center)
|
||||
.spacing(12),
|
||||
]
|
||||
.spacing(6),
|
||||
)
|
||||
.padding([10, 20]);
|
||||
|
||||
let cards_col = Column::with_children(cards)
|
||||
.push(container(add_btn).padding(Padding {
|
||||
top: 8.0,
|
||||
right: 0.0,
|
||||
bottom: 0.0,
|
||||
left: 0.0,
|
||||
}))
|
||||
.spacing(8)
|
||||
.padding(Padding {
|
||||
top: 0.0,
|
||||
right: 20.0,
|
||||
bottom: 8.0,
|
||||
left: 20.0,
|
||||
})
|
||||
.width(Length::Fill);
|
||||
|
||||
let error_bar: Element<Message> = if let Some(err) = &state.last_error {
|
||||
container(
|
||||
text(format!("⚠ {err}"))
|
||||
.size(12)
|
||||
.style(|_: &Theme| text::Style {
|
||||
color: Some(Color::from_rgb(1.0, 0.45, 0.45)),
|
||||
}),
|
||||
)
|
||||
.padding([4, 20])
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
} else {
|
||||
text("").into()
|
||||
};
|
||||
|
||||
let body = column![
|
||||
title,
|
||||
scrollable(cards_col).height(Length::Fill),
|
||||
error_bar,
|
||||
iced::widget::horizontal_rule(1),
|
||||
footer,
|
||||
];
|
||||
|
||||
container(body)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
// ── Card views ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn launcher_card<'a>(
|
||||
l: &'a crate::config::Launcher,
|
||||
installed: bool,
|
||||
running: bool,
|
||||
add_form: Option<&'a (String, String)>,
|
||||
) -> Element<'a, Message> {
|
||||
let status_label = if running {
|
||||
"● Running"
|
||||
} else if installed {
|
||||
"○ Installed"
|
||||
} else {
|
||||
"· Not installed"
|
||||
};
|
||||
|
||||
let action: Element<Message> = {
|
||||
let n = l.name.clone();
|
||||
if !installed {
|
||||
button(text("Setup").size(13))
|
||||
.on_press(Message::Setup(n))
|
||||
.style(button::secondary)
|
||||
.into()
|
||||
} else if running {
|
||||
button(text("Kill").size(13))
|
||||
.on_press(Message::Kill(n))
|
||||
.style(button::danger)
|
||||
.into()
|
||||
} else {
|
||||
button(text("Launch").size(13))
|
||||
.on_press(Message::Launch(n))
|
||||
.style(button::primary)
|
||||
.into()
|
||||
}
|
||||
};
|
||||
|
||||
let header = row![
|
||||
text(&l.display).size(15),
|
||||
text(" — ").size(12),
|
||||
text(status_label).size(12),
|
||||
iced::widget::horizontal_space(),
|
||||
action,
|
||||
]
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
let mut rows: Vec<Element<Message>> = vec![header.into()];
|
||||
|
||||
for g in &l.games {
|
||||
let lname = l.name.clone();
|
||||
let gname = g.name.clone();
|
||||
|
||||
let play = button(text("▶").size(11))
|
||||
.on_press(Message::Play(lname.clone(), gname.clone()))
|
||||
.style(button::primary);
|
||||
|
||||
let gamemode_btn = button(text("GameMode").size(11))
|
||||
.on_press(Message::ToggleGameMode(lname.clone(), gname.clone()))
|
||||
.style(if g.gamemode { button::primary } else { button::secondary });
|
||||
|
||||
let mangohud_btn = button(text("MangoHud").size(11))
|
||||
.on_press(Message::ToggleMangoHud(lname.clone(), gname.clone()))
|
||||
.style(if g.mangohud { button::primary } else { button::secondary });
|
||||
|
||||
let gamescope_btn = button(text("Gamescope").size(11))
|
||||
.on_press(Message::ToggleGamescope(lname.clone(), gname.clone()))
|
||||
.style(if g.gamescope.is_some() { button::primary } else { button::secondary });
|
||||
|
||||
let remove_game = button(text("✕").size(10))
|
||||
.on_press(Message::RemoveGame(lname, gname))
|
||||
.style(button::danger);
|
||||
|
||||
rows.push(
|
||||
row![
|
||||
text(" ").size(13),
|
||||
play,
|
||||
text(&g.display).size(13),
|
||||
iced::widget::horizontal_space(),
|
||||
gamemode_btn,
|
||||
mangohud_btn,
|
||||
gamescope_btn,
|
||||
remove_game,
|
||||
]
|
||||
.align_y(Alignment::Center)
|
||||
.spacing(6)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
// Inline add-game form
|
||||
if let Some((name_val, exe_val)) = add_form {
|
||||
let lname = l.name.clone();
|
||||
let lname2 = l.name.clone();
|
||||
let lname3 = l.name.clone();
|
||||
let lname4 = l.name.clone();
|
||||
let name_input = text_input("Game name", name_val)
|
||||
.on_input(move |v| Message::AddGameNameChanged(lname.clone(), v))
|
||||
.padding(5)
|
||||
.size(12)
|
||||
.width(Length::FillPortion(2));
|
||||
let exe_input = text_input("Exe path (relative to drive_c/)", exe_val)
|
||||
.on_input(move |v| Message::AddGameExeChanged(lname2.clone(), v))
|
||||
.padding(5)
|
||||
.size(12)
|
||||
.width(Length::FillPortion(3));
|
||||
let can_confirm = !name_val.trim().is_empty() && !exe_val.trim().is_empty();
|
||||
let confirm_btn = button(text("Add").size(11))
|
||||
.on_press_maybe(can_confirm.then_some(Message::AddGameConfirm(lname3)))
|
||||
.style(button::primary);
|
||||
let cancel_btn = button(text("Cancel").size(11))
|
||||
.on_press(Message::AddGamePressed(lname4))
|
||||
.style(button::secondary);
|
||||
rows.push(
|
||||
row![name_input, exe_input, confirm_btn, cancel_btn,]
|
||||
.align_y(Alignment::Center)
|
||||
.spacing(6)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let add_game_btn = {
|
||||
let lname = l.name.clone();
|
||||
let label = if add_form.is_some() { "− Game" } else { "+ Game" };
|
||||
button(text(label).size(11))
|
||||
.on_press(Message::AddGamePressed(lname))
|
||||
.style(button::secondary)
|
||||
};
|
||||
|
||||
rows.push(
|
||||
container(add_game_btn)
|
||||
.padding(Padding { top: 4.0, right: 0.0, bottom: 0.0, left: 0.0 })
|
||||
.into(),
|
||||
);
|
||||
|
||||
Column::with_children(rows).spacing(6).into()
|
||||
}
|
||||
|
||||
fn context_menu_card(l: &crate::config::Launcher) -> Element<'_, Message> {
|
||||
let header = row![
|
||||
text(&l.display).size(15),
|
||||
iced::widget::horizontal_space(),
|
||||
button(text("✕").size(12))
|
||||
.on_press(Message::HideContextMenu)
|
||||
.style(button::secondary),
|
||||
]
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
let open_prefix = button(text("Open prefix folder").size(13))
|
||||
.on_press(Message::OpenPrefix(l.name.clone()))
|
||||
.style(button::secondary)
|
||||
.width(Length::Fill);
|
||||
|
||||
let rerun_setup = button(text("Re-run setup").size(13))
|
||||
.on_press(Message::RerunSetup(l.name.clone()))
|
||||
.style(button::secondary)
|
||||
.width(Length::Fill);
|
||||
|
||||
let diagnose = button(text("Diagnose").size(13))
|
||||
.on_press(Message::DiagnosePressed(l.name.clone()))
|
||||
.style(button::secondary)
|
||||
.width(Length::Fill);
|
||||
|
||||
let remove = button(text("Remove launcher").size(13))
|
||||
.on_press(Message::RemoveLauncher(l.name.clone()))
|
||||
.style(button::danger)
|
||||
.width(Length::Fill);
|
||||
|
||||
column![
|
||||
header,
|
||||
iced::widget::horizontal_rule(1),
|
||||
open_prefix,
|
||||
rerun_setup,
|
||||
diagnose,
|
||||
remove,
|
||||
]
|
||||
.spacing(4)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn diagnose_card<'a>(
|
||||
l: &'a crate::config::Launcher,
|
||||
checks: Option<&'a [diagnose::CheckResult]>,
|
||||
) -> Element<'a, Message> {
|
||||
let header = row![
|
||||
text(format!("Diagnose: {}", l.display)).size(15),
|
||||
iced::widget::horizontal_space(),
|
||||
button(text("✕").size(12))
|
||||
.on_press(Message::HideDiagnose)
|
||||
.style(button::secondary),
|
||||
]
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
let body: Element<Message> = match checks {
|
||||
None => text("Running checks…").size(12).into(),
|
||||
Some(results) => {
|
||||
let rows: Vec<Element<Message>> = results
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let (sym, color) = if c.pass {
|
||||
("✓", Color::from_rgb(0.4, 0.9, 0.4))
|
||||
} else {
|
||||
("✗", Color::from_rgb(1.0, 0.45, 0.45))
|
||||
};
|
||||
row![
|
||||
text(sym).size(12).style(move |_: &Theme| text::Style {
|
||||
color: Some(color),
|
||||
}),
|
||||
text(format!(" {:24} {}", c.label, c.detail)).size(12),
|
||||
]
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
scrollable(Column::with_children(rows).spacing(3))
|
||||
.height(Length::Fixed(160.0))
|
||||
.into()
|
||||
}
|
||||
};
|
||||
|
||||
column![header, iced::widget::horizontal_rule(1), body,]
|
||||
.spacing(6)
|
||||
.into()
|
||||
}
|
||||
|
||||
// ── Settings view ───────────────────────────────────────────────────────────
|
||||
|
||||
fn view_settings(state: &Dashboard) -> Element<'_, Message> {
|
||||
let header = row![
|
||||
text("⚙ Settings").size(24),
|
||||
iced::widget::horizontal_space(),
|
||||
button(text("← Back").size(13))
|
||||
.on_press(Message::HideSettings)
|
||||
.style(button::secondary),
|
||||
]
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
let proton_version_input =
|
||||
text_input("e.g. GE-Proton or GE-Proton10-34", &state.settings_proton_version)
|
||||
.on_input(Message::SettingsProtonVersionChanged)
|
||||
.padding(8);
|
||||
|
||||
let compat_dir_input = text_input(
|
||||
"e.g. ~/.local/share/Steam/compatibilitytools.d",
|
||||
&state.settings_compat_dir,
|
||||
)
|
||||
.on_input(Message::SettingsCompatDirChanged)
|
||||
.padding(8);
|
||||
|
||||
let save_btn = button(text("Save").size(13))
|
||||
.on_press(Message::SaveSettings)
|
||||
.style(button::primary);
|
||||
|
||||
let installed = service_is_installed();
|
||||
let svc_install_btn = button(
|
||||
text(if state.service_busy { "Working…" } else { "Install autostart service" }).size(13),
|
||||
)
|
||||
.on_press_maybe((!state.service_busy && !installed).then_some(Message::ServiceInstall))
|
||||
.style(button::secondary);
|
||||
|
||||
let svc_uninstall_btn = button(
|
||||
text(if state.service_busy { "Working…" } else { "Remove autostart service" }).size(13),
|
||||
)
|
||||
.on_press_maybe((!state.service_busy && installed).then_some(Message::ServiceUninstall))
|
||||
.style(button::danger);
|
||||
|
||||
let svc_status_label = if installed {
|
||||
"Autostart: enabled (tray starts on login)"
|
||||
} else {
|
||||
"Autostart: disabled"
|
||||
};
|
||||
|
||||
let body = column![
|
||||
header,
|
||||
iced::widget::horizontal_rule(1),
|
||||
text("Proton version").size(13),
|
||||
proton_version_input,
|
||||
text("GE-Proton compat directory").size(13),
|
||||
compat_dir_input,
|
||||
save_btn,
|
||||
iced::widget::horizontal_rule(1),
|
||||
text(svc_status_label).size(13),
|
||||
row![svc_install_btn, svc_uninstall_btn,].spacing(10),
|
||||
text(&state.service_status).size(12),
|
||||
]
|
||||
.spacing(10)
|
||||
.padding(20);
|
||||
|
||||
container(body)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn run(config: &Config) -> Result<()> {
|
||||
let config = config.clone();
|
||||
iced::application(|_: &Dashboard| String::from("umutray"), update, view)
|
||||
.subscription(subscription)
|
||||
.theme(|_| Theme::Dark)
|
||||
.window(iced::window::Settings {
|
||||
size: iced::Size::new(600.0, 560.0),
|
||||
..Default::default()
|
||||
})
|
||||
.run_with(move || (Dashboard::new(config.clone()), Task::none()))
|
||||
.map_err(|e| anyhow::anyhow!("iced: {e}"))
|
||||
}
|
||||
+26
-8
@@ -1,11 +1,13 @@
|
||||
mod config;
|
||||
mod detect;
|
||||
mod diagnose;
|
||||
mod gui;
|
||||
mod launcher;
|
||||
mod proton;
|
||||
mod service;
|
||||
mod setup;
|
||||
mod tray;
|
||||
mod util;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
@@ -62,12 +64,15 @@ enum Commands {
|
||||
launcher: Option<String>,
|
||||
},
|
||||
|
||||
/// Open the graphical setup wizard for a launcher
|
||||
/// Open the graphical setup wizard. Omit NAME to pick from the launcher list.
|
||||
Setup {
|
||||
/// Launcher name
|
||||
name: String,
|
||||
/// Launcher name (e.g. battlenet). Omit to open the launcher picker.
|
||||
name: Option<String>,
|
||||
},
|
||||
|
||||
/// Open the graphical dashboard (default when launched from app menu)
|
||||
Gui,
|
||||
|
||||
/// Scan common Wine prefix locations for installed launchers
|
||||
Detect {
|
||||
/// Additional directory to scan (repeatable)
|
||||
@@ -214,12 +219,16 @@ enum ConfigAction {
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum ServiceAction {
|
||||
/// Write the unit, daemon-reload, and enable+start the service
|
||||
/// Write the unit, daemon-reload, and enable+start the service (includes app menu entry)
|
||||
Install,
|
||||
/// Stop, disable, and remove the unit file
|
||||
/// Stop, disable, and remove the unit file (includes app menu entry)
|
||||
Uninstall,
|
||||
/// Show `systemctl --user status` for the service
|
||||
Status,
|
||||
/// Install only the app menu entry — no systemd service required
|
||||
InstallDesktop,
|
||||
/// Remove the app menu entry
|
||||
UninstallDesktop,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
@@ -305,12 +314,19 @@ fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
Commands::Setup { name } => {
|
||||
let l = config.find(&name).ok_or_else(|| {
|
||||
anyhow::anyhow!("unknown launcher '{name}' — try `umutray launchers`")
|
||||
Commands::Setup { name } => match name {
|
||||
None => setup::run_new(&config)?,
|
||||
Some(n) => {
|
||||
let l = config.find(&n).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"unknown launcher '{n}' — try `umutray setup` to add it first"
|
||||
)
|
||||
})?;
|
||||
setup::run(&config, l)?;
|
||||
}
|
||||
},
|
||||
|
||||
Commands::Gui => gui::run(&config)?,
|
||||
|
||||
Commands::Detect { dir, apply } => {
|
||||
detect::run(&config, &dir, apply)?;
|
||||
@@ -402,6 +418,8 @@ fn main() -> Result<()> {
|
||||
ServiceAction::Install => service::install()?,
|
||||
ServiceAction::Uninstall => service::uninstall()?,
|
||||
ServiceAction::Status => service::status()?,
|
||||
ServiceAction::InstallDesktop => service::install_desktop()?,
|
||||
ServiceAction::UninstallDesktop => service::uninstall_desktop()?,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+79
-24
@@ -3,18 +3,28 @@ use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
const UNIT_NAME: &str = "umutray.service";
|
||||
const DESKTOP_NAME: &str = "umutray.desktop";
|
||||
|
||||
fn home() -> Result<PathBuf> {
|
||||
Ok(PathBuf::from(
|
||||
std::env::var("HOME").context("$HOME is not set")?,
|
||||
))
|
||||
}
|
||||
|
||||
fn unit_path() -> Result<PathBuf> {
|
||||
let home = std::env::var("HOME").context("$HOME is not set")?;
|
||||
Ok(PathBuf::from(home)
|
||||
.join(".config/systemd/user")
|
||||
.join(UNIT_NAME))
|
||||
Ok(home()?.join(".config/systemd/user").join(UNIT_NAME))
|
||||
}
|
||||
|
||||
fn desktop_path() -> Result<PathBuf> {
|
||||
Ok(home()?
|
||||
.join(".local/share/applications")
|
||||
.join(DESKTOP_NAME))
|
||||
}
|
||||
|
||||
fn render_unit(exe: &std::path::Path) -> String {
|
||||
format!(
|
||||
"[Unit]\n\
|
||||
Description=Battle.net tray manager\n\
|
||||
Description=umutray Wine launcher manager\n\
|
||||
After=graphical-session.target\n\
|
||||
PartOf=graphical-session.target\n\
|
||||
\n\
|
||||
@@ -29,6 +39,21 @@ fn render_unit(exe: &std::path::Path) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn render_desktop(exe: &std::path::Path) -> String {
|
||||
format!(
|
||||
"[Desktop Entry]\n\
|
||||
Name=umutray\n\
|
||||
Comment=Wine launcher manager for Windows game launchers\n\
|
||||
Exec={exe} gui\n\
|
||||
Icon=applications-games\n\
|
||||
Type=Application\n\
|
||||
Categories=Game;\n\
|
||||
Keywords=wine;proton;gaming;launcher;\n\
|
||||
StartupNotify=true\n",
|
||||
exe = exe.display(),
|
||||
)
|
||||
}
|
||||
|
||||
fn systemctl(args: &[&str]) -> Result<()> {
|
||||
let status = Command::new("systemctl")
|
||||
.arg("--user")
|
||||
@@ -41,20 +66,49 @@ fn systemctl(args: &[&str]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write the unit, reload systemd, and enable+start the service.
|
||||
pub fn install() -> Result<()> {
|
||||
/// Install only the .desktop file so umutray appears in the app menu.
|
||||
/// Called automatically on first `umutray gui` run.
|
||||
pub fn install_desktop() -> Result<()> {
|
||||
let exe = std::env::current_exe().context("Cannot determine path to own executable")?;
|
||||
let path = unit_path()?;
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).with_context(|| format!("Failed to create {parent:?}"))?;
|
||||
let desktop = desktop_path()?;
|
||||
if let Some(p) = desktop.parent() {
|
||||
std::fs::create_dir_all(p).with_context(|| format!("Failed to create {p:?}"))?;
|
||||
}
|
||||
std::fs::write(&desktop, render_desktop(&exe))
|
||||
.with_context(|| format!("Failed to write desktop file {desktop:?}"))?;
|
||||
println!("\x1b[1;32m✓\x1b[0m App menu entry written: {}", desktop.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
let contents = render_unit(&exe);
|
||||
std::fs::write(&path, &contents)
|
||||
.with_context(|| format!("Failed to write unit file {path:?}"))?;
|
||||
println!("Wrote unit: {}", path.display());
|
||||
println!("ExecStart: {}", exe.display());
|
||||
/// Remove the .desktop file.
|
||||
pub fn uninstall_desktop() -> Result<()> {
|
||||
let desktop = desktop_path()?;
|
||||
if desktop.exists() {
|
||||
std::fs::remove_file(&desktop)
|
||||
.with_context(|| format!("Failed to remove {desktop:?}"))?;
|
||||
println!("Removed {}", desktop.display());
|
||||
} else {
|
||||
println!("No desktop file at {}", desktop.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write the unit + desktop file, reload systemd, and enable+start the service.
|
||||
pub fn install() -> Result<()> {
|
||||
let exe = std::env::current_exe().context("Cannot determine path to own executable")?;
|
||||
|
||||
// systemd unit
|
||||
let unit = unit_path()?;
|
||||
if let Some(p) = unit.parent() {
|
||||
std::fs::create_dir_all(p).with_context(|| format!("Failed to create {p:?}"))?;
|
||||
}
|
||||
std::fs::write(&unit, render_unit(&exe))
|
||||
.with_context(|| format!("Failed to write unit file {unit:?}"))?;
|
||||
println!("Wrote unit: {}", unit.display());
|
||||
|
||||
// .desktop file
|
||||
install_desktop()?;
|
||||
println!("Exec: {} gui", exe.display());
|
||||
println!();
|
||||
|
||||
systemctl(&["daemon-reload"])?;
|
||||
@@ -62,25 +116,26 @@ pub fn install() -> Result<()> {
|
||||
|
||||
println!();
|
||||
println!("\x1b[1;32m✓\x1b[0m Service installed and started.");
|
||||
println!(" umutray autostarts with your session and is in the app menu.");
|
||||
println!(" Status: systemctl --user status {UNIT_NAME}");
|
||||
println!(" Logs: journalctl --user -u {UNIT_NAME} -f");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop, disable, and remove the unit file.
|
||||
/// Stop, disable, and remove the unit + desktop files.
|
||||
pub fn uninstall() -> Result<()> {
|
||||
let path = unit_path()?;
|
||||
|
||||
// Ignore failures: the unit may already be stopped or unknown to systemd.
|
||||
let _ = systemctl(&["disable", "--now", UNIT_NAME]);
|
||||
|
||||
if path.exists() {
|
||||
std::fs::remove_file(&path).with_context(|| format!("Failed to remove {path:?}"))?;
|
||||
println!("Removed {}", path.display());
|
||||
let unit = unit_path()?;
|
||||
if unit.exists() {
|
||||
std::fs::remove_file(&unit).with_context(|| format!("Failed to remove {unit:?}"))?;
|
||||
println!("Removed {}", unit.display());
|
||||
} else {
|
||||
println!("No unit file at {}", path.display());
|
||||
println!("No unit file at {}", unit.display());
|
||||
}
|
||||
|
||||
uninstall_desktop()?;
|
||||
|
||||
let _ = systemctl(&["daemon-reload"]);
|
||||
println!("\x1b[1;32m✓\x1b[0m Service removed.");
|
||||
Ok(())
|
||||
|
||||
+256
-27
@@ -1,8 +1,8 @@
|
||||
use crate::config::{Config, Launcher};
|
||||
use crate::{config::{self, Config, Launcher}, util::async_blocking};
|
||||
use rfd;
|
||||
use anyhow::Result;
|
||||
use iced::futures::channel::oneshot;
|
||||
use iced::widget::{
|
||||
button, column, container, progress_bar, row, scrollable, text, text_input, Column,
|
||||
button, column, container, pick_list, progress_bar, row, scrollable, text, text_input, Column,
|
||||
};
|
||||
use iced::{Element, Length, Subscription, Task, Theme};
|
||||
use std::ffi::OsString;
|
||||
@@ -14,6 +14,14 @@ use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
// Picking stage
|
||||
TemplateSelected(String),
|
||||
PrefixChanged(String),
|
||||
BrowsePrefix,
|
||||
BrowsePrefixDone(Option<String>),
|
||||
ConfirmLauncher,
|
||||
// Install stage
|
||||
Back,
|
||||
SourceChanged(String),
|
||||
PreparePressed,
|
||||
PrepareDone(Result<PathBuf, String>),
|
||||
@@ -24,6 +32,7 @@ pub enum Message {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Stage {
|
||||
Picking,
|
||||
Idle,
|
||||
Busy,
|
||||
Ready,
|
||||
@@ -39,26 +48,71 @@ struct DownloadProgress {
|
||||
|
||||
struct State {
|
||||
config: Config,
|
||||
launcher: Launcher,
|
||||
launcher: Option<Launcher>,
|
||||
// Picking stage fields
|
||||
template_options: Vec<String>,
|
||||
selected_template: Option<String>,
|
||||
prefix_input: String,
|
||||
// Install stage fields
|
||||
source: String,
|
||||
installer: Option<PathBuf>,
|
||||
/// Tracks a file we downloaded to a temp path so we can delete it on exit or failure.
|
||||
/// Not set for local files the user pointed us at — those are never deleted.
|
||||
downloaded_temp: Option<PathBuf>,
|
||||
stage: Stage,
|
||||
status: String,
|
||||
download: Arc<Mutex<DownloadProgress>>,
|
||||
log: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl Drop for State {
|
||||
fn drop(&mut self) {
|
||||
if let Some(path) = self.downloaded_temp.take() {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl State {
|
||||
fn new(config: Config, launcher: Launcher) -> Self {
|
||||
fn new_picking(config: Config) -> Self {
|
||||
let template_options = config::presets()
|
||||
.iter()
|
||||
.map(|l| l.display.clone())
|
||||
.collect();
|
||||
Self {
|
||||
config,
|
||||
launcher: None,
|
||||
template_options,
|
||||
selected_template: None,
|
||||
prefix_input: String::new(),
|
||||
source: String::new(),
|
||||
installer: None,
|
||||
downloaded_temp: None,
|
||||
stage: Stage::Picking,
|
||||
status: String::new(),
|
||||
download: Arc::new(Mutex::new(DownloadProgress::default())),
|
||||
log: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
fn new_install(config: Config, launcher: Launcher) -> Self {
|
||||
let status = format!(
|
||||
"Paste an installer URL or a local .exe path. It will install into {}.",
|
||||
launcher.prefix_dir.display()
|
||||
);
|
||||
let template_options = config::presets()
|
||||
.iter()
|
||||
.map(|l| l.display.clone())
|
||||
.collect();
|
||||
Self {
|
||||
config,
|
||||
launcher,
|
||||
launcher: Some(launcher),
|
||||
template_options,
|
||||
selected_template: None,
|
||||
prefix_input: String::new(),
|
||||
source: String::new(),
|
||||
installer: None,
|
||||
downloaded_temp: None,
|
||||
stage: Stage::Idle,
|
||||
status,
|
||||
download: Arc::new(Mutex::new(DownloadProgress::default())),
|
||||
@@ -69,6 +123,78 @@ impl State {
|
||||
|
||||
fn update(state: &mut State, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
// ── Picking stage ──────────────────────────────────────────────────
|
||||
Message::TemplateSelected(display) => {
|
||||
// Pre-fill prefix dir from the matching preset
|
||||
let prefix = config::presets()
|
||||
.into_iter()
|
||||
.find(|p| p.display == display)
|
||||
.map(|p| p.prefix_dir.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
state.prefix_input = prefix;
|
||||
state.selected_template = Some(display);
|
||||
state.status = String::new();
|
||||
Task::none()
|
||||
}
|
||||
Message::PrefixChanged(s) => {
|
||||
state.prefix_input = s;
|
||||
Task::none()
|
||||
}
|
||||
Message::BrowsePrefix => Task::perform(
|
||||
async {
|
||||
rfd::AsyncFileDialog::new()
|
||||
.set_title("Choose install location (Wine prefix)")
|
||||
.pick_folder()
|
||||
.await
|
||||
.map(|h| h.path().to_string_lossy().into_owned())
|
||||
},
|
||||
Message::BrowsePrefixDone,
|
||||
),
|
||||
Message::BrowsePrefixDone(path) => {
|
||||
if let Some(p) = path {
|
||||
state.prefix_input = p;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::ConfirmLauncher => {
|
||||
let Some(display) = state.selected_template.clone() else {
|
||||
state.status = "Please select a launcher first.".into();
|
||||
return Task::none();
|
||||
};
|
||||
let prefix = state.prefix_input.trim().to_string();
|
||||
if prefix.is_empty() {
|
||||
state.status = "Please enter an install location.".into();
|
||||
return Task::none();
|
||||
}
|
||||
let Some(mut preset) =
|
||||
config::presets().into_iter().find(|p| p.display == display)
|
||||
else {
|
||||
state.status = "Unknown launcher template.".into();
|
||||
return Task::none();
|
||||
};
|
||||
preset.prefix_dir = PathBuf::from(&prefix);
|
||||
|
||||
state.status = format!(
|
||||
"Paste an installer URL or a local .exe path. It will install into {}.",
|
||||
preset.prefix_dir.display()
|
||||
);
|
||||
state.launcher = Some(preset);
|
||||
state.stage = Stage::Idle;
|
||||
Task::none()
|
||||
}
|
||||
|
||||
// ── Install stage ──────────────────────────────────────────────────
|
||||
Message::Back => {
|
||||
if let Some(l) = &state.launcher {
|
||||
state.prefix_input = l.prefix_dir.to_string_lossy().into_owned();
|
||||
}
|
||||
if let Some(tmp) = state.downloaded_temp.take() {
|
||||
let _ = std::fs::remove_file(tmp);
|
||||
}
|
||||
state.installer = None;
|
||||
state.stage = Stage::Picking;
|
||||
Task::none()
|
||||
}
|
||||
Message::Tick => Task::none(),
|
||||
Message::SourceChanged(s) => {
|
||||
state.source = s;
|
||||
@@ -96,14 +222,20 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
|
||||
if let Ok(mut p) = state.download.lock() {
|
||||
*p = DownloadProgress::default();
|
||||
}
|
||||
let name = state.launcher.name.clone();
|
||||
let name = state
|
||||
.launcher
|
||||
.as_ref()
|
||||
.expect("launcher set before install")
|
||||
.name
|
||||
.clone();
|
||||
let progress = state.download.clone();
|
||||
Task::perform(
|
||||
blocking(move || download_blocking(&src, &name, progress)),
|
||||
async_blocking(move || download_blocking(&src, &name, progress)),
|
||||
Message::PrepareDone,
|
||||
)
|
||||
}
|
||||
Message::PrepareDone(Ok(path)) => {
|
||||
state.downloaded_temp = Some(path.clone());
|
||||
state.installer = Some(path.clone());
|
||||
state.stage = Stage::Ready;
|
||||
state.status = format!("Downloaded to {}", path.display());
|
||||
@@ -124,22 +256,40 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
|
||||
v.clear();
|
||||
}
|
||||
let config = state.config.clone();
|
||||
let launcher = state.launcher.clone();
|
||||
let launcher = state
|
||||
.launcher
|
||||
.clone()
|
||||
.expect("launcher set before install");
|
||||
let log = state.log.clone();
|
||||
Task::perform(
|
||||
blocking(move || run_installer(&config, &launcher, &installer, log)),
|
||||
async_blocking(move || run_installer(&config, &launcher, &installer, log)),
|
||||
Message::InstallDone,
|
||||
)
|
||||
}
|
||||
Message::InstallDone(res) => {
|
||||
state.stage = Stage::Finished;
|
||||
let exe = state.launcher.full_exe_path();
|
||||
let launcher = state
|
||||
.launcher
|
||||
.as_ref()
|
||||
.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 state.config.find(&launcher.name).is_none() {
|
||||
state.config.launchers.push(launcher.clone());
|
||||
let _ = state.config.save();
|
||||
}
|
||||
}
|
||||
// Always clean up downloaded temp file
|
||||
if let Some(tmp) = state.downloaded_temp.take() {
|
||||
let _ = std::fs::remove_file(tmp);
|
||||
}
|
||||
state.status = match res {
|
||||
Ok(code) if exe.exists() => format!(
|
||||
"✓ Installer finished (umu exit {code}); {} is present.\n\
|
||||
You can now run: umutray launch {}",
|
||||
exe.display(),
|
||||
state.launcher.name,
|
||||
launcher.name,
|
||||
),
|
||||
Ok(code) => format!(
|
||||
"umu-run exited {code} but the expected exe is not at {}.\n\
|
||||
@@ -163,11 +313,88 @@ fn subscription(state: &State) -> Subscription<Message> {
|
||||
}
|
||||
|
||||
fn view(state: &State) -> Element<'_, Message> {
|
||||
let header = text(format!("Setup: {}", state.launcher.display)).size(24);
|
||||
let prefix = text(format!("Prefix: {}", state.launcher.prefix_dir.display())).size(13);
|
||||
if matches!(state.stage, Stage::Picking) {
|
||||
return view_picking(state);
|
||||
}
|
||||
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);
|
||||
|
||||
let picker = pick_list(
|
||||
state.template_options.as_slice(),
|
||||
state.selected_template.clone(),
|
||||
Message::TemplateSelected,
|
||||
)
|
||||
.placeholder("Select a launcher…")
|
||||
.width(Length::Fill);
|
||||
|
||||
let prefix_input = text_input("/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 can_confirm =
|
||||
state.selected_template.is_some() && !state.prefix_input.trim().is_empty();
|
||||
|
||||
let confirm_btn = button(text("Next →"))
|
||||
.on_press_maybe(can_confirm.then_some(Message::ConfirmLauncher))
|
||||
.style(button::primary);
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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: {}",
|
||||
state.launcher.full_exe_path().display()
|
||||
launcher.full_exe_path().display()
|
||||
))
|
||||
.size(13);
|
||||
|
||||
@@ -243,26 +470,28 @@ pub fn run(config: &Config, launcher: &Launcher) -> Result<()> {
|
||||
let config = config.clone();
|
||||
let launcher = launcher.clone();
|
||||
let title = format!("umutray setup — {}", launcher.display);
|
||||
|
||||
iced::application(move |_: &State| title.clone(), update, view)
|
||||
.subscription(subscription)
|
||||
.theme(|_| Theme::Dark)
|
||||
.run_with(move || (State::new(config.clone(), launcher.clone()), Task::none()))
|
||||
.run_with(move || {
|
||||
(
|
||||
State::new_install(config.clone(), launcher.clone()),
|
||||
Task::none(),
|
||||
)
|
||||
})
|
||||
.map_err(|e| anyhow::anyhow!("iced: {e}"))
|
||||
}
|
||||
|
||||
async fn blocking<T, F>(f: F) -> T
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: FnOnce() -> T + Send + 'static,
|
||||
{
|
||||
let (tx, rx) = oneshot::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(f());
|
||||
});
|
||||
rx.await.expect("setup helper thread panicked")
|
||||
pub fn run_new(config: &Config) -> Result<()> {
|
||||
let config = config.clone();
|
||||
iced::application(|_: &State| "umutray — Add Launcher".to_string(), update, view)
|
||||
.subscription(subscription)
|
||||
.theme(|_| Theme::Dark)
|
||||
.run_with(move || (State::new_picking(config.clone()), Task::none()))
|
||||
.map_err(|e| anyhow::anyhow!("iced: {e}"))
|
||||
}
|
||||
|
||||
|
||||
fn download_blocking(
|
||||
url: &str,
|
||||
name: &str,
|
||||
|
||||
+36
-1
@@ -16,6 +16,20 @@ fn spawn_setup(name: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_gui() {
|
||||
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("umutray"));
|
||||
if let Err(e) = std::process::Command::new(exe).arg("gui").spawn() {
|
||||
eprintln!("umutray: failed to launch dashboard: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_setup_picker() {
|
||||
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("umutray"));
|
||||
if let Err(e) = std::process::Command::new(exe).arg("setup").spawn() {
|
||||
eprintln!("umutray: failed to launch setup picker: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
enum GameFlag {
|
||||
GameMode,
|
||||
MangoHud,
|
||||
@@ -89,7 +103,16 @@ impl ksni::Tray for UmuTray {
|
||||
|
||||
fn menu(&self) -> Vec<ksni::MenuItem<Self>> {
|
||||
use ksni::menu::*;
|
||||
let mut items: Vec<ksni::MenuItem<Self>> = vec![];
|
||||
let mut items: Vec<ksni::MenuItem<Self>> = vec![
|
||||
StandardItem {
|
||||
label: "Open Dashboard".into(),
|
||||
icon_name: "applications-games".into(),
|
||||
activate: Box::new(|_: &mut Self| spawn_gui()),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
ksni::MenuItem::Separator,
|
||||
];
|
||||
|
||||
for l in &self.config.launchers {
|
||||
let installed = l.full_exe_path().exists();
|
||||
@@ -217,6 +240,18 @@ impl ksni::Tray for UmuTray {
|
||||
}
|
||||
}
|
||||
|
||||
if self.config.launchers.is_empty() {
|
||||
items.push(
|
||||
StandardItem {
|
||||
label: "Add Launcher…".into(),
|
||||
icon_name: "list-add".into(),
|
||||
activate: Box::new(|_: &mut Self| spawn_setup_picker()),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
items.push(ksni::MenuItem::Separator);
|
||||
|
||||
items.push(
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
use iced::futures::channel::oneshot;
|
||||
|
||||
/// Run a blocking closure on a thread pool thread and await its result.
|
||||
/// Used to offload blocking work (HTTP, disk, process spawning) without
|
||||
/// stalling the iced event loop.
|
||||
pub async fn async_blocking<T, F>(f: F) -> T
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: FnOnce() -> T + Send + 'static,
|
||||
{
|
||||
let (tx, rx) = oneshot::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(f());
|
||||
});
|
||||
rx.await.expect("blocking task panicked")
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
[Desktop Entry]
|
||||
Name=umutray
|
||||
Comment=Wine launcher manager for Windows game launchers
|
||||
Exec=umutray gui
|
||||
Icon=applications-games
|
||||
Type=Application
|
||||
Categories=Game;
|
||||
Keywords=wine;proton;gaming;launcher;
|
||||
StartupNotify=true
|
||||
Reference in New Issue
Block a user