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",
|
"iced_core",
|
||||||
"log",
|
"log",
|
||||||
"rustc-hash 2.1.2",
|
"rustc-hash 2.1.2",
|
||||||
|
"tokio",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"wasm-timer",
|
"wasm-timer",
|
||||||
]
|
]
|
||||||
|
|||||||
+1
-1
@@ -37,4 +37,4 @@ ksni = "0.2"
|
|||||||
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
||||||
|
|
||||||
# GUI for the setup wizard
|
# 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.
|
prefix / exe / ownership / running state.
|
||||||
- `service` — installs a `systemd --user` unit so the tray autostarts with
|
- `service` — installs a `systemd --user` unit so the tray autostarts with
|
||||||
the graphical session.
|
the graphical session.
|
||||||
- `setup` — graphical wizard (iced) that downloads an installer URL or
|
- `setup` — graphical wizard (iced) that downloads an installer URL
|
||||||
accepts a local `.exe`, then runs it via `umu-run` in the launcher's
|
(with progress bar) or accepts a local `.exe`, then runs it via
|
||||||
Wine prefix.
|
`umu-run` in the launcher's Wine prefix with a live log pane.
|
||||||
|
Uninstalled launchers expose a **Setup…** entry directly in the tray.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -65,6 +66,8 @@ umutray service install
|
|||||||
| `umutray config show` / `path` | Print current config or its file path |
|
| `umutray config show` / `path` | Print current config or its file path |
|
||||||
| `umutray config edit` | Open config in `$EDITOR` |
|
| `umutray config edit` | Open config in `$EDITOR` |
|
||||||
| `umutray config set …` | Update globals (`--proton-version`, `--compat-dir`) |
|
| `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 install` | Write + enable a `systemd --user` unit |
|
||||||
| `umutray service uninstall` | Stop, disable, and remove the unit |
|
| `umutray service uninstall` | Stop, disable, and remove the unit |
|
||||||
| `umutray service status` | `systemctl --user status umutray.service` |
|
| `umutray service status` | `systemctl --user status umutray.service` |
|
||||||
|
|||||||
@@ -61,6 +61,21 @@ fn default_proton_version() -> String {
|
|||||||
"GE-Proton".into()
|
"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`,
|
/// The six launchers umutray ships out of the box. `exe_path`, `gameid`,
|
||||||
/// and `process_pattern` are best-effort defaults for typical installs —
|
/// and `process_pattern` are best-effort defaults for typical installs —
|
||||||
/// users can adjust per-launcher via `umutray config edit`.
|
/// users can adjust per-launcher via `umutray config edit`.
|
||||||
@@ -231,6 +246,60 @@ impl Config {
|
|||||||
Ok(())
|
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.
|
/// Update global fields non-interactively, then save.
|
||||||
/// Use `config edit` for per-launcher changes.
|
/// Use `config edit` for per-launcher changes.
|
||||||
pub fn set_globals(
|
pub fn set_globals(
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ pub fn run(config: &Config, name: Option<&str>) -> Result<()> {
|
|||||||
global_vulkan_check(),
|
global_vulkan_check(),
|
||||||
global_display_check(),
|
global_display_check(),
|
||||||
compat_dir_check(config),
|
compat_dir_check(config),
|
||||||
|
wineserver_check(config),
|
||||||
];
|
];
|
||||||
|
|
||||||
let launchers: Vec<&Launcher> = if let Some(n) = name {
|
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> {
|
fn launcher_checks(l: &Launcher) -> Vec<Check> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let tag = format!("[{}]", l.name);
|
let tag = format!("[{}]", l.name);
|
||||||
|
|||||||
+58
@@ -98,6 +98,40 @@ enum ConfigAction {
|
|||||||
#[arg(long, value_name = "PATH")]
|
#[arg(long, value_name = "PATH")]
|
||||||
compat_dir: Option<PathBuf>,
|
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)]
|
#[derive(Subcommand)]
|
||||||
@@ -173,6 +207,30 @@ fn main() -> Result<()> {
|
|||||||
let mut c = config;
|
let mut c = config;
|
||||||
c.set_globals(proton_version, compat_dir)?;
|
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 {
|
Commands::Service { action } => match action {
|
||||||
|
|||||||
+151
-14
@@ -1,10 +1,16 @@
|
|||||||
use crate::config::{Config, Launcher};
|
use crate::config::{Config, Launcher};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use iced::futures::channel::oneshot;
|
use iced::futures::channel::oneshot;
|
||||||
use iced::widget::{button, column, container, row, text, text_input};
|
use iced::widget::{
|
||||||
use iced::{Element, Task, Theme};
|
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::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::{Command, Stdio};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum Message {
|
pub enum Message {
|
||||||
@@ -13,6 +19,7 @@ pub enum Message {
|
|||||||
PrepareDone(Result<PathBuf, String>),
|
PrepareDone(Result<PathBuf, String>),
|
||||||
InstallPressed,
|
InstallPressed,
|
||||||
InstallDone(Result<i32, String>),
|
InstallDone(Result<i32, String>),
|
||||||
|
Tick,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -24,6 +31,12 @@ enum Stage {
|
|||||||
Finished,
|
Finished,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
struct DownloadProgress {
|
||||||
|
bytes: u64,
|
||||||
|
total: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
struct State {
|
struct State {
|
||||||
config: Config,
|
config: Config,
|
||||||
launcher: Launcher,
|
launcher: Launcher,
|
||||||
@@ -31,6 +44,8 @@ struct State {
|
|||||||
installer: Option<PathBuf>,
|
installer: Option<PathBuf>,
|
||||||
stage: Stage,
|
stage: Stage,
|
||||||
status: String,
|
status: String,
|
||||||
|
download: Arc<Mutex<DownloadProgress>>,
|
||||||
|
log: Arc<Mutex<Vec<String>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl State {
|
impl State {
|
||||||
@@ -46,12 +61,15 @@ impl State {
|
|||||||
installer: None,
|
installer: None,
|
||||||
stage: Stage::Idle,
|
stage: Stage::Idle,
|
||||||
status,
|
status,
|
||||||
|
download: Arc::new(Mutex::new(DownloadProgress::default())),
|
||||||
|
log: Arc::new(Mutex::new(Vec::new())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(state: &mut State, message: Message) -> Task<Message> {
|
fn update(state: &mut State, message: Message) -> Task<Message> {
|
||||||
match message {
|
match message {
|
||||||
|
Message::Tick => Task::none(),
|
||||||
Message::SourceChanged(s) => {
|
Message::SourceChanged(s) => {
|
||||||
state.source = s;
|
state.source = s;
|
||||||
Task::none()
|
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();
|
state.status = "Enter an installer URL or local path first.".into();
|
||||||
return Task::none();
|
return Task::none();
|
||||||
}
|
}
|
||||||
// Local file on disk?
|
|
||||||
let path = PathBuf::from(&src);
|
let path = PathBuf::from(&src);
|
||||||
if path.is_file() {
|
if path.is_file() {
|
||||||
state.installer = Some(path.clone());
|
state.installer = Some(path.clone());
|
||||||
@@ -76,9 +93,13 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
|
|||||||
}
|
}
|
||||||
state.stage = Stage::Busy;
|
state.stage = Stage::Busy;
|
||||||
state.status = format!("Downloading {src} …");
|
state.status = format!("Downloading {src} …");
|
||||||
|
if let Ok(mut p) = state.download.lock() {
|
||||||
|
*p = DownloadProgress::default();
|
||||||
|
}
|
||||||
let name = state.launcher.name.clone();
|
let name = state.launcher.name.clone();
|
||||||
|
let progress = state.download.clone();
|
||||||
Task::perform(
|
Task::perform(
|
||||||
blocking(move || download_blocking(&src, &name)),
|
blocking(move || download_blocking(&src, &name, progress)),
|
||||||
Message::PrepareDone,
|
Message::PrepareDone,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -100,10 +121,14 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
|
|||||||
state.stage = Stage::Installing;
|
state.stage = Stage::Installing;
|
||||||
state.status =
|
state.status =
|
||||||
"Running installer via umu-run (this may take several minutes)…".into();
|
"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 config = state.config.clone();
|
||||||
let launcher = state.launcher.clone();
|
let launcher = state.launcher.clone();
|
||||||
|
let log = state.log.clone();
|
||||||
Task::perform(
|
Task::perform(
|
||||||
blocking(move || run_installer(&config, &launcher, &installer)),
|
blocking(move || run_installer(&config, &launcher, &installer, log)),
|
||||||
Message::InstallDone,
|
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> {
|
fn view(state: &State) -> Element<'_, Message> {
|
||||||
let header = text(format!("Setup: {}", state.launcher.display)).size(24);
|
let header = text(format!("Setup: {}", state.launcher.display)).size(24);
|
||||||
let prefix = text(format!(
|
let prefix = text(format!(
|
||||||
@@ -157,13 +191,53 @@ fn view(state: &State) -> Element<'_, Message> {
|
|||||||
|
|
||||||
let status = text(state.status.clone());
|
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![
|
let body = column![
|
||||||
header,
|
header,
|
||||||
prefix,
|
prefix,
|
||||||
expected,
|
expected,
|
||||||
input,
|
input,
|
||||||
row![prepare_btn, install_btn].spacing(12),
|
row![prepare_btn, install_btn].spacing(12),
|
||||||
|
progress_row,
|
||||||
status,
|
status,
|
||||||
|
log_pane,
|
||||||
]
|
]
|
||||||
.spacing(12)
|
.spacing(12)
|
||||||
.padding(20);
|
.padding(20);
|
||||||
@@ -177,13 +251,12 @@ pub fn run(config: &Config, launcher: &Launcher) -> Result<()> {
|
|||||||
let title = format!("umutray setup — {}", launcher.display);
|
let title = format!("umutray setup — {}", launcher.display);
|
||||||
|
|
||||||
iced::application(move |_: &State| title.clone(), update, view)
|
iced::application(move |_: &State| title.clone(), update, view)
|
||||||
|
.subscription(subscription)
|
||||||
.theme(|_| Theme::Dark)
|
.theme(|_| Theme::Dark)
|
||||||
.run_with(move || (State::new(config.clone(), launcher.clone()), Task::none()))
|
.run_with(move || (State::new(config.clone(), launcher.clone()), Task::none()))
|
||||||
.map_err(|e| anyhow::anyhow!("iced: {e}"))
|
.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
|
async fn blocking<T, F>(f: F) -> T
|
||||||
where
|
where
|
||||||
T: Send + 'static,
|
T: Send + 'static,
|
||||||
@@ -196,7 +269,11 @@ where
|
|||||||
rx.await.expect("setup helper thread panicked")
|
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()
|
let client = reqwest::blocking::Client::builder()
|
||||||
.user_agent("umutray/0.1")
|
.user_agent("umutray/0.1")
|
||||||
.build()
|
.build()
|
||||||
@@ -207,8 +284,17 @@ fn download_blocking(url: &str, name: &str) -> Result<PathBuf, String> {
|
|||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
.error_for_status()
|
.error_for_status()
|
||||||
.map_err(|e| e.to_string())?;
|
.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 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() {
|
let filename = if candidate.is_empty() {
|
||||||
format!("{name}-installer.exe")
|
format!("{name}-installer.exe")
|
||||||
} else {
|
} else {
|
||||||
@@ -216,7 +302,17 @@ fn download_blocking(url: &str, name: &str) -> Result<PathBuf, String> {
|
|||||||
};
|
};
|
||||||
let tmp = std::env::temp_dir().join(filename);
|
let tmp = std::env::temp_dir().join(filename);
|
||||||
let mut f = std::fs::File::create(&tmp).map_err(|e| e.to_string())?;
|
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)
|
Ok(tmp)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,23 +320,64 @@ fn run_installer(
|
|||||||
config: &Config,
|
config: &Config,
|
||||||
launcher: &Launcher,
|
launcher: &Launcher,
|
||||||
installer: &Path,
|
installer: &Path,
|
||||||
|
log: Arc<Mutex<Vec<String>>>,
|
||||||
) -> Result<i32, String> {
|
) -> Result<i32, String> {
|
||||||
std::fs::create_dir_all(&launcher.prefix_dir).map_err(|e| e.to_string())?;
|
std::fs::create_dir_all(&launcher.prefix_dir).map_err(|e| e.to_string())?;
|
||||||
let version = launcher
|
let version = launcher
|
||||||
.proton_version
|
.proton_version
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or(&config.proton_version);
|
.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()
|
version.to_string().into()
|
||||||
} else {
|
} else {
|
||||||
config.proton_compat_dir.join(version).into_os_string()
|
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("WINEPREFIX", &launcher.prefix_dir)
|
||||||
.env("GAMEID", &launcher.gameid)
|
.env("GAMEID", &launcher.gameid)
|
||||||
.env("PROTONPATH", &proton_path)
|
.env("PROTONPATH", &proton_path)
|
||||||
.arg(installer)
|
.arg(installer)
|
||||||
.status()
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
.map_err(|e| e.to_string())?;
|
.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))
|
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 crate::{config::Config, launcher};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
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 struct UmuTray {
|
||||||
pub config: Config,
|
pub config: Config,
|
||||||
/// Per-launcher running state keyed by launcher.name
|
/// Per-launcher running state keyed by launcher.name
|
||||||
@@ -41,10 +53,14 @@ impl ksni::Tray for UmuTray {
|
|||||||
let display = l.display.clone();
|
let display = l.display.clone();
|
||||||
|
|
||||||
if !installed {
|
if !installed {
|
||||||
|
let setup_name = name.clone();
|
||||||
items.push(
|
items.push(
|
||||||
StandardItem {
|
StandardItem {
|
||||||
label: format!("{display} (not installed)"),
|
label: format!("Setup {display}…"),
|
||||||
enabled: false,
|
icon_name: "document-new".into(),
|
||||||
|
activate: Box::new(move |_this: &mut Self| {
|
||||||
|
spawn_setup(&setup_name);
|
||||||
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
.into(),
|
.into(),
|
||||||
|
|||||||
Reference in New Issue
Block a user