Honor bread.command.pad.capture via breadpad listen
Some checks failed
dev release / build (push) Failing after 1s
Some checks failed
dev release / build (push) Failing after 1s
This commit is contained in:
parent
2f57f432a0
commit
54eba3d73a
3 changed files with 127 additions and 16 deletions
53
EVENTS.md
53
EVENTS.md
|
|
@ -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
83
breadpad/src/listen.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue