diff --git a/AGENTS.md b/AGENTS.md index a48505f..6911f08 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ Follow [`CONTRIBUTING.md`](CONTRIBUTING.md). Single-trunk: `main` plus short-liv - `github` — mirror. Day-to-day push `origin` only. ## Product -GTK4 app launcher + `breadbox-sync` icon cache. Theme via `bread-theme` (pin by tag on `git.breadway.dev`). Toggle uses `bread-utils::singleton`, not a homegrown PID file. `EVENTS.md` is the bread-event contract (app id `box`); emit `bread.box.launched` after a successful launch. No command verbs. +GTK4 app launcher + `breadbox-sync` icon cache. Theme via `bread-theme` (pin by tag on `git.breadway.dev`). Toggle uses `bread-utils::singleton`, not a homegrown PID file. `EVENTS.md` is the bread-event contract (app id `box`); emit `bread.box.launched` after a successful launch. `breadbox listen` honors `bread.command.box.open`. ## Distribution Bakery (`bakery.toml`). Forgejo `.forgejo/workflows/` is canonical; do not re-add a GitHub Actions release workflow. diff --git a/EVENTS.md b/EVENTS.md index 6e76638..3e7064a 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -8,16 +8,19 @@ specifically its "Namespaces" and "Integrating a bread\* app" sections — for the general convention this follows. App id: **`box`**. Transport: `bread-utils`'s `bread_client` module -(feature `bread-client`) — `breadbox` links it directly. breadbox is a -short-lived process (it exits when the launcher closes), so each `emit` -is its own short-lived connection. There is no long-running daemon and -therefore no command subscription. +(feature `bread-client`) — `breadbox` links it directly. One-shot +launcher invocations each `emit` on their own fire-and-forget +connection. Command verbs are only received while `breadbox listen` is +running — that process holds the `bread.command.box.**` subscription +open. ## Events published (`bread.box.*`) | Event | Data | When | |-------|------|------| | `bread.box.launched` | `{ "id": "", "name": "" }` | The user launched an app (Enter / keypad Enter on the selected row, or activating a row) **and** the spawn succeeded. Not emitted if `Command::spawn` fails (missing terminal, `exec` that cannot start). `id` is the desktop-file id (the `.desktop` filename, e.g. `firefox.desktop`), falling back to the stripped `Exec=` line when that id is empty. `name` is the desktop-entry display name. | +| `bread.box.open.done` | `{}` | `bread.command.box.open` was received and `breadbox` was spawned. This is the command confirmation, not proof the overlay mapped — the spawned process is the same toggle as a keybind. | +| `bread.box.open.failed` | `{ "error": "" }` | `bread.command.box.open` was received but this binary could not be started. | Launch history is local to breadbox (`~/.cache/breadbox/history.json`); the event bus is a notification that a launch happened, not a channel @@ -25,17 +28,38 @@ for the exec line's arguments or the resulting process. ## Commands honored (`bread.command.box.*`) -None. breadbox is not a daemon — it is not running (and not subscribed) -except while the launcher overlay is open. There is no existing -"launch this desktop id from the bus" product surface, and inventing -`bread.command.box.launch` (or similar) without that surface would be a -stub. If/when breadbox grows a long-running piece that can honor a -verb, the corresponding `bread.command.box.*` command should be added -at the same time, not stubbed out ahead of it. +These are only received while `breadbox listen` is running. Publishing a +command with no subscriber is a silent no-op — that is the documented +bread convention, not a breadbox bug. + +| Verb | Data | Effect | +|------|------|--------| +| `open` | none | Same as running `breadbox` (toggle the launcher overlay via the existing singleton). Emits `bread.box.open.done` / `.failed`. | + +```lua +bread.spawn(function() + bread.emit("bread.command.box.open") + bread.wait("bread.box.open.done", { timeout = 5000 }) +end) +``` + +### Not implemented: extra verbs + +There is no `launch` / `close` / `query` command verb. Picking a desktop +id from the bus would be a new product surface. If/when that exists, add +the corresponding `bread.command.box.*` verb at the same time, not +stubbed 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) — launching, + (`BreadClient::emit` never blocks or errors the caller) and the + command subscription simply never receives anything — launching, history, theming, and the singleton toggle are entirely unaffected. +- If breadd restarts, the command subscription reconnects automatically + (`BreadClient::subscribe`'s background thread has its own backoff + loop); no restart of `breadbox listen` is needed. +- If `breadbox listen` is not running, commands are a graceful no-op at + the bus (no subscriber). The CLI still works, and one-shot invocations + still emit `bread.box.launched` on their own short-lived connection. - Closing the launcher without launching anything emits nothing. diff --git a/breadbox/src/listen.rs b/breadbox/src/listen.rs new file mode 100644 index 0000000..9aeb367 --- /dev/null +++ b/breadbox/src/listen.rs @@ -0,0 +1,84 @@ +//! Long-running command subscription for `bread.command.box.*`. +//! +//! `breadbox` is still a one-shot toggle overlay by default. `breadbox listen` +//! is the optional persistent process that can honor bus commands. See +//! `EVENTS.md`. + +use bread_utils::bread_client::{BreadClient, BreadEvent}; + +/// Sibling-app id in `bread_shared::apps::KNOWN_APPS`. +const APP_ID: &str = "box"; + +/// Subscribe to `bread.command.box.**` 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!("breadbox: breadd unreachable; command subscription will connect when it comes back"); + } + + let _commands = client.subscribe("bread.command.box.**", |event| { + handle_command(&event); + }); + + eprintln!("breadbox: listening for bread.command.box.**"); + loop { + std::thread::park(); + } +} + +/// Reacts to `bread.command.box.*` 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!("breadbox: ignoring unrecognized bread.command.box.{other}"); + } + } +} + +fn handle_open() { + // Same as running `breadbox` from a keybind: toggle the overlay via the + // existing singleton. Spawn success is the command confirmation — we do + // not wait for the GTK window to map. + let result = spawn_self(); + let client = BreadClient::connect(APP_ID); + match result { + Ok(_) => client.emit("bread.box.open.done", serde_json::json!({})), + Err(e) => { + eprintln!("breadbox: bread.command.box.open failed: {e}"); + client.emit( + "bread.box.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("breadbox")); + std::process::Command::new(exe).spawn() +} + +fn command_verb(event_name: &str) -> Option<&str> { + event_name.strip_prefix("bread.command.box.") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn command_verb_strips_box_prefix() { + assert_eq!(command_verb("bread.command.box.open"), Some("open")); + assert_eq!(command_verb("bread.command.box.launch"), Some("launch")); + assert_eq!(command_verb("bread.command.clip.clear"), None); + assert_eq!(command_verb("bread.box.launched"), None); + } +} diff --git a/breadbox/src/main.rs b/breadbox/src/main.rs index 449b1ed..b207b97 100644 --- a/breadbox/src/main.rs +++ b/breadbox/src/main.rs @@ -28,6 +28,7 @@ use gtk4::{ }; use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; +mod listen; mod screenshot; // ---- Hyprland IPC ----------------------------------------------------------- @@ -560,6 +561,11 @@ fn run_ui( // ---- Main ------------------------------------------------------------------- fn main() { + if std::env::args().nth(1).as_deref() == Some("listen") { + listen::run(); + return; + } + use clap::Parser; let cli = screenshot::Cli::parse(); let screenshot_req = cli.screenshot_request();