diff --git a/EVENTS.md b/EVENTS.md index 22233d3..428599c 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -8,11 +8,11 @@ specifically its "Namespaces" and "Integrating a bread\* app" sections — for the general convention this follows. App id: **`pad`**. Transport: `bread-utils`'s `bread_client` module -(feature `bread-client`) — the capture popup links it directly. breadpad is -short-lived (one popup, or one `fire ` invocation from the existing -systemd user timer), so each `emit` is its own short-lived connection. -There is no long-running breadpad daemon and therefore no command -subscription. +(feature `bread-client`) — the capture popup links it directly. One-shot +popup / `fire ` invocations each `emit` on their own short-lived +connection. Command verbs are only received while `breadpad listen` is +running — that process holds the `bread.command.pad.**` subscription +open. `breadman` (the viewer) does not emit or subscribe. Notes created or edited there are not quick-capture, and it is not on the reminder-fire path. @@ -23,6 +23,8 @@ there are not quick-capture, and it is not on the reminder-fire path. |-------|------|------| | `bread.pad.captured` | `{ "id": "" }` | The capture popup saved a note successfully (`Store::save_note` returned `Ok`). Not emitted when the field is empty, the window is dismissed, classification-only preview happens, or the write fails. | | `bread.pad.reminder.due` | `{ "id": "" }` | `breadpad fire ` decided the reminder is due (`Scheduler::fire` returned true) and is about to show the reminder window. This is the existing in-process systemd-timer hook (`breadpad-reminder-.timer` → `breadpad fire `), not a new daemon. Not emitted when the note is missing, the fire is outside the missed-grace window, or the reminder window is opened as a `--screenshot` sample. | +| `bread.pad.capture.done` | `{}` | `bread.command.pad.capture` was received and `breadpad` was spawned. This is the command confirmation, not proof the popup mapped — the spawned process is the same no-args invocation as the capture keybind. | +| `bread.pad.capture.failed` | `{ "error": "" }` | `bread.command.pad.capture` was received but this binary could not be started. | Note bodies are never included in the payload — only the local note id. Notes stay in `~/.local/share/breadpad/notes.jsonl`; the event bus is for @@ -31,19 +33,38 @@ content. ## Commands honored (`bread.command.pad.*`) -None. breadpad has no persistent process that could subscribe, and the -actions a command verb would map to already exist as local CLI / keybind -paths (`breadpad` for capture, `breadpad fire ` for the reminder -window, `breadman` for viewing and editing). Stubbing `capture` / `snooze` -/ `done` on the bus without a subscriber (or inventing a daemon just to -hold one) would be a no-op dressed up as an API. If breadpad later grows a -long-running piece that can honor a verb for real, add the verb then. +These are only received while `breadpad listen` is running. Publishing a +command with no subscriber is a silent no-op — that is the documented +bread convention, not a breadpad bug. + +| Verb | Data | Effect | +|------|------|--------| +| `capture` | none | Same as running `breadpad` with no args: open the capture popup. Emits `bread.pad.capture.done` / `.failed`. | + +```lua +bread.spawn(function() + bread.emit("bread.command.pad.capture") + bread.wait("bread.pad.capture.done", { timeout = 5000 }) +end) +``` + +### Not implemented: extra verbs + +There is no `snooze` / `done` / `fire` command verb. Reminder fire +already exists as `breadpad fire ` (systemd user timer), and +viewing/editing lives in `breadman`. 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). Capture, save, + (`BreadClient::emit` never blocks or errors the caller) and the + command subscription simply never receives anything. Capture, save, systemd timers, and the reminder window are entirely unaffected. -- There is no command subscription to reconnect. Restarting breadd does - not require restarting breadpad; the next real capture or fire will emit - again if breadd is reachable at that moment. +- If breadd restarts, the command subscription reconnects automatically + (`BreadClient::subscribe`'s background thread has its own backoff + loop); no restart of `breadpad listen` is needed. +- If `breadpad listen` is not running, commands are a graceful no-op at + the bus (no subscriber). One-shot capture / `fire` still emit + `bread.pad.captured` / `bread.pad.reminder.due` on their own + short-lived connection. diff --git a/breadpad/src/listen.rs b/breadpad/src/listen.rs new file mode 100644 index 0000000..e8649a5 --- /dev/null +++ b/breadpad/src/listen.rs @@ -0,0 +1,83 @@ +//! Long-running command subscription for `bread.command.pad.*`. +//! +//! `breadpad` is still a one-shot capture popup by default. `breadpad listen` +//! is the optional persistent process that can honor bus commands. See +//! `EVENTS.md`. + +use anyhow::Result; +use bread_utils::bread_client::{BreadClient, BreadEvent}; + +/// Sibling-app id in `bread_shared::apps::KNOWN_APPS`. +const APP_ID: &str = "pad"; + +/// Subscribe to `bread.command.pad.**` 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() -> Result<()> { + let client = BreadClient::connect(APP_ID); + if client.health().is_none() { + tracing::warn!("breadd unreachable; command subscription will connect when it comes back"); + } + + let _commands = client.subscribe("bread.command.pad.**", |event| { + handle_command(&event); + }); + + tracing::info!("listening for bread.command.pad.**"); + loop { + std::thread::park(); + } +} + +/// Reacts to `bread.command.pad.*` verbs. Only `capture` 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 { + "capture" => handle_capture(), + other => { + tracing::debug!("ignoring unrecognized command verb '{other}'"); + } + } +} + +fn handle_capture() { + // Same as running `breadpad` with no args: open the capture popup. + let result = spawn_self(); + let client = BreadClient::connect(APP_ID); + match result { + Ok(_) => client.emit("bread.pad.capture.done", serde_json::json!({})), + Err(e) => { + tracing::warn!("bread.command.pad.capture failed: {e}"); + client.emit( + "bread.pad.capture.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("breadpad")); + std::process::Command::new(exe).spawn() +} + +fn command_verb(event_name: &str) -> Option<&str> { + event_name.strip_prefix("bread.command.pad.") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn command_verb_strips_pad_prefix() { + assert_eq!(command_verb("bread.command.pad.capture"), Some("capture")); + assert_eq!(command_verb("bread.command.pad.snooze"), Some("snooze")); + assert_eq!(command_verb("bread.command.box.open"), None); + assert_eq!(command_verb("bread.pad.captured"), None); + } +} diff --git a/breadpad/src/main.rs b/breadpad/src/main.rs index d1b057b..def0c86 100644 --- a/breadpad/src/main.rs +++ b/breadpad/src/main.rs @@ -15,6 +15,7 @@ use std::cell::RefCell; use std::rc::Rc; use std::sync::{Arc, Once}; +mod listen; mod screenshot; /// Sibling-app id in `bread_shared::apps::KNOWN_APPS`. Events are `bread.pad.*`. @@ -64,6 +65,7 @@ mod args { pub output: Option, pub width: u32, pub height: u32, + pub listen: bool, } impl Args { @@ -99,6 +101,7 @@ mod args { output: None, width: 1920, height: 1080, + listen: false, }; let raw: Vec = std::env::args().skip(1).collect(); let mut i = 0; @@ -112,6 +115,7 @@ mod args { "--status" => args.status = true, "download-model" => args.download_model = true, "model-info" => args.model_info = true, + "listen" => args.listen = true, "fire" => { i += 1; args.fire_id = raw.get(i).cloned(); @@ -165,6 +169,9 @@ fn main() -> Result<()> { .init(); let args = args::parse(); + if args.listen { + return listen::run(); + } let cfg = Config::load()?; if args.status {