platform: BreadClient command/health, fail-closed get.sh, registry README

Add BreadClient::command (unsourced bread.command.<app>.<verb> emit) plus
health/api_version, and a clap-free screenshot_cli helper for the next pin.
get.sh now dies if minisign or .minisig is missing — checksum-only is not
enough to install. Generate the README products table from the registry,
and refresh release-channels/CONTRIBUTING/CLAUDE.md to match.
This commit is contained in:
Breadway 2026-08-15 21:39:08 +08:00
parent 69ce2d67a8
commit 08b71262da
10 changed files with 494 additions and 106 deletions

View file

@ -14,10 +14,15 @@
//!
//! A sibling app must never crash or block because breadd is down,
//! restarting, or was never installed. Concretely:
//! - [`BreadClient::emit`] is a best-effort, fire-and-forget single-shot
//! connection (mirroring `bread-emit`'s own stance) — if breadd is
//! unreachable, the event is silently dropped, not an error the caller
//! has to handle.
//! - [`BreadClient::emit`] and [`BreadClient::command`] are best-effort,
//! fire-and-forget single-shot connections (mirroring `bread-emit`'s
//! own stance) — if breadd is unreachable, the event is silently
//! dropped, not an error the caller has to handle.
//! - [`BreadClient::health`] / [`BreadClient::api_version`] return `None`
//! when breadd is unreachable or the response is missing fields.
//! Long-running daemons SHOULD log a warning in that case and MUST NOT
//! crash. [`BreadClient::connect`] never fails just because breadd is
//! down — do not change that.
//! - [`BreadClient::subscribe`] runs its read loop on a background thread
//! that reconnects with exponential backoff on any disconnect. The
//! caller's callback simply stops being invoked while disconnected; it
@ -29,6 +34,15 @@
//! outside the app's own `bread.<app_id>.*` segment, so a misconfigured
//! caller fails fast instead of discovering the mistake from the daemon's
//! rejection. The daemon enforces the same rule server-side regardless.
//!
//! `command` is the outbound half of the same story: it publishes
//! `bread.command.<target_app>.<verb>` as an **unsourced** IPC emit
//! (`params` is `{ event, data }` only — no `source`/`kind`). The
//! daemon treats unsourced `bread.command.<known_app>.*` as legal so a
//! sibling can address another app without impersonating that app's
//! own namespace. Local refusal (eprint + return, same stance as
//! `emit`) if `target_app` or `verb` is empty, or if `verb` contains
//! `.` (a command verb is a single segment).
use std::io::{BufRead, BufReader, Write};
use std::net::Shutdown;
@ -94,25 +108,60 @@ impl BreadClient {
return;
}
let request = json!({
"id": "0",
"method": "emit",
"params": {
"event": event,
"source": self.app_id,
"kind": event,
"data": data,
}
});
let Ok(line) = serde_json::to_string(&request) else {
return;
};
fire_and_forget_emit(json!({
"event": event,
"source": self.app_id,
"kind": event,
"data": data,
}));
}
let Ok(mut stream) = UnixStream::connect(bread_shared::resolve_socket_path()) else {
/// Publish `bread.command.<target_app>.<verb>` as an unsourced IPC
/// emit so another bread app (or a Lua workflow) can act on it.
///
/// Fire-and-forget: same silent-if-down stance as [`emit`]. Locally
/// refuses (eprint + return, no socket) if `target_app` or `verb` is
/// empty, or if `verb` contains `.` — a verb is one segment
/// (`clear`, not `history.clear`).
///
/// The wire payload is `{ method: "emit", params: { event, data } }`
/// with **no** `source`/`kind`. Do not add those: a sourced emit
/// would have to claim the *target's* namespace (or ours), and the
/// daemon half of this integration is specifically making unsourced
/// `bread.command.<known_app>.*` legal.
pub fn command(&self, target_app: &str, verb: &str, data: Value) {
if target_app.is_empty() || verb.is_empty() || verb.contains('.') {
eprintln!(
"bread-client: refusing to send command to '{target_app}' with verb '{verb}' \
(target and verb must be non-empty; verb must be a single segment)"
);
return;
};
let _ = stream.set_write_timeout(Some(Duration::from_millis(200)));
let _ = writeln!(stream, "{line}");
}
let event = format!("bread.command.{target_app}.{verb}");
fire_and_forget_emit(json!({
"event": event,
"data": data,
}));
}
/// One-shot `health` IPC request. `None` if breadd is unreachable or
/// the response is malformed / an error.
///
/// Long-running daemons SHOULD log a warning when this returns
/// `None` (or when [`api_version`] is missing) and MUST NOT crash.
pub fn health(&self) -> Option<Value> {
self.request("health", json!({}))
}
/// `api_version` string from [`health`], or `None` if health failed
/// or the field is absent. Same SHOULD-warn / MUST-NOT-crash rule
/// as [`health`].
pub fn api_version(&self) -> Option<String> {
self.health()?
.get("api_version")
.and_then(Value::as_str)
.map(str::to_owned)
}
/// Send a one-shot IPC request and return its `result`, or `None` on any
@ -205,6 +254,27 @@ impl BreadClient {
}
}
/// Fire-and-forget a single `emit` request. Shared by sourced [`BreadClient::emit`]
/// and unsourced [`BreadClient::command`] so the write/timeout path cannot
/// drift. Silent if the socket is missing, the write fails, or the body
/// cannot be serialized.
fn fire_and_forget_emit(params: Value) {
let request = json!({
"id": "0",
"method": "emit",
"params": params,
});
let Ok(line) = serde_json::to_string(&request) else {
return;
};
let Ok(mut stream) = UnixStream::connect(bread_shared::resolve_socket_path()) else {
return;
};
let _ = stream.set_write_timeout(Some(Duration::from_millis(200)));
let _ = writeln!(stream, "{line}");
}
/// Connects once, sends `events.subscribe`, and invokes `on_event` for every
/// matching line until the connection ends (cleanly or with an error).
/// Stores the live stream in `current_stream` so [`Subscription::stop`] can
@ -334,12 +404,81 @@ mod tests {
// integration tests for the IPC-side of namespace validation.
}
/// Point `HOME` + `XDG_RUNTIME_DIR` at an empty temp dir so
/// `resolve_socket_path` cannot find a live breadd (either via
/// `~/.config/bread/breadd.toml` or `$XDG_RUNTIME_DIR/bread/breadd.sock`).
/// Serialized with the other env-mutating tests via `env_test_lock`.
fn with_unreachable_daemon<T>(f: impl FnOnce() -> T) -> T {
let _lock = crate::env_test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
let old_home = std::env::var("HOME").ok();
let old_xdg = std::env::var("XDG_RUNTIME_DIR").ok();
unsafe {
std::env::set_var("HOME", tmp.path());
std::env::set_var("XDG_RUNTIME_DIR", tmp.path());
}
struct Restore(Option<String>, Option<String>);
impl Drop for Restore {
fn drop(&mut self) {
unsafe {
match &self.0 {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
match &self.1 {
Some(v) => std::env::set_var("XDG_RUNTIME_DIR", v),
None => std::env::remove_var("XDG_RUNTIME_DIR"),
}
}
}
}
let _restore = Restore(old_home, old_xdg);
f()
}
#[test]
fn request_returns_none_when_daemon_is_unreachable() {
// No daemon present in the test environment; must return None
// promptly rather than blocking or panicking.
with_unreachable_daemon(|| {
let client = BreadClient::connect("clip");
assert!(client.request("widgets.list", json!(null)).is_none());
});
}
#[test]
fn command_refuses_empty_target_without_connecting() {
let client = BreadClient::connect("clip");
assert!(client.request("widgets.list", json!(null)).is_none());
client.command("", "clear", json!({}));
}
#[test]
fn command_refuses_empty_verb_without_connecting() {
let client = BreadClient::connect("clip");
client.command("clip", "", json!({}));
}
#[test]
fn command_refuses_dotted_verb_without_connecting() {
// A verb is a single segment — `history.clear` would produce
// `bread.command.clip.history.clear`, which is two verb segments.
let client = BreadClient::connect("clip");
client.command("clip", "history.clear", json!({}));
}
#[test]
fn command_is_a_silent_no_op_when_daemon_is_unreachable() {
let client = BreadClient::connect("clip");
client.command("clip", "clear", json!({ "n": 1 }));
}
#[test]
fn health_and_api_version_return_none_when_daemon_is_unreachable() {
with_unreachable_daemon(|| {
let client = BreadClient::connect("clip");
assert!(client.health().is_none());
assert!(client.api_version().is_none());
});
}
#[test]

View file

@ -19,12 +19,18 @@
//! - [`gtk_popup`] (feature `gtk`) — shared layer-shell popup window setup,
//! list navigation, and click-outside-to-close.
//! - [`bread_client`] (feature `bread-client`) — a persistent-connection
//! client for breadd's IPC socket (emit + subscribe), for sibling
//! `bread*` app daemons integrating with the bread automation fabric.
//! client for breadd's IPC socket (`emit`, unsourced `command`,
//! `health`/`api_version`, `subscribe`), for sibling `bread*` app
//! daemons integrating with the bread automation fabric.
//! - [`screenshot_cli`] — shared `--screenshot` / `--output` /
//! `--width` / `--height` values, `SETTLE_DELAY` (300ms), and the
//! "both flags or neither" validator. Next-pin helper; no clap/GTK
//! dependency. Does not replace `bread-screenshots` or `bread-capture`.
pub mod atomic;
pub mod hypr;
pub mod proc;
pub mod screenshot_cli;
pub mod singleton;
pub mod xdg;

View file

@ -0,0 +1,156 @@
//! Shared `--screenshot` CLI flags and the post-`map` settle delay used by
//! bread-capture-driven GTK apps.
//!
//! The same clap block (`--screenshot`, `--output`, `--width`, `--height`)
//! plus a 300ms settle after GTK `map` is cloned across breadbar, breadbox,
//! breadclip, breadpad, breadman, breadsearch, and breadhelp. This module
//! is the next-pin target for that duplication — consumers this cycle still
//! pin an older bread-utils tag and will not see it until a future release.
//!
//! Zero extra deps: no clap, no GTK. Apps keep (or flatten) the four `#[arg]`
//! fields themselves and call [`validate_pair`] / [`SETTLE_DELAY`].
//!
//! Confirmed present in:
//! - `breadbar/src/screenshot.rs`
//! - `breadbox/breadbox/src/screenshot.rs`
//! - `breadclip/breadclip/src/screenshot.rs`
//! - `breadsearch/breadsearch/src/screenshot.rs`
//! - `breadpad/breadpad/src/screenshot.rs`
//! - `breadpad/breadman/src/screenshot.rs`
//! - `breadhelp/src/screenshot.rs`
//!
//! Do not fold `bread-screenshots` or `bread-capture`'s `TARGETS` table into
//! this module — those are the capture primitive and the orchestrator, not
//! the per-app CLI flags.
use std::path::{Path, PathBuf};
use std::time::Duration;
/// Extra settle time after GTK `map` for the first frame to actually paint
/// before grim runs. `map` fires once the surface exists, not once anything
/// has been drawn into it.
pub const SETTLE_DELAY: Duration = Duration::from_millis(300);
/// Default `--width`, matching `bread-capture --isolate-width`.
pub const DEFAULT_WIDTH: u32 = 1920;
/// Default `--height`, matching `bread-capture --isolate-height`.
pub const DEFAULT_HEIGHT: u32 = 1080;
/// The four `--screenshot` / `--output` / `--width` / `--height` values
/// parsed from an app's CLI.
///
/// `screenshot` and `output` must both be present (a capture run) or both
/// absent (a normal run) — see [`ScreenshotCli::validate`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScreenshotCli {
/// Named view to capture (`--screenshot`). `None` for a normal run.
pub screenshot: Option<String>,
/// PNG path to write (`--output`). Required together with `screenshot`.
pub output: Option<PathBuf>,
/// Capture canvas width (`--width`).
pub width: u32,
/// Capture canvas height (`--height`).
pub height: u32,
}
impl Default for ScreenshotCli {
fn default() -> Self {
Self {
screenshot: None,
output: None,
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
}
}
}
/// Why a screenshot-flag pair is invalid.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScreenshotCliError {
/// `--screenshot` was given without `--output`.
ScreenshotWithoutOutput,
/// `--output` was given without `--screenshot`.
OutputWithoutScreenshot,
}
impl std::fmt::Display for ScreenshotCliError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ScreenshotWithoutOutput => write!(f, "--screenshot requires --output"),
Self::OutputWithoutScreenshot => write!(f, "--output requires --screenshot"),
}
}
}
impl std::error::Error for ScreenshotCliError {}
/// Both `--screenshot` and `--output` must be present, or neither.
pub fn validate_pair(
screenshot: Option<&str>,
output: Option<&Path>,
) -> Result<(), ScreenshotCliError> {
match (screenshot, output) {
(Some(_), Some(_)) | (None, None) => Ok(()),
(Some(_), None) => Err(ScreenshotCliError::ScreenshotWithoutOutput),
(None, Some(_)) => Err(ScreenshotCliError::OutputWithoutScreenshot),
}
}
impl ScreenshotCli {
/// Both `screenshot` and `output` present, or neither.
pub fn validate(&self) -> Result<(), ScreenshotCliError> {
validate_pair(self.screenshot.as_deref(), self.output.as_deref())
}
/// `true` when this is a capture run (both flags present).
pub fn is_screenshot_run(&self) -> bool {
self.screenshot.is_some() && self.output.is_some()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settle_delay_is_300ms() {
assert_eq!(SETTLE_DELAY, Duration::from_millis(300));
}
#[test]
fn neither_flag_is_ok() {
assert!(validate_pair(None, None).is_ok());
assert!(ScreenshotCli::default().validate().is_ok());
assert!(!ScreenshotCli::default().is_screenshot_run());
}
#[test]
fn both_flags_are_ok() {
let cli = ScreenshotCli {
screenshot: Some("search".into()),
output: Some(PathBuf::from("/tmp/out.png")),
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
};
assert!(cli.validate().is_ok());
assert!(cli.is_screenshot_run());
}
#[test]
fn screenshot_without_output_is_an_error() {
assert_eq!(
validate_pair(Some("search"), None),
Err(ScreenshotCliError::ScreenshotWithoutOutput)
);
}
#[test]
fn output_without_screenshot_is_an_error() {
let path = PathBuf::from("/tmp/out.png");
assert_eq!(
validate_pair(None, Some(path.as_path())),
Err(ScreenshotCliError::OutputWithoutScreenshot)
);
}
}