Add a day-zero first-run wizard before the desktop tour
Some checks failed
check / check (push) Failing after 6s
dev release / build (push) Successful in 29s

First-boot --autostart now walks Welcome, timezone, network hint, updates,
and an optional snapper day-zero snapshot in the existing help window, then
hands off to the live cheatsheet tour. Existing onboarded users are skipped.
This commit is contained in:
Breadway 2026-08-16 00:00:23 +08:00
parent d9012c8e72
commit af6ba1bd82
9 changed files with 901 additions and 31 deletions

View file

@ -1,3 +1,4 @@
use std::path::Path;
use std::process::Command;
/// Fire-and-forget: runs `command` via a shell, matching the idiom already
@ -8,6 +9,35 @@ pub fn run(command: &str) {
}
}
/// Fire-and-forget argv form — use this when arguments come from the UI
/// (timezone names, page ids) so they never pass through a shell.
pub fn run_argv(prog: &str, args: &[&str]) {
if let Err(e) = Command::new(prog).args(args).spawn() {
eprintln!("breadhelp: failed to run `{prog}`: {e}");
}
}
/// `true` when `name` resolves to an executable on `PATH`.
pub fn command_exists(name: &str) -> bool {
std::env::var_os("PATH")
.map(|paths| std::env::split_paths(&paths).any(|dir| is_executable(&dir.join(name))))
.unwrap_or(false)
}
fn is_executable(path: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path)
.map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(not(unix))]
{
path.is_file()
}
}
/// Runs `command` on a background thread and delivers success/failure back
/// onto the GTK main loop via `on_done` — plain `spawn()` only confirms the
/// process *launched*, not how it exited, and one-click fixes need to report
@ -26,3 +56,19 @@ pub fn run_reporting(command: &str, on_done: impl Fn(bool) + 'static) {
}
});
}
/// Same as [`run_reporting`], but without a shell.
pub fn run_argv_reporting(prog: &str, args: &[&str], on_done: impl Fn(bool) + 'static) {
let prog = prog.to_string();
let args: Vec<String> = args.iter().map(|s| (*s).to_string()).collect();
let (tx, rx) = async_channel::bounded(1);
std::thread::spawn(move || {
let status = Command::new(&prog).args(&args).status();
let _ = tx.send_blocking(status.map(|s| s.success()).unwrap_or(false));
});
glib::MainContext::default().spawn_local(async move {
if let Ok(success) = rx.recv().await {
on_done(success);
}
});
}