Add a day-zero first-run wizard before the desktop tour
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:
parent
d9012c8e72
commit
af6ba1bd82
9 changed files with 901 additions and 31 deletions
|
|
@ -25,8 +25,9 @@ Not emitted when:
|
|||
|
||||
- every-login `--autostart` builds a hidden window because onboarding is
|
||||
already done (silent autostart)
|
||||
- first-run `--autostart` starts the tour overlay without presenting the
|
||||
main window
|
||||
- first-run `--autostart` presents the main window on the day-zero
|
||||
wizard; the tour overlay starts after that wizard finishes (or is
|
||||
skipped). `bread.help.opened` **is** emitted for that first-run present.
|
||||
- `--onboard` / `--tour-event` (tour only)
|
||||
- `--screenshot` (capture, not a user-visible open)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# breadhelp
|
||||
|
||||
Onboarding and help center for [BOS (Bread Operating System)](https://git.breadway.dev/Breadway/bos) — GTK4, a searchable guide library, an interactive keybind viewer, a troubleshooting wizard with one-click fixes, and a live guided tour overlay that spotlights real on-screen bread\* apps (breadbar, breadbox, ...) instead of walking through a static wizard.
|
||||
Onboarding and help center for [BOS (Bread Operating System)](https://git.breadway.dev/Breadway/bos) — GTK4, a searchable guide library, an interactive keybind viewer, a troubleshooting wizard with one-click fixes, a short first-boot day-zero wizard (timezone, network hint, updates, snapper), and a live guided tour overlay that spotlights real on-screen bread\* apps (breadbar, breadbox, ...) instead of walking through a static wizard.
|
||||
|
||||
Split out of the `bos` repo into its own repo so a breadhelp release doesn't require a BOS ISO release, and vice versa. A [bakery](https://git.breadway.dev/Breadway/bread-ecosystem) product (`bakery.toml` + bakery CI) and baked into the BOS ISO — not pacman-packaged.
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ pub struct Action {
|
|||
/// `present()` build the window (so the app is ready to respond the
|
||||
/// instant it's needed) without popping it open when onboarding is
|
||||
/// already done — autostart should only be visible on a genuine first
|
||||
/// run, never on every subsequent login.
|
||||
/// run (day-zero wizard, then the desktop tour), never on every
|
||||
/// subsequent login.
|
||||
pub autostart: bool,
|
||||
/// From a breadd Lua module (e.g. `breadhelp-suggest.lua`) reacting to a
|
||||
/// system event. Resolved to banner text by `services::breadd::resolve`.
|
||||
|
|
|
|||
135
src/config.rs
135
src/config.rs
|
|
@ -86,7 +86,11 @@ impl State {
|
|||
}
|
||||
|
||||
pub fn onboarding_step(&self) -> i64 {
|
||||
self.doc.get("onboarding").and_then(|t| t.get("step")).and_then(|v| v.as_integer()).unwrap_or(0)
|
||||
self.doc
|
||||
.get("onboarding")
|
||||
.and_then(|t| t.get("step"))
|
||||
.and_then(|v| v.as_integer())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn set_onboarding_step(&mut self, step: i64) {
|
||||
|
|
@ -94,6 +98,48 @@ impl State {
|
|||
self.save();
|
||||
}
|
||||
|
||||
/// First-boot day-zero wizard (timezone / network hint / updates /
|
||||
/// snapshot). Independent of the live desktop tour so an existing user
|
||||
/// who already finished onboarding is never pulled back into setup.
|
||||
pub fn day_zero_completed(&self) -> bool {
|
||||
self.doc
|
||||
.get("day_zero")
|
||||
.and_then(|t| t.get("completed"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn set_day_zero_completed(&mut self, completed: bool) {
|
||||
self.doc["day_zero"]["completed"] = value(completed);
|
||||
self.save();
|
||||
}
|
||||
|
||||
pub fn day_zero_step(&self) -> i64 {
|
||||
self.doc
|
||||
.get("day_zero")
|
||||
.and_then(|t| t.get("step"))
|
||||
.and_then(|v| v.as_integer())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn set_day_zero_step(&mut self, step: i64) {
|
||||
self.doc["day_zero"]["step"] = value(step);
|
||||
self.save();
|
||||
}
|
||||
|
||||
pub fn day_zero_snapshot_attempted(&self) -> bool {
|
||||
self.doc
|
||||
.get("day_zero")
|
||||
.and_then(|t| t.get("snapshot_attempted"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn set_day_zero_snapshot_attempted(&mut self, attempted: bool) {
|
||||
self.doc["day_zero"]["snapshot_attempted"] = value(attempted);
|
||||
self.save();
|
||||
}
|
||||
|
||||
/// Set the instant before `services::hyprland::rebind_temp` is called for
|
||||
/// a tour step with no compositor-observable signal (e.g. the screenshot
|
||||
/// step), cleared the instant after reverting. If breadhelp is killed
|
||||
|
|
@ -101,8 +147,18 @@ impl State {
|
|||
/// pointing at a chained `--tour-event` ping and self-heals it before
|
||||
/// the user can be surprised by a stray tour popup — see `ui::tour`.
|
||||
pub fn pending_rebind(&self) -> Option<(String, String)> {
|
||||
let key = self.doc.get("tour")?.get("pending_rebind_key")?.as_str()?.to_string();
|
||||
let original = self.doc.get("tour")?.get("pending_rebind_original")?.as_str()?.to_string();
|
||||
let key = self
|
||||
.doc
|
||||
.get("tour")?
|
||||
.get("pending_rebind_key")?
|
||||
.as_str()?
|
||||
.to_string();
|
||||
let original = self
|
||||
.doc
|
||||
.get("tour")?
|
||||
.get("pending_rebind_original")?
|
||||
.as_str()?
|
||||
.to_string();
|
||||
Some((key, original))
|
||||
}
|
||||
|
||||
|
|
@ -121,7 +177,12 @@ impl State {
|
|||
}
|
||||
|
||||
pub fn mode(&self) -> Mode {
|
||||
let s = self.doc.get("general").and_then(|t| t.get("mode")).and_then(|v| v.as_str()).unwrap_or("normal");
|
||||
let s = self
|
||||
.doc
|
||||
.get("general")
|
||||
.and_then(|t| t.get("mode"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("normal");
|
||||
Mode::from_str(s)
|
||||
}
|
||||
|
||||
|
|
@ -145,14 +206,20 @@ mod tests {
|
|||
// path save_doc uses (via tomlcfg::save_doc), at the file-layout
|
||||
// breadhelp actually writes to — the deeper backup/no-leftover-tmp
|
||||
// behavior itself is covered by bread-utils' own test suite.
|
||||
let dir = std::env::temp_dir().join(format!("breadhelp-atomic-write-test-{}", std::process::id()));
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"breadhelp-atomic-write-test-{}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("state.toml");
|
||||
let backup = dir.join("state.toml.bak");
|
||||
|
||||
bread_utils::atomic::write_atomic_backed_up(&path, "first").unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "first");
|
||||
assert!(!backup.exists(), "no backup should be made when there's nothing to back up yet");
|
||||
assert!(
|
||||
!backup.exists(),
|
||||
"no backup should be made when there's nothing to back up yet"
|
||||
);
|
||||
|
||||
bread_utils::atomic::write_atomic_backed_up(&path, "second").unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "second");
|
||||
|
|
@ -164,14 +231,20 @@ mod tests {
|
|||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.filter(|n| n.contains(".tmp."))
|
||||
.collect();
|
||||
assert!(leftover_tmp.is_empty(), "temp file should be renamed away, not left behind: {leftover_tmp:?}");
|
||||
assert!(
|
||||
leftover_tmp.is_empty(),
|
||||
"temp file should be renamed away, not left behind: {leftover_tmp:?}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_doc_then_load_doc_round_trips_state() {
|
||||
let dir = std::env::temp_dir().join(format!("breadhelp-state-roundtrip-test-{}", std::process::id()));
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"breadhelp-state-roundtrip-test-{}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("state.toml");
|
||||
|
||||
|
|
@ -181,8 +254,50 @@ mod tests {
|
|||
save_doc(&path, &doc);
|
||||
|
||||
let loaded = load_doc(&path);
|
||||
assert_eq!(loaded.get("onboarding").and_then(|t| t.get("completed")).and_then(|v| v.as_bool()), Some(true));
|
||||
assert_eq!(loaded.get("general").and_then(|t| t.get("mode")).and_then(|v| v.as_str()), Some("dad"));
|
||||
assert_eq!(
|
||||
loaded
|
||||
.get("onboarding")
|
||||
.and_then(|t| t.get("completed"))
|
||||
.and_then(|v| v.as_bool()),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
loaded
|
||||
.get("general")
|
||||
.and_then(|t| t.get("mode"))
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("dad")
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn day_zero_defaults_incomplete_and_round_trips() {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("breadhelp-day-zero-state-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("state.toml");
|
||||
|
||||
let mut state = State {
|
||||
doc: DocumentMut::default(),
|
||||
path: path.clone(),
|
||||
};
|
||||
assert!(!state.day_zero_completed());
|
||||
assert_eq!(state.day_zero_step(), 0);
|
||||
assert!(!state.day_zero_snapshot_attempted());
|
||||
|
||||
state.set_day_zero_step(3);
|
||||
state.set_day_zero_snapshot_attempted(true);
|
||||
state.set_day_zero_completed(true);
|
||||
|
||||
let loaded = State {
|
||||
doc: load_doc(&path),
|
||||
path,
|
||||
};
|
||||
assert!(loaded.day_zero_completed());
|
||||
assert_eq!(loaded.day_zero_step(), 3);
|
||||
assert!(loaded.day_zero_snapshot_attempted());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,8 @@ window.wizard-dialog headerbar { \
|
|||
border-bottom: 1px solid alpha(@on-bg, 0.08); \
|
||||
box-shadow: none; \
|
||||
}\n\
|
||||
/* ui::day_zero — timezone list is a tall boxed list inside the Welcome tab. */\n\
|
||||
.day-zero list { margin-top: 4px; }\n\
|
||||
";
|
||||
|
||||
thread_local! {
|
||||
|
|
|
|||
665
src/ui/day_zero.rs
Normal file
665
src/ui/day_zero.rs
Normal file
|
|
@ -0,0 +1,665 @@
|
|||
//! In-window day-zero wizard for first boot / `--autostart`.
|
||||
//!
|
||||
//! Not a second app: this is one extra tab on the existing help window so
|
||||
//! Learn/Ask stay reachable when bakery `content.tar.gz` is installed.
|
||||
//! Existing users who already finished the desktop tour never see it
|
||||
//! (`needs_wizard` is false when `onboarding.completed` is set).
|
||||
//!
|
||||
//! Finish (or skip) hands off to the live tour — the previous first-run
|
||||
//! destination — with the Home cheatsheet already showing underneath.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::process::Command;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{
|
||||
Align, Box as GBox, Button, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow,
|
||||
SearchEntry, SelectionMode, Stack,
|
||||
};
|
||||
|
||||
use crate::config::State;
|
||||
use crate::content::keybinds::Keybind;
|
||||
use crate::content::{markdown, ContentStore};
|
||||
use crate::services::exec;
|
||||
|
||||
const PAGE_NAME: &str = "day-zero";
|
||||
const STEP_COUNT: usize = 6;
|
||||
const WELCOME_GUIDE_CATEGORY: &str = "getting-started";
|
||||
const WELCOME_GUIDE_ID: &str = "01-what-is-bos";
|
||||
const FALLBACK_WELCOME: &str = "\
|
||||
# Welcome to BOS
|
||||
|
||||
BOS (the Bread Operating System) is a complete Hyprland desktop with the \
|
||||
bread ecosystem preinstalled: a bar, a launcher, notes, Wi-Fi profiles, and a \
|
||||
settings app that needs no config files.
|
||||
|
||||
This short setup gets the clock, network, and updates in place. Use the tabs \
|
||||
above any time to browse the full guide.
|
||||
";
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Step {
|
||||
Welcome,
|
||||
Timezone,
|
||||
Network,
|
||||
Updates,
|
||||
Snapshot,
|
||||
Finish,
|
||||
}
|
||||
|
||||
const STEPS: [Step; STEP_COUNT] = [
|
||||
Step::Welcome,
|
||||
Step::Timezone,
|
||||
Step::Network,
|
||||
Step::Updates,
|
||||
Step::Snapshot,
|
||||
Step::Finish,
|
||||
];
|
||||
|
||||
struct Wizard {
|
||||
content: GBox,
|
||||
welcome_markdown: String,
|
||||
binds: Vec<Keybind>,
|
||||
step: RefCell<usize>,
|
||||
finished: Cell<bool>,
|
||||
on_finished: Box<dyn Fn()>,
|
||||
}
|
||||
|
||||
/// First boot only: skip when the wizard already ran *or* the user already
|
||||
/// finished the older tour-only onboarding (so upgrades don't re-prompt).
|
||||
pub fn needs_wizard() -> bool {
|
||||
needs_wizard_from(
|
||||
State::load().day_zero_completed(),
|
||||
State::load().onboarding_completed(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn needs_wizard_from(day_zero_done: bool, onboarding_done: bool) -> bool {
|
||||
!day_zero_done && !onboarding_done
|
||||
}
|
||||
|
||||
/// Add (or focus) the Welcome tab and show it. Idempotent across D-Bus
|
||||
/// re-activation while the wizard is already up.
|
||||
pub fn attach(
|
||||
stack: &Stack,
|
||||
store: &ContentStore,
|
||||
binds: &[Keybind],
|
||||
on_finished: impl Fn() + 'static,
|
||||
) {
|
||||
if stack.child_by_name(PAGE_NAME).is_some() {
|
||||
stack.set_visible_child_name(PAGE_NAME);
|
||||
return;
|
||||
}
|
||||
|
||||
let start = (State::load().day_zero_step() as usize).min(STEP_COUNT - 1);
|
||||
let welcome_markdown = store
|
||||
.guide(WELCOME_GUIDE_CATEGORY, WELCOME_GUIDE_ID)
|
||||
.map(|g| store.body(g, State::load().mode().is_simplified()))
|
||||
.filter(|body| !body.trim().is_empty())
|
||||
.unwrap_or_else(|| FALLBACK_WELCOME.to_string());
|
||||
|
||||
let content = GBox::new(Orientation::Vertical, 16);
|
||||
content.add_css_class("view-content");
|
||||
content.add_css_class("day-zero");
|
||||
|
||||
let wizard = Rc::new(Wizard {
|
||||
content: content.clone(),
|
||||
welcome_markdown,
|
||||
binds: binds.to_vec(),
|
||||
step: RefCell::new(start),
|
||||
finished: Cell::new(false),
|
||||
on_finished: Box::new(on_finished),
|
||||
});
|
||||
wizard.render();
|
||||
|
||||
let page = ScrolledWindow::builder()
|
||||
.child(&content)
|
||||
.vexpand(true)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
stack.add_titled(&page, Some(PAGE_NAME), "Welcome");
|
||||
stack.set_visible_child_name(PAGE_NAME);
|
||||
}
|
||||
|
||||
pub(crate) fn pick_network_tool(
|
||||
has_settings: bool,
|
||||
has_nm: bool,
|
||||
) -> Option<(&'static str, &'static str, &'static [&'static str])> {
|
||||
if has_settings {
|
||||
Some((
|
||||
"Open network settings",
|
||||
"bos-settings",
|
||||
&["--page", "network"],
|
||||
))
|
||||
} else if has_nm {
|
||||
Some(("Open network editor", "nm-connection-editor", &[]))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn pick_updates_tool(
|
||||
has_settings: bool,
|
||||
) -> Option<(&'static str, &'static str, &'static [&'static str])> {
|
||||
if has_settings {
|
||||
Some(("Open BOS Settings", "bos-settings", &["--page", "packages"]))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_safe_timezone_name(tz: &str) -> bool {
|
||||
!tz.is_empty()
|
||||
&& tz.len() < 128
|
||||
&& tz
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '/' | '+' | '-' | '.'))
|
||||
}
|
||||
|
||||
fn parse_timezone_property(stdout: &str) -> Option<String> {
|
||||
stdout
|
||||
.trim()
|
||||
.strip_prefix("Timezone=")
|
||||
.map(str::to_string)
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
fn parse_timezone_list(stdout: &str) -> Vec<String> {
|
||||
stdout
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Wizard {
|
||||
fn current_step(&self) -> Step {
|
||||
STEPS[*self.step.borrow()]
|
||||
}
|
||||
|
||||
fn go(self: &Rc<Self>, step: usize) {
|
||||
let step = step.min(STEP_COUNT - 1);
|
||||
*self.step.borrow_mut() = step;
|
||||
let mut state = State::load();
|
||||
state.set_day_zero_step(step as i64);
|
||||
self.render();
|
||||
}
|
||||
|
||||
fn finish(self: &Rc<Self>) {
|
||||
if self.finished.replace(true) {
|
||||
return;
|
||||
}
|
||||
let mut state = State::load();
|
||||
state.set_day_zero_completed(true);
|
||||
(self.on_finished)();
|
||||
}
|
||||
|
||||
fn clear(&self) {
|
||||
while let Some(child) = self.content.first_child() {
|
||||
self.content.remove(&child);
|
||||
}
|
||||
}
|
||||
|
||||
fn render(self: &Rc<Self>) {
|
||||
self.clear();
|
||||
let index = *self.step.borrow();
|
||||
let step = self.current_step();
|
||||
|
||||
let title = Label::new(Some(step_title(step)));
|
||||
title.add_css_class("page-title");
|
||||
title.set_xalign(0.0);
|
||||
self.content.append(&title);
|
||||
|
||||
match step {
|
||||
Step::Welcome => self.render_welcome(),
|
||||
Step::Timezone => self.render_timezone(),
|
||||
Step::Network => self.render_network(),
|
||||
Step::Updates => self.render_updates(),
|
||||
Step::Snapshot => self.render_snapshot(),
|
||||
Step::Finish => self.render_finish(),
|
||||
}
|
||||
|
||||
let counter = Label::new(Some(&format!("Step {} of {STEP_COUNT}", index + 1)));
|
||||
counter.add_css_class("dim-label");
|
||||
counter.set_xalign(0.0);
|
||||
self.content.append(&counter);
|
||||
|
||||
let nav = GBox::new(Orientation::Horizontal, 8);
|
||||
let skip = Button::with_label("Skip setup");
|
||||
skip.add_css_class("flat");
|
||||
let this = self.clone();
|
||||
skip.connect_clicked(move |_| this.finish());
|
||||
|
||||
let back = Button::with_label("Back");
|
||||
back.set_sensitive(index > 0);
|
||||
let this = self.clone();
|
||||
back.connect_clicked(move |_| {
|
||||
let i = *this.step.borrow();
|
||||
if i > 0 {
|
||||
this.go(i - 1);
|
||||
}
|
||||
});
|
||||
|
||||
let spacer = GBox::new(Orientation::Horizontal, 0);
|
||||
spacer.set_hexpand(true);
|
||||
|
||||
let is_last = index + 1 >= STEP_COUNT;
|
||||
let next = Button::with_label(if is_last { "Start tour" } else { "Next" });
|
||||
next.add_css_class("suggested-action");
|
||||
let this = self.clone();
|
||||
next.connect_clicked(move |_| {
|
||||
let i = *this.step.borrow();
|
||||
if i + 1 >= STEP_COUNT {
|
||||
this.finish();
|
||||
} else {
|
||||
this.go(i + 1);
|
||||
}
|
||||
});
|
||||
|
||||
nav.append(&skip);
|
||||
nav.append(&spacer);
|
||||
nav.append(&back);
|
||||
nav.append(&next);
|
||||
self.content.append(&nav);
|
||||
}
|
||||
|
||||
fn render_welcome(self: &Rc<Self>) {
|
||||
let blurb = Label::new(Some(
|
||||
"A few things before the desktop tour. The other tabs stay available if you want to look around.",
|
||||
));
|
||||
blurb.set_wrap(true);
|
||||
blurb.set_xalign(0.0);
|
||||
self.content.append(&blurb);
|
||||
self.content.append(&markdown::render(
|
||||
&markdown::parse(&self.welcome_markdown),
|
||||
&self.binds,
|
||||
));
|
||||
}
|
||||
|
||||
fn render_timezone(self: &Rc<Self>) {
|
||||
let (current, zones) = load_timezones();
|
||||
let intro = if zones.is_empty() {
|
||||
"Couldn't list timezones (is timedatectl available?). You can set this later from BOS Settings."
|
||||
} else {
|
||||
"The clock should match where you are. Applying a change may ask for your password."
|
||||
};
|
||||
let blurb = Label::new(Some(intro));
|
||||
blurb.set_wrap(true);
|
||||
blurb.set_xalign(0.0);
|
||||
self.content.append(&blurb);
|
||||
|
||||
let current_lbl = Label::new(Some(&if current.is_empty() {
|
||||
"Current timezone: unknown".into()
|
||||
} else {
|
||||
format!("Current timezone: {current}")
|
||||
}));
|
||||
current_lbl.set_xalign(0.0);
|
||||
current_lbl.add_css_class("dim-label");
|
||||
self.content.append(¤t_lbl);
|
||||
|
||||
if zones.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let search = SearchEntry::new();
|
||||
search.set_placeholder_text(Some("Search timezones"));
|
||||
search.set_hexpand(true);
|
||||
self.content.append(&search);
|
||||
|
||||
let list = ListBox::new();
|
||||
list.set_selection_mode(SelectionMode::Single);
|
||||
list.add_css_class("boxed-list");
|
||||
for tz in &zones {
|
||||
let row = ListBoxRow::new();
|
||||
row.set_widget_name(tz);
|
||||
let lbl = Label::new(Some(tz));
|
||||
lbl.set_xalign(0.0);
|
||||
lbl.set_margin_top(4);
|
||||
lbl.set_margin_bottom(4);
|
||||
lbl.set_margin_start(8);
|
||||
row.set_child(Some(&lbl));
|
||||
list.append(&row);
|
||||
if tz == ¤t {
|
||||
list.select_row(Some(&row));
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let list = list.clone();
|
||||
search.connect_search_changed(move |entry| {
|
||||
let q = entry.text().to_lowercase();
|
||||
let mut i = 0;
|
||||
let mut first_visible: Option<ListBoxRow> = None;
|
||||
loop {
|
||||
let Some(row) = list.row_at_index(i) else {
|
||||
break;
|
||||
};
|
||||
let name = row.widget_name();
|
||||
let visible = q.is_empty() || name.to_lowercase().contains(&q);
|
||||
row.set_visible(visible);
|
||||
if visible && first_visible.is_none() {
|
||||
first_visible = Some(row);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if let Some(row) = first_visible {
|
||||
list.select_row(Some(&row));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let scroller = ScrolledWindow::builder()
|
||||
.child(&list)
|
||||
.min_content_height(180)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
self.content.append(&scroller);
|
||||
|
||||
let status = Label::new(None);
|
||||
status.set_xalign(0.0);
|
||||
status.add_css_class("dim-label");
|
||||
|
||||
let apply = Button::with_label("Apply timezone");
|
||||
apply.set_halign(Align::Start);
|
||||
apply.add_css_class("suggested-action");
|
||||
let current_lbl = current_lbl.clone();
|
||||
let status_for_click = status.clone();
|
||||
apply.connect_clicked(move |_| {
|
||||
let Some(row) = list.selected_row() else {
|
||||
status_for_click.set_label("Pick a timezone first.");
|
||||
return;
|
||||
};
|
||||
let tz = row.widget_name().to_string();
|
||||
if !is_safe_timezone_name(&tz) {
|
||||
status_for_click.set_label("That timezone name doesn't look valid.");
|
||||
return;
|
||||
}
|
||||
status_for_click.set_label("Applying\u{2026}");
|
||||
let status = status_for_click.clone();
|
||||
let current_lbl = current_lbl.clone();
|
||||
apply_timezone(tz, move |msg, ok, tz| {
|
||||
status.set_label(&msg);
|
||||
if ok {
|
||||
current_lbl.set_label(&format!("Current timezone: {tz}"));
|
||||
}
|
||||
});
|
||||
});
|
||||
self.content.append(&apply);
|
||||
self.content.append(&status);
|
||||
}
|
||||
|
||||
fn render_network(self: &Rc<Self>) {
|
||||
let blurb = Label::new(Some(
|
||||
"Connect to Wi-Fi in BOS Settings (or the NetworkManager editor). This window does not manage networks itself.",
|
||||
));
|
||||
blurb.set_wrap(true);
|
||||
blurb.set_xalign(0.0);
|
||||
self.content.append(&blurb);
|
||||
|
||||
if let Some((label, prog, args)) = pick_network_tool(
|
||||
exec::command_exists("bos-settings"),
|
||||
exec::command_exists("nm-connection-editor"),
|
||||
) {
|
||||
let btn = Button::with_label(label);
|
||||
btn.set_halign(Align::Start);
|
||||
let prog = prog.to_string();
|
||||
let args: Vec<String> = args.iter().map(|s| (*s).to_string()).collect();
|
||||
btn.connect_clicked(move |_| {
|
||||
let argv: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
exec::run_argv(&prog, &argv);
|
||||
});
|
||||
self.content.append(&btn);
|
||||
} else {
|
||||
let hint = Label::new(Some(
|
||||
"No network UI found. Run `breadcrumbs` or `nmcli` in a terminal to connect.",
|
||||
));
|
||||
hint.set_wrap(true);
|
||||
hint.set_xalign(0.0);
|
||||
hint.add_css_class("dim-label");
|
||||
self.content.append(&hint);
|
||||
}
|
||||
|
||||
if exec::command_exists("breadcrumbs") {
|
||||
let crumbs = Label::new(Some(
|
||||
"Location profiles (home / work / away) are handled by breadcrumbs once a network is saved.",
|
||||
));
|
||||
crumbs.set_wrap(true);
|
||||
crumbs.set_xalign(0.0);
|
||||
crumbs.add_css_class("dim-label");
|
||||
self.content.append(&crumbs);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_updates(self: &Rc<Self>) {
|
||||
let blurb = Label::new(Some(
|
||||
"When you're online, check for system and bread-ecosystem updates. Nothing is installed from this step.",
|
||||
));
|
||||
blurb.set_wrap(true);
|
||||
blurb.set_xalign(0.0);
|
||||
self.content.append(&blurb);
|
||||
|
||||
if let Some((label, prog, args)) = pick_updates_tool(exec::command_exists("bos-settings")) {
|
||||
let btn = Button::with_label(label);
|
||||
btn.set_halign(Align::Start);
|
||||
let prog = prog.to_string();
|
||||
let args: Vec<String> = args.iter().map(|s| (*s).to_string()).collect();
|
||||
btn.connect_clicked(move |_| {
|
||||
let argv: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
exec::run_argv(&prog, &argv);
|
||||
});
|
||||
self.content.append(&btn);
|
||||
}
|
||||
|
||||
let hint = Label::new(Some(if exec::command_exists("bos-settings") {
|
||||
"Or run `bos-update` in a terminal."
|
||||
} else {
|
||||
"BOS Settings isn't installed. Run `bos-update` in a terminal when you're ready."
|
||||
}));
|
||||
hint.set_wrap(true);
|
||||
hint.set_xalign(0.0);
|
||||
hint.add_css_class("dim-label");
|
||||
self.content.append(&hint);
|
||||
}
|
||||
|
||||
fn render_snapshot(self: &Rc<Self>) {
|
||||
let available = exec::command_exists("snapper");
|
||||
let blurb = Label::new(Some(if available {
|
||||
"A snapshot is a restore point. We'll try to create one named day-zero — if this machine doesn't allow it, that's fine."
|
||||
} else {
|
||||
"snapper isn't available on this system, so no first-boot snapshot will be created. You can skip this."
|
||||
}));
|
||||
blurb.set_wrap(true);
|
||||
blurb.set_xalign(0.0);
|
||||
self.content.append(&blurb);
|
||||
|
||||
let status = Label::new(None);
|
||||
status.set_xalign(0.0);
|
||||
status.add_css_class("dim-label");
|
||||
|
||||
if available {
|
||||
let retry = Button::with_label("Create snapshot");
|
||||
retry.set_halign(Align::Start);
|
||||
let status_click = status.clone();
|
||||
retry.connect_clicked(move |_| {
|
||||
status_click.set_label("Creating snapshot\u{2026}");
|
||||
let status = status_click.clone();
|
||||
create_day_zero_snapshot(move |ok| {
|
||||
status.set_label(snapshot_status(ok));
|
||||
});
|
||||
});
|
||||
self.content.append(&retry);
|
||||
|
||||
if !State::load().day_zero_snapshot_attempted() {
|
||||
let mut state = State::load();
|
||||
state.set_day_zero_snapshot_attempted(true);
|
||||
status.set_label("Creating snapshot\u{2026}");
|
||||
let status = status.clone();
|
||||
create_day_zero_snapshot(move |ok| {
|
||||
status.set_label(snapshot_status(ok));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
self.content.append(&status);
|
||||
}
|
||||
|
||||
fn render_finish(self: &Rc<Self>) {
|
||||
let blurb = Label::new(Some(
|
||||
"You're set. Next is a short tour of the real desktop. Super+/ opens this help center any time — the Home tab is the keybind cheatsheet.",
|
||||
));
|
||||
blurb.set_wrap(true);
|
||||
blurb.set_xalign(0.0);
|
||||
self.content.append(&blurb);
|
||||
}
|
||||
}
|
||||
|
||||
fn step_title(step: Step) -> &'static str {
|
||||
match step {
|
||||
Step::Welcome => "Welcome to BOS",
|
||||
Step::Timezone => "Timezone",
|
||||
Step::Network => "Network",
|
||||
Step::Updates => "Updates",
|
||||
Step::Snapshot => "Snapshot",
|
||||
Step::Finish => "You're ready",
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_status(ok: bool) -> &'static str {
|
||||
if ok {
|
||||
"Created snapshot “day-zero”."
|
||||
} else {
|
||||
"Couldn't create a snapshot (that's OK — you can do this later from Settings)."
|
||||
}
|
||||
}
|
||||
|
||||
fn load_timezones() -> (String, Vec<String>) {
|
||||
let current = Command::new("timedatectl")
|
||||
.args(["show", "--property=Timezone"])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.and_then(|o| parse_timezone_property(&String::from_utf8_lossy(&o.stdout)))
|
||||
.unwrap_or_default();
|
||||
let zones = Command::new("timedatectl")
|
||||
.arg("list-timezones")
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| parse_timezone_list(&String::from_utf8_lossy(&o.stdout)))
|
||||
.unwrap_or_default();
|
||||
(current, zones)
|
||||
}
|
||||
|
||||
fn apply_timezone(tz: String, on_done: impl Fn(String, bool, String) + 'static) {
|
||||
let (tx, rx) = async_channel::bounded(1);
|
||||
std::thread::spawn(move || {
|
||||
let direct = Command::new("timedatectl")
|
||||
.args(["set-timezone", &tz])
|
||||
.status();
|
||||
if direct.map(|s| s.success()).unwrap_or(false) {
|
||||
let _ = tx.send_blocking((format!("Timezone set to {tz}."), true, tz));
|
||||
return;
|
||||
}
|
||||
if !exec::command_exists("pkexec") {
|
||||
let _ = tx.send_blocking((
|
||||
"Need permission to change the timezone, and pkexec isn't available.".into(),
|
||||
false,
|
||||
tz,
|
||||
));
|
||||
return;
|
||||
}
|
||||
let elevated = Command::new("pkexec")
|
||||
.args(["timedatectl", "set-timezone", &tz])
|
||||
.status();
|
||||
if elevated.map(|s| s.success()).unwrap_or(false) {
|
||||
let _ = tx.send_blocking((format!("Timezone set to {tz}."), true, tz));
|
||||
} else {
|
||||
let _ = tx.send_blocking((
|
||||
"Couldn't change the timezone. You can try again from BOS Settings.".into(),
|
||||
false,
|
||||
tz,
|
||||
));
|
||||
}
|
||||
});
|
||||
glib::MainContext::default().spawn_local(async move {
|
||||
if let Ok((msg, ok, tz)) = rx.recv().await {
|
||||
on_done(msg, ok, tz);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn create_day_zero_snapshot(on_done: impl Fn(bool) + 'static) {
|
||||
exec::run_argv_reporting(
|
||||
"snapper",
|
||||
&["-c", "root", "create", "-d", "day-zero"],
|
||||
on_done,
|
||||
);
|
||||
}
|
||||
|
||||
/// Used by `window` after the wizard finishes: drop the Welcome tab so the
|
||||
/// help center is just Home/Learn/Ask again.
|
||||
pub fn detach(stack: &Stack) {
|
||||
if let Some(child) = stack.child_by_name(PAGE_NAME) {
|
||||
stack.remove(&child);
|
||||
}
|
||||
if stack.child_by_name("home").is_some() {
|
||||
stack.set_visible_child_name("home");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn existing_onboarding_skips_wizard() {
|
||||
assert!(!needs_wizard_from(false, true));
|
||||
assert!(!needs_wizard_from(true, true));
|
||||
assert!(!needs_wizard_from(true, false));
|
||||
assert!(needs_wizard_from(false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_prefers_bos_settings_then_nm() {
|
||||
let (label, prog, args) = pick_network_tool(true, true).unwrap();
|
||||
assert_eq!(prog, "bos-settings");
|
||||
assert_eq!(args, &["--page", "network"]);
|
||||
assert!(label.contains("network"));
|
||||
let (_, prog, _) = pick_network_tool(false, true).unwrap();
|
||||
assert_eq!(prog, "nm-connection-editor");
|
||||
assert!(pick_network_tool(false, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updates_tool_only_when_settings_exists() {
|
||||
assert!(pick_updates_tool(true).is_some());
|
||||
assert!(pick_updates_tool(false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timezone_names_reject_shell_metacharacters() {
|
||||
assert!(is_safe_timezone_name("Australia/Perth"));
|
||||
assert!(is_safe_timezone_name("Etc/GMT+8"));
|
||||
assert!(is_safe_timezone_name("America/Argentina/ComodRivadavia"));
|
||||
assert!(!is_safe_timezone_name(""));
|
||||
assert!(!is_safe_timezone_name("America/New York"));
|
||||
assert!(!is_safe_timezone_name("x; rm -rf /"));
|
||||
assert!(!is_safe_timezone_name("$(reboot)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_timedatectl_show_and_list() {
|
||||
assert_eq!(
|
||||
parse_timezone_property("Timezone=Australia/Perth\n").as_deref(),
|
||||
Some("Australia/Perth")
|
||||
);
|
||||
assert_eq!(parse_timezone_property("Timezone=\n"), None);
|
||||
assert_eq!(
|
||||
parse_timezone_list("Africa/Abidjan\nAustralia/Perth\n\nUTC\n"),
|
||||
vec!["Africa/Abidjan", "Australia/Perth", "UTC"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
pub mod ask;
|
||||
pub mod day_zero;
|
||||
pub mod guide_view;
|
||||
pub mod home;
|
||||
pub mod keybind_viewer;
|
||||
|
|
|
|||
|
|
@ -6,10 +6,11 @@ use gtk4::{Application, ApplicationWindow, Box as GBox, Orientation, Stack, Stac
|
|||
|
||||
use crate::cli::Action;
|
||||
use crate::config::State;
|
||||
use crate::content::keybinds::Keybind;
|
||||
use crate::content::{keybinds, ContentStore};
|
||||
|
||||
use super::home::Home;
|
||||
use super::{ask, learn, modes, tabs, tour};
|
||||
use super::{ask, day_zero, learn, modes, tabs, tour};
|
||||
|
||||
const DEFAULT_TAB: &str = "home";
|
||||
|
||||
|
|
@ -17,6 +18,8 @@ struct Handle {
|
|||
window: ApplicationWindow,
|
||||
home: Home,
|
||||
stack: Stack,
|
||||
store: Rc<ContentStore>,
|
||||
binds: Rc<Vec<Keybind>>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
|
|
@ -69,16 +72,39 @@ pub fn present(app: &Application, action: Action) {
|
|||
}
|
||||
|
||||
// Every-login autostart builds the window (so the app is ready to
|
||||
// respond to SUPER+/ instantly) but only starts the tour / pops the
|
||||
// window open on a genuine first run — never on later logins.
|
||||
if action.autostart && !State::load().onboarding_completed() {
|
||||
// respond to SUPER+/ instantly) but only shows UI on a genuine
|
||||
// first run — never on later logins. First run is now the in-window
|
||||
// day-zero wizard; the live tour starts when that finishes.
|
||||
let onboarded = State::load().onboarding_completed();
|
||||
if action.autostart && onboarded {
|
||||
return;
|
||||
}
|
||||
|
||||
if day_zero::needs_wizard() {
|
||||
let stack = handle.stack.clone();
|
||||
let display = display.clone();
|
||||
day_zero::attach(&handle.stack, &handle.store, &handle.binds, move || {
|
||||
// Drop the Welcome tab after the click handler returns —
|
||||
// removing it here would destroy the Skip/Finish button
|
||||
// mid-signal.
|
||||
let stack = stack.clone();
|
||||
let display = display.clone();
|
||||
glib::idle_add_local_once(move || {
|
||||
day_zero::detach(&stack);
|
||||
if !State::load().onboarding_completed() {
|
||||
tour::start(&display);
|
||||
}
|
||||
});
|
||||
});
|
||||
show_window(&handle.window, action.autostart);
|
||||
return;
|
||||
}
|
||||
|
||||
if action.autostart && !onboarded {
|
||||
tour::start(&display);
|
||||
return;
|
||||
}
|
||||
let silent_autostart = action.autostart && State::load().onboarding_completed();
|
||||
if !silent_autostart {
|
||||
show_window(&handle.window, action.autostart);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -126,8 +152,16 @@ fn build(app: &Application) -> Handle {
|
|||
}
|
||||
});
|
||||
stack.add_titled(&home.root, Some("home"), "Home");
|
||||
stack.add_titled(&learn::build(&store, &binds, state.mode()), Some("learn"), "Learn");
|
||||
stack.add_titled(&ask::build(store.clone(), binds.clone(), state.mode()), Some("ask"), "Ask");
|
||||
stack.add_titled(
|
||||
&learn::build(&store, &binds, state.mode()),
|
||||
Some("learn"),
|
||||
"Learn",
|
||||
);
|
||||
stack.add_titled(
|
||||
&ask::build(store.clone(), binds.clone(), state.mode()),
|
||||
Some("ask"),
|
||||
"Ask",
|
||||
);
|
||||
stack.set_visible_child_name(DEFAULT_TAB);
|
||||
|
||||
let switcher = tabs::build(&stack);
|
||||
|
|
@ -138,12 +172,17 @@ fn build(app: &Application) -> Handle {
|
|||
|
||||
window.set_child(Some(&content_vbox));
|
||||
// Deliberately not presented here — `present()` (the caller) decides
|
||||
// whether this initial build should actually be shown (see
|
||||
// `silent_autostart` above), so a from-cold every-login autostart with
|
||||
// onboarding already complete builds a ready-but-hidden window instead
|
||||
// of flashing it open. On a genuine first run, the tour overlay runs
|
||||
// independently of this window (see `tour::start`) — it never needs to
|
||||
// be shown at all until the user explicitly opens it later.
|
||||
// whether this initial build should actually be shown, so a from-cold
|
||||
// every-login autostart with onboarding already complete builds a
|
||||
// ready-but-hidden window instead of flashing it open. First run
|
||||
// presents the window with the day-zero wizard; the tour overlay
|
||||
// starts after that finishes.
|
||||
|
||||
Handle { window, home, stack }
|
||||
Handle {
|
||||
window,
|
||||
home,
|
||||
stack,
|
||||
store,
|
||||
binds,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue