Add setup tray entry, wizard progress/log, config add/remove, stale-wine check
- tray: uninstalled launchers now show a "Setup…" entry that spawns the setup wizard as a child process. - setup.rs: download shows a progress bar (bytes / total), and umu-run stdout+stderr stream into a scrollable log pane. A 250 ms tick subscription pulls updates from the shared state. - config add-launcher / remove-launcher CLI, with sensible defaults for prefix_dir, gameid, and process_pattern derived from name/exe. - diagnose: flag stale wineserver processes when no launcher is running, suggesting `umutray kill`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Generated
+1
@@ -1827,6 +1827,7 @@ dependencies = [
|
||||
"iced_core",
|
||||
"log",
|
||||
"rustc-hash 2.1.2",
|
||||
"tokio",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-timer",
|
||||
]
|
||||
|
||||
+1
-1
@@ -37,4 +37,4 @@ ksni = "0.2"
|
||||
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
||||
|
||||
# GUI for the setup wizard
|
||||
iced = "0.13"
|
||||
iced = { version = "0.13", features = ["tokio"] }
|
||||
|
||||
@@ -26,9 +26,10 @@ Launch / Kill entries. Users can add or remove launchers in `config edit`.
|
||||
prefix / exe / ownership / running state.
|
||||
- `service` — installs a `systemd --user` unit so the tray autostarts with
|
||||
the graphical session.
|
||||
- `setup` — graphical wizard (iced) that downloads an installer URL or
|
||||
accepts a local `.exe`, then runs it via `umu-run` in the launcher's
|
||||
Wine prefix.
|
||||
- `setup` — graphical wizard (iced) that downloads an installer URL
|
||||
(with progress bar) or accepts a local `.exe`, then runs it via
|
||||
`umu-run` in the launcher's Wine prefix with a live log pane.
|
||||
Uninstalled launchers expose a **Setup…** entry directly in the tray.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -65,6 +66,8 @@ umutray service install
|
||||
| `umutray config show` / `path` | Print current config or its file path |
|
||||
| `umutray config edit` | Open config in `$EDITOR` |
|
||||
| `umutray config set …` | Update globals (`--proton-version`, `--compat-dir`) |
|
||||
| `umutray config add-launcher …` | Append a new launcher (needs `--exe-path`) |
|
||||
| `umutray config remove-launcher` | Drop a launcher (prefix on disk is left untouched) |
|
||||
| `umutray service install` | Write + enable a `systemd --user` unit |
|
||||
| `umutray service uninstall` | Stop, disable, and remove the unit |
|
||||
| `umutray service status` | `systemctl --user status umutray.service` |
|
||||
|
||||
@@ -61,6 +61,21 @@ fn default_proton_version() -> String {
|
||||
"GE-Proton".into()
|
||||
}
|
||||
|
||||
fn regex_escape(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 4);
|
||||
for c in s.chars() {
|
||||
if matches!(
|
||||
c,
|
||||
'.' | '*' | '?' | '+' | '(' | ')' | '[' | ']'
|
||||
| '{' | '}' | '|' | '\\' | '^' | '$'
|
||||
) {
|
||||
out.push('\\');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The six launchers umutray ships out of the box. `exe_path`, `gameid`,
|
||||
/// and `process_pattern` are best-effort defaults for typical installs —
|
||||
/// users can adjust per-launcher via `umutray config edit`.
|
||||
@@ -231,6 +246,60 @@ impl Config {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn add_launcher(
|
||||
&mut self,
|
||||
name: String,
|
||||
display: Option<String>,
|
||||
exe_path: PathBuf,
|
||||
prefix_dir: Option<PathBuf>,
|
||||
gameid: Option<String>,
|
||||
process_pattern: Option<String>,
|
||||
installer_url: Option<String>,
|
||||
) -> Result<()> {
|
||||
if self.launchers.iter().any(|l| l.name == name) {
|
||||
anyhow::bail!("launcher '{name}' already exists");
|
||||
}
|
||||
let display = display.unwrap_or_else(|| name.clone());
|
||||
let prefix_dir = prefix_dir
|
||||
.unwrap_or_else(|| home_dir().join("Games").join(&name));
|
||||
let gameid = gameid.unwrap_or_else(|| format!("umu-{name}"));
|
||||
let process_pattern = process_pattern.unwrap_or_else(|| {
|
||||
exe_path
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(regex_escape)
|
||||
.unwrap_or_else(|| name.clone())
|
||||
});
|
||||
self.launchers.push(Launcher {
|
||||
name: name.clone(),
|
||||
display,
|
||||
prefix_dir,
|
||||
exe_path,
|
||||
gameid,
|
||||
process_pattern,
|
||||
installer_url,
|
||||
proton_version: None,
|
||||
});
|
||||
self.save()?;
|
||||
println!("\x1b[1;32m✓\x1b[0m Added launcher '{name}'.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_launcher(&mut self, name: &str) -> Result<()> {
|
||||
let before = self.launchers.len();
|
||||
self.launchers.retain(|l| l.name != name);
|
||||
if self.launchers.len() == before {
|
||||
anyhow::bail!("no launcher named '{name}'");
|
||||
}
|
||||
self.save()?;
|
||||
println!(
|
||||
"\x1b[1;32m✓\x1b[0m Removed '{name}'. \
|
||||
The Wine prefix on disk was left untouched."
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update global fields non-interactively, then save.
|
||||
/// Use `config edit` for per-launcher changes.
|
||||
pub fn set_globals(
|
||||
|
||||
@@ -25,6 +25,7 @@ pub fn run(config: &Config, name: Option<&str>) -> Result<()> {
|
||||
global_vulkan_check(),
|
||||
global_display_check(),
|
||||
compat_dir_check(config),
|
||||
wineserver_check(config),
|
||||
];
|
||||
|
||||
let launchers: Vec<&Launcher> = if let Some(n) = name {
|
||||
@@ -124,6 +125,40 @@ fn compat_dir_check(config: &Config) -> Check {
|
||||
}
|
||||
}
|
||||
|
||||
fn wineserver_check(config: &Config) -> Check {
|
||||
let count = wineserver_count();
|
||||
if count == 0 {
|
||||
return Check::pass("wine procs", "no wineserver running");
|
||||
}
|
||||
let any_running = config
|
||||
.launchers
|
||||
.iter()
|
||||
.any(crate::launcher::is_running);
|
||||
if any_running {
|
||||
Check::pass(
|
||||
"wine procs",
|
||||
format!("{count} wineserver process(es); launcher active"),
|
||||
)
|
||||
} else {
|
||||
Check::fail(
|
||||
"wine procs",
|
||||
format!(
|
||||
"{count} stale wineserver process(es) — try: umutray kill"
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn wineserver_count() -> usize {
|
||||
Command::new("pgrep")
|
||||
.args(["-c", "-f", "wineserver"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.and_then(|s| s.trim().parse::<usize>().ok())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn launcher_checks(l: &Launcher) -> Vec<Check> {
|
||||
let mut out = Vec::new();
|
||||
let tag = format!("[{}]", l.name);
|
||||
|
||||
+58
@@ -98,6 +98,40 @@ enum ConfigAction {
|
||||
#[arg(long, value_name = "PATH")]
|
||||
compat_dir: Option<PathBuf>,
|
||||
},
|
||||
/// Add a new launcher to the config
|
||||
AddLauncher {
|
||||
/// Short CLI name (e.g. "heroic")
|
||||
name: String,
|
||||
|
||||
/// Windows exe path relative to drive_c/ (e.g. "Program Files/Foo/foo.exe")
|
||||
#[arg(long, value_name = "PATH")]
|
||||
exe_path: PathBuf,
|
||||
|
||||
/// Display name for menus (defaults to NAME)
|
||||
#[arg(long)]
|
||||
display: Option<String>,
|
||||
|
||||
/// Wine prefix dir (defaults to ~/Games/NAME)
|
||||
#[arg(long, value_name = "PATH")]
|
||||
prefix_dir: Option<PathBuf>,
|
||||
|
||||
/// umu GAMEID (defaults to "umu-NAME")
|
||||
#[arg(long)]
|
||||
gameid: Option<String>,
|
||||
|
||||
/// pgrep -f regex (defaults to escaped exe basename)
|
||||
#[arg(long)]
|
||||
process_pattern: Option<String>,
|
||||
|
||||
/// Optional installer URL
|
||||
#[arg(long)]
|
||||
installer_url: Option<String>,
|
||||
},
|
||||
/// Remove a launcher from the config (leaves its prefix on disk)
|
||||
RemoveLauncher {
|
||||
/// Short CLI name
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -173,6 +207,30 @@ fn main() -> Result<()> {
|
||||
let mut c = config;
|
||||
c.set_globals(proton_version, compat_dir)?;
|
||||
}
|
||||
ConfigAction::AddLauncher {
|
||||
name,
|
||||
exe_path,
|
||||
display,
|
||||
prefix_dir,
|
||||
gameid,
|
||||
process_pattern,
|
||||
installer_url,
|
||||
} => {
|
||||
let mut c = config;
|
||||
c.add_launcher(
|
||||
name,
|
||||
display,
|
||||
exe_path,
|
||||
prefix_dir,
|
||||
gameid,
|
||||
process_pattern,
|
||||
installer_url,
|
||||
)?;
|
||||
}
|
||||
ConfigAction::RemoveLauncher { name } => {
|
||||
let mut c = config;
|
||||
c.remove_launcher(&name)?;
|
||||
}
|
||||
},
|
||||
|
||||
Commands::Service { action } => match action {
|
||||
|
||||
+151
-14
@@ -1,10 +1,16 @@
|
||||
use crate::config::{Config, Launcher};
|
||||
use anyhow::Result;
|
||||
use iced::futures::channel::oneshot;
|
||||
use iced::widget::{button, column, container, row, text, text_input};
|
||||
use iced::{Element, Task, Theme};
|
||||
use iced::widget::{
|
||||
button, column, container, progress_bar, row, scrollable, text, text_input, Column,
|
||||
};
|
||||
use iced::{Element, Length, Subscription, Task, Theme};
|
||||
use std::ffi::OsString;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
@@ -13,6 +19,7 @@ pub enum Message {
|
||||
PrepareDone(Result<PathBuf, String>),
|
||||
InstallPressed,
|
||||
InstallDone(Result<i32, String>),
|
||||
Tick,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -24,6 +31,12 @@ enum Stage {
|
||||
Finished,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct DownloadProgress {
|
||||
bytes: u64,
|
||||
total: Option<u64>,
|
||||
}
|
||||
|
||||
struct State {
|
||||
config: Config,
|
||||
launcher: Launcher,
|
||||
@@ -31,6 +44,8 @@ struct State {
|
||||
installer: Option<PathBuf>,
|
||||
stage: Stage,
|
||||
status: String,
|
||||
download: Arc<Mutex<DownloadProgress>>,
|
||||
log: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl State {
|
||||
@@ -46,12 +61,15 @@ impl State {
|
||||
installer: None,
|
||||
stage: Stage::Idle,
|
||||
status,
|
||||
download: Arc::new(Mutex::new(DownloadProgress::default())),
|
||||
log: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update(state: &mut State, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::Tick => Task::none(),
|
||||
Message::SourceChanged(s) => {
|
||||
state.source = s;
|
||||
Task::none()
|
||||
@@ -62,7 +80,6 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
|
||||
state.status = "Enter an installer URL or local path first.".into();
|
||||
return Task::none();
|
||||
}
|
||||
// Local file on disk?
|
||||
let path = PathBuf::from(&src);
|
||||
if path.is_file() {
|
||||
state.installer = Some(path.clone());
|
||||
@@ -76,9 +93,13 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
|
||||
}
|
||||
state.stage = Stage::Busy;
|
||||
state.status = format!("Downloading {src} …");
|
||||
if let Ok(mut p) = state.download.lock() {
|
||||
*p = DownloadProgress::default();
|
||||
}
|
||||
let name = state.launcher.name.clone();
|
||||
let progress = state.download.clone();
|
||||
Task::perform(
|
||||
blocking(move || download_blocking(&src, &name)),
|
||||
blocking(move || download_blocking(&src, &name, progress)),
|
||||
Message::PrepareDone,
|
||||
)
|
||||
}
|
||||
@@ -100,10 +121,14 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
|
||||
state.stage = Stage::Installing;
|
||||
state.status =
|
||||
"Running installer via umu-run (this may take several minutes)…".into();
|
||||
if let Ok(mut v) = state.log.lock() {
|
||||
v.clear();
|
||||
}
|
||||
let config = state.config.clone();
|
||||
let launcher = state.launcher.clone();
|
||||
let log = state.log.clone();
|
||||
Task::perform(
|
||||
blocking(move || run_installer(&config, &launcher, &installer)),
|
||||
blocking(move || run_installer(&config, &launcher, &installer, log)),
|
||||
Message::InstallDone,
|
||||
)
|
||||
}
|
||||
@@ -129,6 +154,15 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
|
||||
}
|
||||
}
|
||||
|
||||
fn subscription(state: &State) -> Subscription<Message> {
|
||||
match state.stage {
|
||||
Stage::Busy | Stage::Installing => {
|
||||
iced::time::every(Duration::from_millis(250)).map(|_| Message::Tick)
|
||||
}
|
||||
_ => Subscription::none(),
|
||||
}
|
||||
}
|
||||
|
||||
fn view(state: &State) -> Element<'_, Message> {
|
||||
let header = text(format!("Setup: {}", state.launcher.display)).size(24);
|
||||
let prefix = text(format!(
|
||||
@@ -157,13 +191,53 @@ fn view(state: &State) -> Element<'_, Message> {
|
||||
|
||||
let status = text(state.status.clone());
|
||||
|
||||
let progress_row: Element<Message> = if matches!(state.stage, Stage::Busy) {
|
||||
let p = state
|
||||
.download
|
||||
.lock()
|
||||
.map(|p| (p.bytes, p.total))
|
||||
.unwrap_or((0, None));
|
||||
let (bytes, total) = p;
|
||||
let fraction = match total {
|
||||
Some(t) if t > 0 => (bytes as f32) / (t as f32),
|
||||
_ => 0.0,
|
||||
};
|
||||
let label = match total {
|
||||
Some(t) => format!("{} / {}", fmt_bytes(bytes), fmt_bytes(t)),
|
||||
None => format!("{} downloaded", fmt_bytes(bytes)),
|
||||
};
|
||||
column![progress_bar(0.0..=1.0, fraction), text(label).size(12)]
|
||||
.spacing(4)
|
||||
.into()
|
||||
} else {
|
||||
text("").into()
|
||||
};
|
||||
|
||||
let log_pane: Element<Message> = if matches!(state.stage, Stage::Installing | Stage::Finished) {
|
||||
let lines: Vec<String> = state.log.lock().map(|v| v.clone()).unwrap_or_default();
|
||||
let tail: Vec<Element<Message>> = lines
|
||||
.iter()
|
||||
.rev()
|
||||
.take(80)
|
||||
.rev()
|
||||
.map(|l| text(l.clone()).size(11).into())
|
||||
.collect();
|
||||
scrollable(Column::with_children(tail).spacing(2))
|
||||
.height(Length::Fixed(220.0))
|
||||
.into()
|
||||
} else {
|
||||
text("").into()
|
||||
};
|
||||
|
||||
let body = column![
|
||||
header,
|
||||
prefix,
|
||||
expected,
|
||||
input,
|
||||
row![prepare_btn, install_btn].spacing(12),
|
||||
progress_row,
|
||||
status,
|
||||
log_pane,
|
||||
]
|
||||
.spacing(12)
|
||||
.padding(20);
|
||||
@@ -177,13 +251,12 @@ pub fn run(config: &Config, launcher: &Launcher) -> Result<()> {
|
||||
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()))
|
||||
.map_err(|e| anyhow::anyhow!("iced: {e}"))
|
||||
}
|
||||
|
||||
/// Run a blocking closure on a helper thread and await the result
|
||||
/// via a oneshot channel. Avoids pulling tokio in as a direct dep.
|
||||
async fn blocking<T, F>(f: F) -> T
|
||||
where
|
||||
T: Send + 'static,
|
||||
@@ -196,7 +269,11 @@ where
|
||||
rx.await.expect("setup helper thread panicked")
|
||||
}
|
||||
|
||||
fn download_blocking(url: &str, name: &str) -> Result<PathBuf, String> {
|
||||
fn download_blocking(
|
||||
url: &str,
|
||||
name: &str,
|
||||
progress: Arc<Mutex<DownloadProgress>>,
|
||||
) -> Result<PathBuf, String> {
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.user_agent("umutray/0.1")
|
||||
.build()
|
||||
@@ -207,8 +284,17 @@ fn download_blocking(url: &str, name: &str) -> Result<PathBuf, String> {
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let total = resp.content_length();
|
||||
if let Ok(mut p) = progress.lock() {
|
||||
p.bytes = 0;
|
||||
p.total = total;
|
||||
}
|
||||
let base = url.split('?').next().unwrap_or(url);
|
||||
let candidate = base.rsplit('/').next().filter(|s| s.contains('.')).unwrap_or("");
|
||||
let candidate = base
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.filter(|s| s.contains('.'))
|
||||
.unwrap_or("");
|
||||
let filename = if candidate.is_empty() {
|
||||
format!("{name}-installer.exe")
|
||||
} else {
|
||||
@@ -216,7 +302,17 @@ fn download_blocking(url: &str, name: &str) -> Result<PathBuf, String> {
|
||||
};
|
||||
let tmp = std::env::temp_dir().join(filename);
|
||||
let mut f = std::fs::File::create(&tmp).map_err(|e| e.to_string())?;
|
||||
std::io::copy(&mut resp, &mut f).map_err(|e| e.to_string())?;
|
||||
let mut buf = [0u8; 64 * 1024];
|
||||
loop {
|
||||
let n = resp.read(&mut buf).map_err(|e| e.to_string())?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
f.write_all(&buf[..n]).map_err(|e| e.to_string())?;
|
||||
if let Ok(mut p) = progress.lock() {
|
||||
p.bytes += n as u64;
|
||||
}
|
||||
}
|
||||
Ok(tmp)
|
||||
}
|
||||
|
||||
@@ -224,23 +320,64 @@ fn run_installer(
|
||||
config: &Config,
|
||||
launcher: &Launcher,
|
||||
installer: &Path,
|
||||
log: Arc<Mutex<Vec<String>>>,
|
||||
) -> Result<i32, String> {
|
||||
std::fs::create_dir_all(&launcher.prefix_dir).map_err(|e| e.to_string())?;
|
||||
let version = launcher
|
||||
.proton_version
|
||||
.as_deref()
|
||||
.unwrap_or(&config.proton_version);
|
||||
let proton_path: std::ffi::OsString = if version == "GE-Proton" {
|
||||
let proton_path: OsString = if version == "GE-Proton" {
|
||||
version.to_string().into()
|
||||
} else {
|
||||
config.proton_compat_dir.join(version).into_os_string()
|
||||
};
|
||||
let status = Command::new("umu-run")
|
||||
let mut child = Command::new("umu-run")
|
||||
.env("WINEPREFIX", &launcher.prefix_dir)
|
||||
.env("GAMEID", &launcher.gameid)
|
||||
.env("PROTONPATH", &proton_path)
|
||||
.arg(installer)
|
||||
.status()
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let stdout = child.stdout.take().expect("piped stdout");
|
||||
let stderr = child.stderr.take().expect("piped stderr");
|
||||
let log_out = log.clone();
|
||||
let log_err = log.clone();
|
||||
let t_out = std::thread::spawn(move || stream_into(stdout, log_out));
|
||||
let t_err = std::thread::spawn(move || stream_into(stderr, log_err));
|
||||
|
||||
let status = child.wait().map_err(|e| e.to_string())?;
|
||||
let _ = t_out.join();
|
||||
let _ = t_err.join();
|
||||
Ok(status.code().unwrap_or(-1))
|
||||
}
|
||||
|
||||
fn stream_into<R: Read>(r: R, log: Arc<Mutex<Vec<String>>>) {
|
||||
for line in BufReader::new(r).lines().map_while(Result::ok) {
|
||||
if let Ok(mut v) = log.lock() {
|
||||
v.push(line);
|
||||
if v.len() > 500 {
|
||||
let drop = v.len() - 500;
|
||||
v.drain(0..drop);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fmt_bytes(n: u64) -> String {
|
||||
const KIB: u64 = 1024;
|
||||
const MIB: u64 = 1024 * 1024;
|
||||
const GIB: u64 = 1024 * 1024 * 1024;
|
||||
if n >= GIB {
|
||||
format!("{:.2} GiB", n as f64 / GIB as f64)
|
||||
} else if n >= MIB {
|
||||
format!("{:.1} MiB", n as f64 / MIB as f64)
|
||||
} else if n >= KIB {
|
||||
format!("{:.0} KiB", n as f64 / KIB as f64)
|
||||
} else {
|
||||
format!("{n} B")
|
||||
}
|
||||
}
|
||||
|
||||
+18
-2
@@ -1,9 +1,21 @@
|
||||
use crate::{config::Config, launcher};
|
||||
use anyhow::Result;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
fn spawn_setup(name: &str) {
|
||||
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("umutray"));
|
||||
if let Err(e) = std::process::Command::new(exe)
|
||||
.arg("setup")
|
||||
.arg(name)
|
||||
.spawn()
|
||||
{
|
||||
eprintln!("umutray: failed to launch setup for {name}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
pub struct UmuTray {
|
||||
pub config: Config,
|
||||
/// Per-launcher running state keyed by launcher.name
|
||||
@@ -41,10 +53,14 @@ impl ksni::Tray for UmuTray {
|
||||
let display = l.display.clone();
|
||||
|
||||
if !installed {
|
||||
let setup_name = name.clone();
|
||||
items.push(
|
||||
StandardItem {
|
||||
label: format!("{display} (not installed)"),
|
||||
enabled: false,
|
||||
label: format!("Setup {display}…"),
|
||||
icon_name: "document-new".into(),
|
||||
activate: Box::new(move |_this: &mut Self| {
|
||||
spawn_setup(&setup_name);
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
|
||||
Reference in New Issue
Block a user