diff --git a/Cargo.lock b/Cargo.lock index 2f2acd5..5890128 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -95,8 +95,8 @@ dependencies = [ [[package]] name = "bread-utils" -version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886" +version = "0.7.2" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" dependencies = [ "bread-shared", "dirs", diff --git a/Cargo.toml b/Cargo.toml index 5d7286c..fbba4d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ serde_json = "1" toml = "0.8" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1", features = ["bread-client"] } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] } [profile.release] lto = "thin" diff --git a/EVENTS.md b/EVENTS.md index abc2eb8..5b7ed86 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -1,33 +1,37 @@ # breadshot — bread event integration -breadshot is a standalone, one-shot Wayland screenshot orchestrator: it -works exactly the same with or without `breadd` running. When breadd *is* -present, a successful capture publishes one event into the shared bread -automation fabric. See the parent `bread` repo's `Documentation.md` — -specifically its "Namespaces" and "Integrating a bread\* app" sections — -for the general convention this follows. +breadshot is a standalone Wayland screenshot orchestrator: it works +exactly the same with or without `breadd` running. When breadd *is* +present, a successful capture publishes into the shared bread automation +fabric. See the parent `bread` repo's `Documentation.md` — specifically +its "Namespaces" and "Integrating a bread\* app" sections — for the +general convention this follows. This is a different job from `bread-screenshots` (the crate in `bread-ecosystem`): that one is a capture harness for screenshotting sibling apps in CI. Do not merge the two. App id: **`shot`**. Transport: `bread-utils`'s `bread_client` module -(feature `bread-client`). breadshot is a short-lived CLI, not a daemon — -each `emit` is its own fire-and-forget connection (the same stance -`bread-emit` takes for occasional callers). There is no process to hold a -command subscription open. +(feature `bread-client`). One-shot CLI invocations (`breadshot region`, +…) each `emit` on their own fire-and-forget connection (the same stance +`bread-emit` takes for occasional callers). Command verbs are only +received while `breadshot listen` is running — that process holds the +`bread.command.shot.**` subscription open. ## Events published (`bread.shot.*`) | Event | Data | When | |-------|------|------| -| `bread.shot.captured` | `{ "mode": "region" \| "window" \| "output" \| "active-window" \| "active-output", "clipboard": bool, "path": }` | A capture completed successfully (grim + clipboard write both returned). Not emitted on a cancelled slurp selection, a missing dependency, or a grim/wl-copy failure. | +| `bread.shot.captured` | `{ "mode": "region" \| "window" \| "output" \| "active-window" \| "active-output", "clipboard": bool, "path": }` | A capture completed successfully (grim + clipboard write both returned), whether triggered by the CLI or by `bread.command.shot.region`. Not emitted on a cancelled slurp selection, a missing dependency, or a grim/wl-copy failure. | +| `bread.shot.region.done` | `{ "clipboard": true, "path": null }` | `bread.command.shot.region` was received and the region capture succeeded. | +| `bread.shot.region.failed` | `{ "error": "" }` | `bread.command.shot.region` was received but the capture failed (cancelled slurp, missing dependency, grim/wl-copy error). | `mode` is the CLI mode name (same strings `breadshot ` accepts). `clipboard` is whether the PNG was written to the clipboard — both current capture paths do this (`save_and_copy` and `--clipboard-only`). `path` is the saved file, or `null` when `--clipboard-only` was used (no file on -disk). +disk). The listen-triggered region path is clipboard-only, so `path` is +always `null` on `bread.shot.region.done`. The image bytes themselves are never included in the payload. The event bus is a notification that a capture happened, not a channel for the @@ -35,32 +39,47 @@ screenshot. ## Commands honored (`bread.command.shot.*`) -None. breadshot is a one-shot CLI with no persistent process to subscribe -to `bread.command.shot.*`. A Lua workflow that wants a screenshot should -shell out: +These are only received while `breadshot listen` is running. Publishing a +command with no subscriber is a silent no-op — that is the documented +bread convention, not a breadshot bug. + +| Verb | Data | Effect | +|------|------|--------| +| `region` | none | Same interactive region capture as `breadshot region --clipboard-only`. Emits `bread.shot.region.done`/`.failed`. A successful capture also publishes `bread.shot.captured` the same way the CLI path does. | + +```lua +bread.spawn(function() + bread.emit("bread.command.shot.region") + bread.wait("bread.shot.region.done", { timeout = 30000 }) +end) +``` + +A workflow that wants a file on disk (not just the clipboard) should +still shell out: ```lua bread.exec("breadshot region") --- or -bread.exec("breadshot region --clipboard-only") bread.exec("breadshot active-output") ``` -The outcome of that exec is the same `bread.shot.captured` event the -keybind path already publishes — `bread.wait("bread.shot.captured")` -inside a spawned coroutine if the workflow needs to react to the file. +### Not implemented: extra verbs -There is no `pin`, `select`, `edit`, or other command verb. breadshot has -no editor, no history, and no concept those verbs could hang on. -`bread.exec("breadshot …")` is the whole command surface. If/when a -long-running piece exists, verbs should be added then, not stubbed as -no-ops ahead of it. +There is no `window`, `output`, `active-window`, `active-output`, `pin`, +`select`, or `edit` command verb. The CLI already covers the other +capture modes as synchronous one-shots, and breadshot has no editor, no +history, and no concept those other verbs could hang on. If/when that +changes, the corresponding `bread.command.shot.*` verb should be added +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) — breadshot's + (`BreadClient::emit` never blocks or errors the caller) and the + command subscription simply never receives anything — breadshot's actual grim/slurp/wl-copy path is entirely unaffected either way. -- There is no command subscription, so a breadd restart cannot drop one. - The next `breadshot` invocation emits (or silently doesn't) on its own - short-lived connection. +- If breadd restarts, the command subscription reconnects automatically + (`BreadClient::subscribe`'s background thread has its own backoff + loop); no restart of `breadshot listen` is needed. +- If `breadshot 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.shot.captured` on their own short-lived connection. diff --git a/README.md b/README.md index cf01f85..1fdefc1 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,14 @@ make install PREFIX=/usr ``` breadshot [options] +breadshot listen ``` +`breadshot listen` is the long-running process that honors +`bread.command.shot.region` on the bread event bus (clipboard-only +region capture). See [EVENTS.md](EVENTS.md). Without it, the CLI still +works; bus commands are a silent no-op. + ### Modes | Mode | Description | diff --git a/src/capture.rs b/src/capture.rs index dd35f2f..73729b4 100644 --- a/src/capture.rs +++ b/src/capture.rs @@ -1,5 +1,4 @@ use anyhow::{bail, Context, Result}; -use clap::ValueEnum; use serde_json::Value; use std::{ io::Write, @@ -12,9 +11,9 @@ use crate::config::Config; /// Sibling-app id in bread's `KNOWN_APPS` registry. Events publish as /// `bread.shot.*`. See `EVENTS.md`. -const APP_ID: &str = "shot"; +pub(crate) const APP_ID: &str = "shot"; -#[derive(Debug, Clone, ValueEnum)] +#[derive(Debug, Clone)] pub enum Mode { /// Select a region interactively Region, @@ -23,10 +22,8 @@ pub enum Mode { /// Click to select a monitor Output, /// Capture the active window - #[value(name = "active-window")] ActiveWindow, /// Capture the active monitor - #[value(name = "active-output")] ActiveOutput, } @@ -353,8 +350,12 @@ fn send_notification(title: &str, msg: &str, timeout: u32, path: &Path) { fn hyprctl_json(subcmd: &str) -> Result { // Was a bare Command::new("hyprctl").output() with no timeout. - bread_utils::proc::run_json("hyprctl", &["-j", subcmd], std::time::Duration::from_secs(3)) - .with_context(|| format!("running/parsing hyprctl {subcmd}")) + bread_utils::proc::run_json( + "hyprctl", + &["-j", subcmd], + std::time::Duration::from_secs(3), + ) + .with_context(|| format!("running/parsing hyprctl {subcmd}")) } fn slurp(args: &[&str]) -> Result { diff --git a/src/listen.rs b/src/listen.rs new file mode 100644 index 0000000..6b31c6e --- /dev/null +++ b/src/listen.rs @@ -0,0 +1,110 @@ +//! Long-running command subscription for `bread.command.shot.*`. +//! +//! `breadshot` is still a one-shot CLI by default. `breadshot listen` is the +//! optional persistent process that can honor bus commands. See `EVENTS.md`. + +use anyhow::Result; +use bread_utils::bread_client::{BreadClient, BreadEvent}; + +use crate::capture::{self, Mode, Overrides, APP_ID}; +use crate::config::Config; + +/// Subscribe to `bread.command.shot.**` 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(config: &Config) -> 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 config = config.clone(); + let _commands = client.subscribe("bread.command.shot.**", move |event| { + handle_command(&event, &config); + }); + + tracing::info!("listening for bread.command.shot.**"); + loop { + std::thread::park(); + } +} + +/// Reacts to `bread.command.shot.*` verbs. Only `region` is honored today — +/// other verbs are ignored, not stubbed as no-ops that pretend to succeed. +/// +/// Emits `bread.shot.region.done` / `.failed` per the confirmation convention +/// in bread's Documentation.md. +fn handle_command(event: &BreadEvent, config: &Config) { + let Some(verb) = command_verb(&event.event) else { + return; + }; + match verb { + "region" => handle_region(config), + other => { + tracing::debug!("ignoring unrecognized command verb '{other}'"); + } + } +} + +fn handle_region(config: &Config) { + // Clipboard-only matches the default region *bus* path: a Lua workflow + // that wants a file on disk can still `bread.exec("breadshot region")`. + let result = capture::run( + &Mode::Region, + config, + Overrides { + clipboard_only: true, + silent: false, + freeze: false, + output_dir: None, + filename: None, + }, + ); + let client = BreadClient::connect(APP_ID); + match result { + Ok(()) => client.emit("bread.shot.region.done", region_done_payload()), + Err(e) => { + tracing::warn!("bread.command.shot.region failed: {e}"); + client.emit("bread.shot.region.failed", region_failed_payload(&e)); + } + } +} + +fn command_verb(event_name: &str) -> Option<&str> { + event_name.strip_prefix("bread.command.shot.") +} + +fn region_done_payload() -> serde_json::Value { + serde_json::json!({ "clipboard": true, "path": serde_json::Value::Null }) +} + +fn region_failed_payload(error: &impl ToString) -> serde_json::Value { + serde_json::json!({ "error": error.to_string() }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn command_verb_strips_shot_prefix() { + assert_eq!(command_verb("bread.command.shot.region"), Some("region")); + assert_eq!(command_verb("bread.command.shot.window"), Some("window")); + assert_eq!(command_verb("bread.command.clip.clear"), None); + assert_eq!(command_verb("bread.shot.captured"), None); + } + + #[test] + fn region_done_payload_is_clipboard_only() { + let v = region_done_payload(); + assert_eq!(v["clipboard"], true); + assert!(v["path"].is_null()); + } + + #[test] + fn region_failed_payload_includes_error() { + let v = region_failed_payload(&"selection cancelled"); + assert_eq!(v["error"], "selection cancelled"); + } +} diff --git a/src/main.rs b/src/main.rs index 8e79de6..9a474d2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,9 @@ mod capture; mod config; +mod listen; use anyhow::Result; -use clap::Parser; +use clap::{Args, Parser, Subcommand}; use std::path::PathBuf; use tracing_subscriber::EnvFilter; @@ -14,12 +15,19 @@ use config::Config; name = "breadshot", version, about = "Screenshot utility for the bread ecosystem", - disable_help_subcommand = true, + disable_help_subcommand = true )] struct Cli { - /// Capture mode - mode: Mode, + #[command(subcommand)] + command: Command, + /// Path to config file + #[arg(long, value_name = "FILE", global = true)] + config: Option, +} + +#[derive(Args)] +struct CaptureOpts { /// Copy to clipboard only, don't save to disk #[arg(long, short = 'c')] clipboard_only: bool, @@ -39,10 +47,37 @@ struct Cli { /// Override output filename (without path) #[arg(long, short = 'f', value_name = "NAME")] filename: Option, +} - /// Path to config file - #[arg(long, value_name = "FILE")] - config: Option, +#[derive(Subcommand)] +enum Command { + /// Select a region interactively + Region(CaptureOpts), + /// Click to select a window + Window(CaptureOpts), + /// Click to select a monitor + Output(CaptureOpts), + /// Capture the active window + #[command(name = "active-window")] + ActiveWindow(CaptureOpts), + /// Capture the active monitor + #[command(name = "active-output")] + ActiveOutput(CaptureOpts), + /// Subscribe to bread.command.shot.** and honor region captures + Listen, +} + +impl Command { + fn into_capture(self) -> Option<(Mode, CaptureOpts)> { + match self { + Self::Region(opts) => Some((Mode::Region, opts)), + Self::Window(opts) => Some((Mode::Window, opts)), + Self::Output(opts) => Some((Mode::Output, opts)), + Self::ActiveWindow(opts) => Some((Mode::ActiveWindow, opts)), + Self::ActiveOutput(opts) => Some((Mode::ActiveOutput, opts)), + Self::Listen => None, + } + } } fn main() -> Result<()> { @@ -58,15 +93,19 @@ fn main() -> Result<()> { None => Config::load()?, }; + let Some((mode, opts)) = cli.command.into_capture() else { + return listen::run(&config); + }; + capture::run( - &cli.mode, + &mode, &config, Overrides { - clipboard_only: cli.clipboard_only, - silent: cli.silent, - freeze: cli.freeze, - output_dir: cli.output_dir, - filename: cli.filename, + clipboard_only: opts.clipboard_only, + silent: opts.silent, + freeze: opts.freeze, + output_dir: opts.output_dir, + filename: opts.filename, }, ) }