From d9012c8e72b47989627e570a93c334d5086e7ac4 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 23:11:28 +0800 Subject: [PATCH] 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