Honor bread.command.pad.capture via breadpad listen
Some checks failed
dev release / build (push) Failing after 1s

This commit is contained in:
Breadway 2026-08-15 23:11:28 +08:00
parent 2f57f432a0
commit 54eba3d73a
3 changed files with 127 additions and 16 deletions

View file

@ -8,11 +8,11 @@ specifically its "Namespaces" and "Integrating a bread\* app" sections — for
the general convention this follows. the general convention this follows.
App id: **`pad`**. Transport: `bread-utils`'s `bread_client` module App id: **`pad`**. Transport: `bread-utils`'s `bread_client` module
(feature `bread-client`) — the capture popup links it directly. breadpad is (feature `bread-client`) — the capture popup links it directly. One-shot
short-lived (one popup, or one `fire <id>` invocation from the existing popup / `fire <id>` invocations each `emit` on their own short-lived
systemd user timer), so each `emit` is its own short-lived connection. connection. Command verbs are only received while `breadpad listen` is
There is no long-running breadpad daemon and therefore no command running — that process holds the `bread.command.pad.**` subscription
subscription. open.
`breadman` (the viewer) does not emit or subscribe. Notes created or edited `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. 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": "<note 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.captured` | `{ "id": "<note 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": "<note id>" }` | `breadpad fire <id>` 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-<id>.timer``breadpad fire <id>`), 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.reminder.due` | `{ "id": "<note id>" }` | `breadpad fire <id>` 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-<id>.timer``breadpad fire <id>`), 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": "<message>" }` | `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. 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 Notes stay in `~/.local/share/breadpad/notes.jsonl`; the event bus is for
@ -31,19 +33,38 @@ content.
## Commands honored (`bread.command.pad.*`) ## Commands honored (`bread.command.pad.*`)
None. breadpad has no persistent process that could subscribe, and the These are only received while `breadpad listen` is running. Publishing a
actions a command verb would map to already exist as local CLI / keybind command with no subscriber is a silent no-op — that is the documented
paths (`breadpad` for capture, `breadpad fire <id>` for the reminder bread convention, not a breadpad bug.
window, `breadman` for viewing and editing). Stubbing `capture` / `snooze`
/ `done` on the bus without a subscriber (or inventing a daemon just to | Verb | Data | Effect |
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. | `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 <id>` (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 ## Fail-safe behavior
- If breadd isn't installed or isn't running, `emit` is a silent no-op - 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. systemd timers, and the reminder window are entirely unaffected.
- There is no command subscription to reconnect. Restarting breadd does - If breadd restarts, the command subscription reconnects automatically
not require restarting breadpad; the next real capture or fire will emit (`BreadClient::subscribe`'s background thread has its own backoff
again if breadd is reachable at that moment. 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.

83
breadpad/src/listen.rs Normal file
View file

@ -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<std::process::Child> {
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);
}
}

View file

@ -15,6 +15,7 @@ use std::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
use std::sync::{Arc, Once}; use std::sync::{Arc, Once};
mod listen;
mod screenshot; mod screenshot;
/// Sibling-app id in `bread_shared::apps::KNOWN_APPS`. Events are `bread.pad.*`. /// Sibling-app id in `bread_shared::apps::KNOWN_APPS`. Events are `bread.pad.*`.
@ -64,6 +65,7 @@ mod args {
pub output: Option<String>, pub output: Option<String>,
pub width: u32, pub width: u32,
pub height: u32, pub height: u32,
pub listen: bool,
} }
impl Args { impl Args {
@ -99,6 +101,7 @@ mod args {
output: None, output: None,
width: 1920, width: 1920,
height: 1080, height: 1080,
listen: false,
}; };
let raw: Vec<String> = std::env::args().skip(1).collect(); let raw: Vec<String> = std::env::args().skip(1).collect();
let mut i = 0; let mut i = 0;
@ -112,6 +115,7 @@ mod args {
"--status" => args.status = true, "--status" => args.status = true,
"download-model" => args.download_model = true, "download-model" => args.download_model = true,
"model-info" => args.model_info = true, "model-info" => args.model_info = true,
"listen" => args.listen = true,
"fire" => { "fire" => {
i += 1; i += 1;
args.fire_id = raw.get(i).cloned(); args.fire_id = raw.get(i).cloned();
@ -165,6 +169,9 @@ fn main() -> Result<()> {
.init(); .init();
let args = args::parse(); let args = args::parse();
if args.listen {
return listen::run();
}
let cfg = Config::load()?; let cfg = Config::load()?;
if args.status { if args.status {