diff --git a/Cargo.lock b/Cargo.lock index a44fd0c..7429009 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -71,8 +71,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 ff77e05..a1f8abe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,4 +9,4 @@ license = "MIT" clap = { version = "4", features = ["derive"] } anyhow = "1" serde_json = "1" -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"] } diff --git a/EVENTS.md b/EVENTS.md index cf531a0..b4cd0f1 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -1,57 +1,67 @@ # breadpaper — bread event integration -breadpaper is a one-shot CLI wallpaper setter: it works exactly the same -with or without `breadd` running. When breadd *is* present, each successful -`breadpaper set` (or the bare-path shorthand) publishes one event into the -shared bread automation fabric. See the parent `bread` repo's +breadpaper is a wallpaper setter: it works exactly the same with or +without `breadd` running. When breadd *is* present, a successful +`breadpaper set` (or the bare-path shorthand) publishes `bread.paper.changed` +into the shared bread automation fabric, and `breadpaper listen` honors +`bread.command.paper.set`. See the parent `bread` repo's `Documentation.md` — specifically its "Namespaces" and "Integrating a bread\* app" sections — for the general convention this follows. App id: **`paper`**. Transport: `bread-utils`'s `bread_client` module -(feature `bread-client`) — the CLI links it directly and uses -`BreadClient::connect("paper")` + `emit` only. v0.7.1 has no `command()` -helper, and breadpaper has no long-running process that could hold a -`subscribe` open. +(feature `bread-client`) — the CLI links it directly. One-shot +`set`/`get` use `BreadClient::connect("paper")` + `emit` only. The +long-running `listen` subcommand holds a `subscribe` open. -There is no `breadpaper` daemon and no `watch` subcommand. A `bread-emit -bread.command.paper.set` (or any other `bread.command.paper.*`) with no -subscriber is a silent no-op — that is the documented bread convention, -not a breadpaper bug. Modules that want to change the wallpaper should +`breadpaper listen` is fail-silent if breadd is down: `subscribe` +reconnects with backoff and simply delivers nothing until the daemon +comes back. The one-shot `set`/`get` path does not require `listen`. +Modules that want to change the wallpaper without a listener can still shell out: ```lua bread.exec("breadpaper set /path/to/image.png") ``` -The one-shot process still emits `bread.paper.changed` on success, so a -workflow can `bread.wait("bread.paper.changed", …)` for the real outcome -instead of assuming the exec finished the set. +A workflow that publishes the command instead should wait for the +confirmation, not assume the emit finished the set: + +```lua +bread.emit("bread.command.paper.set", { path = "/path/to/image.png" }) +bread.wait("bread.paper.set.done", { timeout = 10000 }) +``` ## Events published (`bread.paper.*`) | Event | Data | When | |-------|------|------| -| `bread.paper.changed` | `{ "path": "" }` | After a successful `set` (awww + wal + `bread-theme reload`). `path` is the canonical absolute path that was applied. Not emitted on `get`, and not emitted if any of the three steps fail. | +| `bread.paper.changed` | `{ "path": "" }` | After a successful `set` (awww + wal + `bread-theme reload`), including when `listen` honors `bread.command.paper.set`. `path` is the canonical absolute path that was applied. Not emitted on `get`, and not emitted if any of the three steps fail. | +| `bread.paper.set.done` | `{ "path": "" }` | `bread.command.paper.set` was received and `set()` succeeded. `path` is the canonical absolute path that was applied. Not emitted by the one-shot CLI `set` — that path only publishes `changed`. | +| `bread.paper.set.failed` | `{ "error": "", "path"?: "" }` | `bread.command.paper.set` was received but `set()` failed, or `data.path` was missing/not a string. `path` is the requested (not canonical) path when one was supplied. | ## Commands honored (`bread.command.paper.*`) -None, because there is nobody listening. +Honored only while `breadpaper listen` is running. A +`bread-emit bread.command.paper.set` with no listener is a silent no-op +— that is the documented bread convention, not a breadpaper bug. -| Verb | Data | Status | +| Verb | Data | Effect | |------|------|--------| -| `set` | `{ "path": "..." }` | **Not subscribed.** The same work is `bread.exec("breadpaper set …")`. A future `breadpaper watch` (or a service-mode of this binary) could honor `bread.command.paper.set` and emit `bread.paper.set.done` / `.failed`; that is deliberately not added here — a long-running process whose only job is to re-exec the existing one-shot CLI is not worth the extra surface. | +| `set` | `{ "path": "..." }` | Calls the existing `set()` (awww + wal + `bread-theme reload`). Emits `bread.paper.set.done` / `.failed`. A successful set also emits `bread.paper.changed`. | ### Not implemented: slideshow / library / random / next breadpaper is not a wallpaper library, a slideshow daemon, or a picker. Browsing `~/Pictures/Backgrounds` lives in bos-settings. Do not invent `bread.command.paper.next` / `.random` / `.cycle` (or matching events) -ahead of a real product feature. +ahead of a real product feature. Unrecognized verbs are ignored. ## 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) — breadpaper still sets the wallpaper, generates the palette, and reloads themes. -- There is no command subscription to reconnect, because there is no - long-running subscriber. +- `breadpaper listen` does not exit if breadd is down. The command + subscription reconnects automatically (`BreadClient::subscribe`'s + background thread has its own backoff loop); no restart of `listen` + is needed once breadd returns. diff --git a/README.md b/README.md index 3ab2b86..d0ae2de 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ install -Dm755 target/release/breadpaper ~/.local/bin/breadpaper breadpaper # shorthand for `set` breadpaper set # awww + wal + bread-theme reload breadpaper get # print the current wallpaper path +breadpaper listen # honor bread.command.paper.set (fail-silent if breadd is down) ``` Supported formats: `png`, `jpg`, `jpeg`, `webp`, `gif`, `bmp`. @@ -45,9 +46,10 @@ Supported formats: `png`, `jpg`, `jpeg`, `webp`, `gif`, `bmp`. ## Bread events After a successful `set`, breadpaper emits `bread.paper.changed` if -`breadd` is running (silent no-op if it isn't). There is no daemon and -no command subscription — Lua modules should `bread.exec("breadpaper set …")`. -See [EVENTS.md](EVENTS.md). +`breadd` is running (silent no-op if it isn't). `breadpaper listen` is +the optional long-running subscriber for `bread.command.paper.set`; it +does not start by itself. Lua modules can still +`bread.exec("breadpaper set …")`. See [EVENTS.md](EVENTS.md). ## License diff --git a/src/lib.rs b/src/lib.rs index c9fa141..f846d67 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,9 +3,11 @@ mod theme; mod wallpaper; use std::path::{Path, PathBuf}; +use std::thread; use anyhow::{bail, Context, Result}; -use bread_utils::bread_client::BreadClient; +use bread_utils::bread_client::{BreadClient, BreadEvent}; +use serde_json::{json, Value}; /// App id in bread's sibling-app registry (`KNOWN_APPS`). Events publish as /// `bread.paper.*`. See `EVENTS.md`. @@ -22,15 +24,65 @@ pub fn set(path: &Path) -> Result<()> { Ok(()) } +/// Honor `bread.command.paper.*` until killed. Subscribe reconnects with +/// backoff if breadd is down or restarts — this never errors the caller. +pub fn listen() -> Result<()> { + let client = BreadClient::connect(APP_ID); + let _subscription = client.subscribe("bread.command.paper.**", handle_command); + loop { + thread::park(); + } +} + /// Fire-and-forget `bread.paper.changed`. Silent no-op if breadd is down /// (`BreadClient::emit` never blocks or errors the caller). fn emit_changed(path: &Path) { BreadClient::connect(APP_ID).emit( "bread.paper.changed", - serde_json::json!({ "path": path.to_string_lossy() }), + json!({ "path": path.to_string_lossy() }), ); } +fn handle_command(event: BreadEvent) { + let Some(verb) = event.event.strip_prefix("bread.command.paper.") else { + return; + }; + match verb { + "set" => handle_set(&event.data), + other => { + eprintln!("breadpaper: ignoring unrecognized command verb '{other}'"); + } + } +} + +fn handle_set(data: &Value) { + let client = BreadClient::connect(APP_ID); + let Some(path_str) = data.get("path").and_then(Value::as_str) else { + client.emit( + "bread.paper.set.failed", + json!({ "error": "missing string \"path\"" }), + ); + return; + }; + let path = Path::new(path_str); + match set(path) { + Ok(()) => { + let applied = path + .canonicalize() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|_| path_str.to_string()); + client.emit("bread.paper.set.done", json!({ "path": applied })); + } + Err(e) => { + eprintln!("breadpaper: bread.command.paper.set failed: {e:#}"); + client.emit( + "bread.paper.set.failed", + json!({ "error": format!("{e:#}"), "path": path_str }), + ); + } + } +} + pub fn get() -> Result { let home = std::env::var("HOME").context("HOME not set")?; let wal_file = PathBuf::from(home).join(".cache/wal/wal"); @@ -85,4 +137,40 @@ mod tests { // socket is missing — this is the fail-silent contract. emit_changed(Path::new("/tmp/wallpaper.png")); } + + #[test] + fn subscribe_is_silent_without_breadd() { + let client = BreadClient::connect(APP_ID); + let sub = client.subscribe("bread.command.paper.**", |_| {}); + drop(sub); + } + + #[test] + fn handle_command_ignores_unrecognized_verb() { + handle_command(BreadEvent { + event: "bread.command.paper.next".into(), + timestamp: 0, + data: json!({}), + }); + } + + #[test] + fn handle_command_ignores_events_outside_its_own_command_namespace() { + handle_command(BreadEvent { + event: "bread.command.clip.clear".into(), + timestamp: 0, + data: json!({}), + }); + handle_command(BreadEvent { + event: "bread.paper.changed".into(), + timestamp: 0, + data: json!({ "path": "/tmp/wallpaper.png" }), + }); + } + + #[test] + fn handle_set_missing_path_is_silent_without_breadd() { + handle_set(&json!({})); + handle_set(&json!({ "path": 1 })); + } } diff --git a/src/main.rs b/src/main.rs index d108e3d..959c184 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,6 +21,8 @@ enum Command { }, /// Print the current wallpaper path Get, + /// Honor bread.command.paper.set until killed + Listen, } fn main() { @@ -28,6 +30,7 @@ fn main() { let result = match (cli.command, cli.path) { (Some(Command::Set { path }), _) | (None, Some(path)) => breadpaper::set(&path), + (Some(Command::Listen), _) => breadpaper::listen(), (Some(Command::Get), _) | (None, None) => { breadpaper::get().map(|p| println!("{}", p.display())) }