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:
+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}"))
|
||||
}
|
||||
+29
-11
@@ -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`")
|
||||
})?;
|
||||
setup::run(&config, l)?;
|
||||
}
|
||||
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()?,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+77
-22
@@ -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.
|
||||
/// Install only the .desktop file so umutray appears in the app menu.
|
||||
/// Called automatically on first `umutray gui` run.
|
||||
pub fn install_desktop() -> Result<()> {
|
||||
let exe = std::env::current_exe().context("Cannot determine path to own executable")?;
|
||||
let desktop = desktop_path()?;
|
||||
if let Some(p) = desktop.parent() {
|
||||
std::fs::create_dir_all(p).with_context(|| format!("Failed to create {p:?}"))?;
|
||||
}
|
||||
std::fs::write(&desktop, render_desktop(&exe))
|
||||
.with_context(|| format!("Failed to write desktop file {desktop:?}"))?;
|
||||
println!("\x1b[1;32m✓\x1b[0m App menu entry written: {}", desktop.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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")?;
|
||||
let path = unit_path()?;
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).with_context(|| format!("Failed to create {parent:?}"))?;
|
||||
// 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());
|
||||
|
||||
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());
|
||||
// .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")
|
||||
}
|
||||
Reference in New Issue
Block a user