From d9012c8e72b47989627e570a93c334d5086e7ac4 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 23:11:28 +0800 Subject: [PATCH 01/10] Honor bread.command.help.open via breadhelp listen --- EVENTS.md | 41 +++++++++++++++++---- src/listen.rs | 84 ++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 6 +++ src/services/breadd.rs | 2 +- 4 files changed, 124 insertions(+), 9 deletions(-) create mode 100644 src/listen.rs diff --git a/EVENTS.md b/EVENTS.md index 6bdd1c8..521d1d8 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -9,14 +9,17 @@ follows. App id: **`help`**. Transport: `bread-utils`'s `bread_client` module (feature `bread-client`) — breadhelp links it directly. Each `emit` is -its own short-lived connection. There is no long-running command -subscription. +its own short-lived connection. Command verbs are only received while +`breadhelp listen` is running — that process holds the +`bread.command.help.**` subscription open. ## Events published (`bread.help.*`) | Event | Data | When | |-------|------|------| | `bread.help.opened` | `{ "autostart": bool }` | The main help window is presented (`ApplicationWindow::present`). `autostart` is `true` when that invocation was launched with `--autostart`. | +| `bread.help.open.done` | `{}` | `bread.command.help.open` was received and `breadhelp` was spawned. This is the command confirmation, not proof the window mapped — the spawned process is the same no-args invocation as SUPER+/. | +| `bread.help.open.failed` | `{ "error": "" }` | `bread.command.help.open` was received but this binary could not be started. | Not emitted when: @@ -29,14 +32,36 @@ Not emitted when: ## Commands honored (`bread.command.help.*`) -None. Opening the help center, starting the tour, and applying one-click -fixes already exist as local CLI / UI paths. There is no command -subscription, and no verb is stubbed as a no-op. If breadhelp later grows -a bus verb that maps to real behavior, add it then. +These are only received while `breadhelp listen` is running. Publishing a +command with no subscriber is a silent no-op — that is the documented +bread convention, not a breadhelp bug. + +| Verb | Data | Effect | +|------|------|--------| +| `open` | none | Same as running `breadhelp` with no flags: present the main help window (GApplication forwards to an already-running primary instance). Emits `bread.help.open.done` / `.failed`. | + +```lua +bread.spawn(function() + bread.emit("bread.command.help.open") + bread.wait("bread.help.open.done", { timeout = 5000 }) +end) +``` + +### Not implemented: extra verbs + +There is no `onboard` / `tour` / `suggest` command verb. Those already +exist as local CLI flags (`--onboard`, `--tour-event`, `--suggest`). +If/when a bus verb maps to real extra behavior, add it then — do not +stub one as a no-op ahead of it. ## Fail-safe behavior - If breadd isn't installed or isn't running, `emit` is a silent no-op - (`BreadClient::emit` never blocks or errors the caller) — the help + (`BreadClient::emit` never blocks or errors the caller) and the + command subscription simply never receives anything — the help center, tour, and screenshots are entirely unaffected. -- There is no command subscription to reconnect. +- If breadd restarts, the command subscription reconnects automatically + (`BreadClient::subscribe`'s background thread has its own backoff + loop); no restart of `breadhelp listen` is needed. +- If `breadhelp listen` is not running, commands are a graceful no-op at + the bus (no subscriber). The CLI still works. diff --git a/src/listen.rs b/src/listen.rs new file mode 100644 index 0000000..45f8c7e --- /dev/null +++ b/src/listen.rs @@ -0,0 +1,84 @@ +//! Long-running command subscription for `bread.command.help.*`. +//! +//! `breadhelp` is still a GTK help center by default. `breadhelp listen` is +//! the optional persistent process that can honor bus commands. See +//! `EVENTS.md`. + +use bread_utils::bread_client::{BreadClient, BreadEvent}; + +use crate::services::breadd::APP_ID; + +/// Subscribe to `bread.command.help.**` and block until the process is killed. +/// +/// breadd being absent is not an error: [`BreadClient::subscribe`] reconnects +/// with backoff, and `on_event` simply isn't called until the daemon is up. +pub fn run() { + let client = BreadClient::connect(APP_ID); + if client.health().is_none() { + eprintln!( + "breadhelp: breadd unreachable; command subscription will connect when it comes back" + ); + } + + let _commands = client.subscribe("bread.command.help.**", |event| { + handle_command(&event); + }); + + eprintln!("breadhelp: listening for bread.command.help.**"); + loop { + std::thread::park(); + } +} + +/// Reacts to `bread.command.help.*` verbs. Only `open` is honored today — +/// other verbs are ignored, not stubbed as no-ops that pretend to succeed. +fn handle_command(event: &BreadEvent) { + let Some(verb) = command_verb(&event.event) else { + return; + }; + match verb { + "open" => handle_open(), + other => { + eprintln!("breadhelp: ignoring unrecognized bread.command.help.{other}"); + } + } +} + +fn handle_open() { + // Same as running `breadhelp` with no flags: GApplication presents the + // main window (or forwards to the already-running primary instance). + let result = spawn_self(); + let client = BreadClient::connect(APP_ID); + match result { + Ok(_) => client.emit("bread.help.open.done", serde_json::json!({})), + Err(e) => { + eprintln!("breadhelp: bread.command.help.open failed: {e}"); + client.emit( + "bread.help.open.failed", + serde_json::json!({ "error": e.to_string() }), + ); + } + } +} + +fn spawn_self() -> std::io::Result { + let exe = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("breadhelp")); + std::process::Command::new(exe).spawn() +} + +fn command_verb(event_name: &str) -> Option<&str> { + event_name.strip_prefix("bread.command.help.") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn command_verb_strips_help_prefix() { + assert_eq!(command_verb("bread.command.help.open"), Some("open")); + assert_eq!(command_verb("bread.command.help.onboard"), Some("onboard")); + assert_eq!(command_verb("bread.command.box.open"), None); + assert_eq!(command_verb("bread.help.opened"), None); + } +} diff --git a/src/main.rs b/src/main.rs index baefb55..3aeed69 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod cli; mod config; mod content; +mod listen; mod screenshot; mod services; mod theme; @@ -10,6 +11,11 @@ use gtk4::gio::ApplicationFlags; use gtk4::prelude::*; fn main() { + if std::env::args().nth(1).as_deref() == Some("listen") { + listen::run(); + return; + } + // GApplication requires a dotted id (g_application_id_is_valid rejects a // bare "breadhelp" with a GLib-GIO-CRITICAL and silently skips setting // it, which would break D-Bus single-instance activation). The Wayland diff --git a/src/services/breadd.rs b/src/services/breadd.rs index d6f51df..1bdd64d 100644 --- a/src/services/breadd.rs +++ b/src/services/breadd.rs @@ -7,7 +7,7 @@ use bread_utils::bread_client::BreadClient; /// Sibling-app id in `bread_shared::apps::KNOWN_APPS`. Events publish as -/// `bread.help.*`. There is no command subscription. +/// `bread.help.*`. Command verbs are handled by `breadhelp listen`. pub const APP_ID: &str = "help"; /// Fire-and-forget `bread.help.opened` after the main window is actually From af6ba1bd82dbcdb07fdb0ce112f9497347a94723 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:00:23 +0800 Subject: [PATCH 02/10] 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. --- EVENTS.md | 5 +- README.md | 2 +- src/cli.rs | 3 +- src/config.rs | 135 ++++++++- src/services/exec.rs | 46 +++ src/theme.rs | 2 + src/ui/day_zero.rs | 665 +++++++++++++++++++++++++++++++++++++++++++ src/ui/mod.rs | 1 + src/ui/window.rs | 73 +++-- 9 files changed, 901 insertions(+), 31 deletions(-) create mode 100644 src/ui/day_zero.rs diff --git a/EVENTS.md b/EVENTS.md index 521d1d8..bfce9b9 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -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) diff --git a/README.md b/README.md index dd4a036..24156d7 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/cli.rs b/src/cli.rs index 88f1877..654d2c2 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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`. diff --git a/src/config.rs b/src/config.rs index e2f3e02..d609c8c 100644 --- a/src/config.rs +++ b/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); } diff --git a/src/services/exec.rs b/src/services/exec.rs index 4bd87f9..827e576 100644 --- a/src/services/exec.rs +++ b/src/services/exec.rs @@ -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 = 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); + } + }); +} diff --git a/src/theme.rs b/src/theme.rs index ae967c5..d72f017 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -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! { diff --git a/src/ui/day_zero.rs b/src/ui/day_zero.rs new file mode 100644 index 0000000..23eef73 --- /dev/null +++ b/src/ui/day_zero.rs @@ -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, + step: RefCell, + finished: Cell, + on_finished: Box, +} + +/// 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 { + stdout + .trim() + .strip_prefix("Timezone=") + .map(str::to_string) + .filter(|s| !s.is_empty()) +} + +fn parse_timezone_list(stdout: &str) -> Vec { + 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, 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) { + 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.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) { + 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) { + 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 = 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) { + 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 = 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) { + 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 = 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) { + 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) { + 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) { + 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"] + ); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 8374aab..7f7a760 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,4 +1,5 @@ pub mod ask; +pub mod day_zero; pub mod guide_view; pub mod home; pub mod keybind_viewer; diff --git a/src/ui/window.rs b/src/ui/window.rs index 10a7839..89c3518 100644 --- a/src/ui/window.rs +++ b/src/ui/window.rs @@ -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, + binds: Rc>, } 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); - } + 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, + } } From a7a67d7d8480bc774fe0dcc5b35da6aa768fad53 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:26:07 +0800 Subject: [PATCH 03/10] Adopt bread_utils::screenshot_cli for --screenshot flags Replace the local settle delay, canvas defaults, and pair-validation error path with bread-utils v0.7.2. Hand-rolled argv parsing stays. --- src/cli.rs | 40 ++++++++++++++++++++++++++++++---------- src/screenshot.rs | 8 ++------ 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 654d2c2..62df4bc 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -4,7 +4,10 @@ //! `connect_command_line`, so `--onboard` on a second launch re-triggers the //! tour in the existing window instead of spawning a duplicate. -#[derive(Clone, Default)] +use bread_utils::screenshot_cli::{validate_pair, DEFAULT_HEIGHT, DEFAULT_WIDTH}; +use std::path::Path; + +#[derive(Clone)] pub struct Action { /// Force-restart the onboarding tour from step 0, regardless of whether /// it was already completed or in progress. @@ -38,19 +41,36 @@ pub struct Action { pub height: u32, } +impl Default for Action { + fn default() -> Self { + Self { + force_onboard: false, + autostart: false, + suggest: None, + tour_event: None, + screenshot: None, + output: None, + width: DEFAULT_WIDTH, + height: DEFAULT_HEIGHT, + } + } +} + impl Action { - /// `None` for a normal run. Exits the process with an error if - /// `--screenshot` was given without `--output`, before any GTK setup + /// `None` for a normal run. Exits the process with an error if the + /// `--screenshot` / `--output` pair is incomplete, before any GTK setup /// happens. pub fn screenshot_request(&self) -> Option { - let view = self.screenshot.clone()?; - let Some(output) = self.output.clone() else { - eprintln!("breadhelp: --screenshot requires --output"); + if let Err(e) = validate_pair( + self.screenshot.as_deref(), + self.output.as_deref().map(Path::new), + ) { + eprintln!("breadhelp: {e}"); std::process::exit(1); - }; + } Some(crate::screenshot::ScreenshotRequest { - view, - output: output.into(), + view: self.screenshot.clone()?, + output: self.output.clone()?.into(), width: self.width, height: self.height, }) @@ -58,7 +78,7 @@ impl Action { } pub fn parse(args: &[std::ffi::OsString]) -> Action { - let mut action = Action { width: 1920, height: 1080, ..Action::default() }; + let mut action = Action::default(); let mut it = args.iter().skip(1); while let Some(arg) = it.next() { if arg == "--onboard" { diff --git a/src/screenshot.rs b/src/screenshot.rs index 95a79cf..dd5ea16 100644 --- a/src/screenshot.rs +++ b/src/screenshot.rs @@ -21,19 +21,15 @@ //! both render embedded *inside* the Learn/Home tabs respectively, so //! they're already covered by those tabs' own captures. +use bread_utils::screenshot_cli::SETTLE_DELAY; use gtk4::prelude::*; use std::path::PathBuf; use std::time::Duration; -/// Extra settle time after `map` for the first frame to actually paint -/// before grim runs — `map` fires once the surface exists, not once -/// anything has been drawn into it. -const SETTLE_DELAY: Duration = Duration::from_millis(300); - /// Delay before opening the wizard modal — same reasoning as every other /// app's PRE_POPUP_DELAY: the parent window's own layout needs a beat to /// settle first. -const PRE_POPUP_DELAY: Duration = Duration::from_millis(300); +const PRE_POPUP_DELAY: Duration = SETTLE_DELAY; const KNOWN_VIEWS: &[&str] = &["home", "learn", "ask", "troubleshoot-wizard"]; From 9c4a1133fb6f3b818e18764244feff89d1f4b1db Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:50:22 +0800 Subject: [PATCH 04/10] CI: run release tests through ci/build.sh --- .forgejo/workflows/release.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 29a4554..f64fc79 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -17,10 +17,19 @@ jobs: "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build - run: cd src && bash ci/build.sh cargo build --release --locked + run: | + set -euo pipefail + if [ ! -f src/ci/build.sh ]; then + echo "::error::ci/build.sh is missing — bakery release builds must go through the shared CI wrapper" + exit 1 + fi + cd src && bash ci/build.sh cargo build --release --locked || { + echo "::error::cargo build --release --locked failed. If Cargo.lock drifted, update and commit it; do not drop --locked." + exit 1 + } - name: test - run: cd src && cargo test --release --locked + run: cd src && bash ci/build.sh cargo test --release --locked - name: prepare artifacts run: | From 5ae12e37015eaa12e45cd17fc3dd642c4d9e86c3 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 13:25:24 +0800 Subject: [PATCH 05/10] Bind help windows to the current monitor's bread-theme palette Pin bread-theme to v0.7.4. --- Cargo.lock | 153 +++++++++++++++++++--------------- Cargo.toml | 2 +- src/theme.rs | 5 ++ src/ui/tour/callout.rs | 1 + src/ui/tour/mask.rs | 1 + src/ui/troubleshoot_wizard.rs | 1 + src/ui/window.rs | 1 + 7 files changed, 98 insertions(+), 66 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9512dfa..77d3aa5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -55,8 +55,8 @@ dependencies = [ [[package]] name = "bread-theme" -version = "0.7.2" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" +version = "0.7.4" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#fcba3760387e2523edb71350f8efea3bc851b21e" dependencies = [ "dirs", "gtk4", @@ -115,7 +115,7 @@ checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54" dependencies = [ "glib-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -178,11 +178,10 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -209,24 +208,24 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -235,32 +234,32 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-macro", @@ -291,7 +290,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -324,7 +323,7 @@ dependencies = [ "libc", "pango-sys", "pkg-config", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -364,7 +363,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 7.0.8", "windows-sys 0.61.2", ] @@ -418,7 +417,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -428,7 +427,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233" dependencies = [ "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -439,7 +438,7 @@ checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c" dependencies = [ "glib-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -460,7 +459,7 @@ checksum = "5c7ffdfde88f3570d3705e0d8a2433e036d387a1f2930bbf47eafcb5f569fd04" dependencies = [ "glib-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -491,7 +490,7 @@ dependencies = [ "graphene-sys", "libc", "pango-sys", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -517,9 +516,9 @@ dependencies = [ [[package]] name = "gtk4-layer-shell" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4069987ff4793699511a251028cc336b438e46565b463f111250148d574752a" +checksum = "17c28ea0f4676fdaaae7ff2413a24d0d35c8657424f84856c1103c73454c9da4" dependencies = [ "bitflags", "gdk4", @@ -532,15 +531,15 @@ dependencies = [ [[package]] name = "gtk4-layer-shell-sys" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f566a5ec5bcc454e7fcf2ab76930887ced5365afce12c1e5201bb296b95f1b9" +checksum = "bcf19bb884ef0ef55b9e6b2b369c39b4fcc0c41e3a0c1cbc8c267720338b690b" dependencies = [ "gdk4-sys", "glib-sys", "gtk4-sys", "libc", - "system-deps", + "system-deps 8.0.0", ] [[package]] @@ -552,7 +551,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -571,7 +570,7 @@ dependencies = [ "gsk4-sys", "libc", "pango-sys", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -610,15 +609,15 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libredox" -version = "0.1.18" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" dependencies = [ "libc", ] @@ -676,7 +675,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -693,9 +692,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "proc-macro-crate" @@ -708,18 +707,18 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -752,9 +751,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -762,29 +761,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -834,6 +833,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "system-deps" version = "7.0.8" @@ -843,7 +853,20 @@ dependencies = [ "cfg-expr", "heck", "pkg-config", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", + "version-compare", +] + +[[package]] +name = "system-deps" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83779a5c956bcb6ba627a4ecf0a9d7625db47d7537e0892d97f712ac995648a3" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml 1.1.4+spec-1.1.0", "version-compare", ] @@ -870,7 +893,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -887,9 +910,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.3+spec-1.1.0" +version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ "indexmap", "serde_core", @@ -946,9 +969,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ "winnow 1.0.4", ] @@ -984,7 +1007,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1115,9 +1138,9 @@ dependencies = [ [[package]] name = "xml-rs" -version = "0.8.28" +version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "zmij" diff --git a/Cargo.toml b/Cargo.toml index 89eaf46..b2867c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ gtk4-layer-shell = "0.8" glib = "0.22" # Shared ecosystem theming — same generated stylesheet bos-settings/breadbar/ # breadbox/breadpad load, so this looks like part of the same desktop. -bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["gtk"] } +bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4", features = ["gtk"] } serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.8" diff --git a/src/theme.rs b/src/theme.rs index d72f017..89cebf1 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -81,3 +81,8 @@ pub fn load(_display: >k4::gdk::Display) { bread_theme::gtk::apply_shared(); APP_PROVIDER.with(|cell| bread_theme::gtk::apply_css(APP_CSS, cell)); } + +pub fn bind_window(window: &impl gtk4::prelude::IsA) { + bread_theme::gtk::bind_window_auto_with_app_css(window, |_| APP_CSS.to_string()); +} + diff --git a/src/ui/tour/callout.rs b/src/ui/tour/callout.rs index 4943824..8baf553 100644 --- a/src/ui/tour/callout.rs +++ b/src/ui/tour/callout.rs @@ -49,6 +49,7 @@ pub fn build( window.set_layer(Layer::Overlay); window.set_keyboard_mode(KeyboardMode::OnDemand); window.set_monitor(Some(monitor)); + crate::theme::bind_window(&window); if let Some(rect) = rect { let (mon_w, mon_h) = monitor_size; diff --git a/src/ui/tour/mask.rs b/src/ui/tour/mask.rs index 8ae8059..e26c9da 100644 --- a/src/ui/tour/mask.rs +++ b/src/ui/tour/mask.rs @@ -20,6 +20,7 @@ fn new_mask_window(monitor: &gdk4::Monitor) -> Window { window.set_layer(Layer::Overlay); window.set_keyboard_mode(KeyboardMode::None); window.set_monitor(Some(monitor)); + crate::theme::bind_window(&window); let dim = GBox::new(gtk4::Orientation::Vertical, 0); dim.add_css_class("tour-mask"); dim.set_hexpand(true); diff --git a/src/ui/troubleshoot_wizard.rs b/src/ui/troubleshoot_wizard.rs index c012835..a7565d4 100644 --- a/src/ui/troubleshoot_wizard.rs +++ b/src/ui/troubleshoot_wizard.rs @@ -38,6 +38,7 @@ pub fn open(parent: &impl IsA, on_build: impl FnOnce(&Window)) { .default_height(360) .build(); window.add_css_class("wizard-dialog"); + crate::theme::bind_window(&window); let header = gtk4::HeaderBar::new(); header.set_show_title_buttons(true); window.set_titlebar(Some(&header)); diff --git a/src/ui/window.rs b/src/ui/window.rs index 89c3518..b68be30 100644 --- a/src/ui/window.rs +++ b/src/ui/window.rs @@ -131,6 +131,7 @@ fn build(app: &Application) -> Handle { .build(); crate::theme::load(&WidgetExt::display(&window)); + crate::theme::bind_window(&window); let store = Rc::new(ContentStore::load()); let binds = Rc::new(keybinds::load()); From d42d6632c615cfa31d80ecedac8fd4a3efdbb30f Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 13:46:28 +0800 Subject: [PATCH 06/10] CI: stop wiping shared bread-ecosystem clones mid-job Another product's gen-index step was deleting /tmp/bread-ecosystem-ci-* between breadhelp's build and test, so the test step 127'd on a missing ci/build.sh. --- .forgejo/workflows/dev-release.yml | 8 ++++---- ci/build.sh | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml index 5c7b221..291d765 100644 --- a/.forgejo/workflows/dev-release.yml +++ b/.forgejo/workflows/dev-release.yml @@ -71,10 +71,10 @@ jobs: echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate dev index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the dev track)" exit 1 fi - rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true - # mktemp: a fixed clone path races when multiple repos' dev/beta - # workflows run close together on the same self-hosted runner. - ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)" + # Do not wipe /tmp/bread-ecosystem-ci-* — other products' ci/build.sh + # caches live there and a wipe between their build and test steps + # is a 127 (missing ci/build.sh). + ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-index-XXXXXX)" git clone --branch main https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" TRACK=dev bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh" rm -rf "${ECOSYSTEM_CI_DIR}" diff --git a/ci/build.sh b/ci/build.sh index 668b199..63262e8 100755 --- a/ci/build.sh +++ b/ci/build.sh @@ -12,8 +12,8 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" REV="$(cat "${ROOT}/ci/bread-ecosystem.rev")" CACHE_DIR="/tmp/bread-ecosystem-ci-${REV}" -if [ ! -d "$CACHE_DIR" ]; then - rm -rf /tmp/bread-ecosystem-ci-* +if [ ! -f "$CACHE_DIR/ci/build.sh" ]; then + rm -rf "$CACHE_DIR" git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "$CACHE_DIR" git -C "$CACHE_DIR" checkout --quiet "$REV" fi From 36cb292213cfd46198fd31378d93272721a91b6f Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 14:09:43 +0800 Subject: [PATCH 07/10] Bump version to v0.2.6 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 77d3aa5..16c4fd9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -78,7 +78,7 @@ dependencies = [ [[package]] name = "breadhelp" -version = "0.2.4" +version = "0.2.6" dependencies = [ "anyhow", "async-channel", diff --git a/Cargo.toml b/Cargo.toml index b2867c7..c93d5c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadhelp" -version = "0.2.4" +version = "0.2.6" edition = "2021" [dependencies] From 03517d9b0f4118b76e3e372281b530a684c00b07 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 23 Aug 2026 14:42:57 +0800 Subject: [PATCH 08/10] Match help copy to live BOS binds and semantic search Keep Super+U / Super+Backspace as in iso binds.json; document Super+, for settings. Describe breadsearch as meaning search, not filename letters. --- content/daily-use/01-searching-your-files/content.md | 2 +- content/getting-started/02-essential-keybinds/content.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/content/daily-use/01-searching-your-files/content.md b/content/daily-use/01-searching-your-files/content.md index b0ee950..2dad088 100644 --- a/content/daily-use/01-searching-your-files/content.md +++ b/content/daily-use/01-searching-your-files/content.md @@ -4,6 +4,6 @@ breadsearch indexes your files so you can find things instantly, without digging through folders. - Open it from BOS Settings, or run it directly. -- Type a few letters of a filename — results update as you type. +- Describe what you are looking for in plain language — breadsearch ranks files by meaning, not by letters in a filename. Results update as you type. - [Run]breadsearch|Open File Search[/Run] diff --git a/content/getting-started/02-essential-keybinds/content.md b/content/getting-started/02-essential-keybinds/content.md index 7063367..132bd70 100644 --- a/content/getting-started/02-essential-keybinds/content.md +++ b/content/getting-started/02-essential-keybinds/content.md @@ -15,7 +15,8 @@ nothing here is mandatory either, click around if you'd rather. - [Show Keybind]super + space[/Show Keybind] opens the launcher (breadbox) - [Show Keybind]super + e[/Show Keybind] opens files (nautilus) - [Show Keybind]super + b[/Show Keybind] opens the browser -- [Show Keybind]super + u[/Show Keybind] opens notes +- [Show Keybind]super + u[/Show Keybind] opens notes (breadpad) +- [Show Keybind]super + ,[/Show Keybind] opens BOS Settings - [Show Keybind]super + l[/Show Keybind] locks the screen ## Screenshots From e27c497df84eab6de97184914e61283abc4ce3bb Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 6 Aug 2026 08:51:24 +0800 Subject: [PATCH 09/10] clippy: fix dead-code and question-mark warnings - meta.rs/tour.rs/troubleshoot.rs: annotate content-schema fields that real TOML content already populates but no UI reads yet, instead of dropping them - hyprland.rs: drop Monitor's x/y/width/height, genuinely unused anywhere and not part of any authored content schema - tour/mod.rs: collapse a let-else into ? in the Option-returning on_tour_event closure (cherry picked from commit 4497eca4661112f808d33ef477265d39e0e0a601) --- src/content/meta.rs | 9 +++++++++ src/content/tour.rs | 4 ++++ src/content/troubleshoot.rs | 5 +++++ src/services/hyprland.rs | 4 ---- src/ui/tour/mod.rs | 2 +- 5 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/content/meta.rs b/src/content/meta.rs index 905ce02..364ca0a 100644 --- a/src/content/meta.rs +++ b/src/content/meta.rs @@ -12,14 +12,23 @@ pub enum Difficulty { #[derive(serde::Deserialize, Clone, Default)] pub struct GuideMeta { pub title: String, + /// Not read by any UI yet — reserved for a future difficulty badge. + /// Real content already sets it, so kept rather than dropped. + #[allow(dead_code)] #[serde(default)] pub difficulty: Difficulty, #[serde(default)] pub tags: Vec, /// Guide dir-names in the same or another category. Unresolvable entries /// are silently dropped when rendering "related guides" — never a crash. + /// Not read by any UI yet — the "related guides" render path doesn't + /// exist. Real content already sets it, so kept rather than dropped. + #[allow(dead_code)] #[serde(default)] pub related: Vec, + /// Not read by any UI yet — reserved for a future "N min read" hint. + /// Real content already sets it, so kept rather than dropped. + #[allow(dead_code)] #[serde(default)] pub estimated_minutes: u32, /// Overrides the parent directory name as the guide's category, if set. diff --git a/src/content/tour.rs b/src/content/tour.rs index 9bda162..6bee9af 100644 --- a/src/content/tour.rs +++ b/src/content/tour.rs @@ -29,6 +29,10 @@ pub enum Success { #[derive(serde::Deserialize, Clone)] pub struct Step { + /// Not read by any Rust code yet — steps are matched by index, not id. + /// Real tour content already sets it (content authors use it to keep + /// track of steps), so kept rather than dropped. + #[allow(dead_code)] pub id: String, pub title: String, pub body: String, diff --git a/src/content/troubleshoot.rs b/src/content/troubleshoot.rs index 7a39da4..948826e 100644 --- a/src/content/troubleshoot.rs +++ b/src/content/troubleshoot.rs @@ -15,6 +15,11 @@ pub struct SymptomOption { pub struct Fix { pub description: String, pub command: String, + /// Not read by `ui::troubleshoot_wizard` yet — the "Run fix" button + /// currently executes unconditionally regardless of this flag. Real + /// symptom content already sets it, so kept rather than dropped; the + /// wizard should gate on it before this is treated as safe. + #[allow(dead_code)] #[serde(default)] pub requires_confirm: bool, } diff --git a/src/services/hyprland.rs b/src/services/hyprland.rs index 4e570fc..ab3bd3e 100644 --- a/src/services/hyprland.rs +++ b/src/services/hyprland.rs @@ -28,10 +28,6 @@ pub struct Client { pub struct Monitor { pub id: i32, pub name: String, - pub x: i32, - pub y: i32, - pub width: i32, - pub height: i32, pub focused: bool, } diff --git a/src/ui/tour/mod.rs b/src/ui/tour/mod.rs index 8112a9d..7404e81 100644 --- a/src/ui/tour/mod.rs +++ b/src/ui/tour/mod.rs @@ -110,7 +110,7 @@ pub fn self_heal() { pub fn on_tour_event(id: &str) { let hit = STATE.with(|cell| { let mut borrow = cell.borrow_mut(); - let Some(state) = borrow.as_mut() else { return None }; + let state = borrow.as_mut()?; // Only a *visibly* confirmed step blocks a re-fire. `confirmed` gets // set below, before `render_step` runs, so that render actually // paints the "done" state instead of the stale pre-confirmation one From 0d540c6bb9a66717f694b26c34082a9edb8b3a1b Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 23 Aug 2026 15:08:58 +0800 Subject: [PATCH 10/10] Skip building the help window on silent every-login autostart Keep a warm GApplication with hold() so Super+/ still hits a resident process, without paying for tabs that may never open. Ignore graphify-out. --- .gitignore | 3 +++ src/ui/window.rs | 39 +++++++++++++++++++++++++++------------ 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index 7379fb9..14a0220 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ logs/ .claude/ # Local hygiene notes (not for commit) + +# graphify knowledge-graph output (local tool cache, not for commit) +graphify-out/ diff --git a/src/ui/window.rs b/src/ui/window.rs index b68be30..866b63d 100644 --- a/src/ui/window.rs +++ b/src/ui/window.rs @@ -30,13 +30,33 @@ thread_local! { /// over D-Bus to this primary instance by a second `breadhelp` launch — so /// it must build the window at most once and reuse it thereafter. pub fn present(app: &Application, action: Action) { + // Crash-recover keybinds even if this invocation never builds a window + // (silent every-login autostart). + tour::self_heal(); + + // Once onboarding is done, autostart only needs a warm process so + // Super+/ is instant. GTK/theme/font load is most of that cost and is + // paid by staying resident (~14 MiB PSS, no window). Building tabs + // adds ~2 MiB of dead weight for a session that may never open help. + let is_bare_silent_autostart = action.autostart + && action.screenshot.is_none() + && !action.force_onboard + && action.tour_event.is_none() + && action.suggest.is_none() + && State::load().onboarding_completed(); + if is_bare_silent_autostart { + // No window means nothing holds GApplication open. Forget the hold + // guard so the primary instance lives for the session; logout is + // what ends it. Without this, the next Super+/ would cold-start. + std::mem::forget(app.hold()); + return; + } + HANDLE.with(|cell| { - let mut just_built = false; { let mut cell_ref = cell.borrow_mut(); if cell_ref.is_none() { *cell_ref = Some(build(app)); - just_built = true; } } let cell_ref = cell.borrow(); @@ -62,19 +82,14 @@ pub fn present(app: &Application, action: Action) { if let Some(s) = crate::services::breadd::resolve(id) { handle.home.set_suggestion(Some(&s.text)); } - // Only focus the window the first time this process builds it - // (i.e. breadhelp wasn't already running) — a background daemon - // event shouldn't steal focus from whatever the user is doing. - if just_built { - show_window(&handle.window, action.autostart); - } + // Background daemon event — never steal focus. A `--suggest` + // can now be the first invocation of the session because + // silent autostart no longer builds a window. return; } - // Every-login autostart builds the window (so the app is ready to - // 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. + // Bare silent autostart (onboarding done) already returned above. + // Autostart that reaches here is a genuine first run. let onboarded = State::load().onboarding_completed(); if action.autostart && onboarded { return;