diff --git a/CLAUDE.md b/CLAUDE.md index 60f0005..9ac62fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,21 +2,7 @@ Scope: this file covers *repo hygiene* — branching, remotes, CI, cleanup. It is not project documentation. -This repo follows the branch/release workflow documented in `CONTRIBUTING.md` -— read and follow it for any git, branch, or release work here (the -single-trunk model, `feature/x`/`fix/x` branch naming, how RC tags work, -etc). Don't improvise a different workflow. The short version: there is one -long-lived branch, `main` — no `dev` or `beta` branch exists. `main` -auto-publishes a dev-track build on every push. "Beta" and "stable" are both -just tags, not branches: push a `vX.Y.Z-rc.N` tag to publish a beta-track -build, push a plain `vX.Y.Z` tag to cut the signed stable release. -"Freezing" for stabilization means pausing pushes to `main`, not moving a -branch. This replaced an earlier three-branch (`dev`/`beta`/`main`) model -after `main` was found to have silently rotted out of sync with `dev`/`beta` -across most repos in this ecosystem — a manual "merge beta into main -monthly" step nobody reliably did across a dozen-plus repos. Collapsing to -one branch removes the class of bug; there's nothing left that can fall out -of sync. +Follow [`CONTRIBUTING.md`](CONTRIBUTING.md) for any git, branch, or release work. Channel/track policy lives in [`docs/release-channels.md`](docs/release-channels.md). The product list is [`registry/bread-ecosystem.toml`](registry/bread-ecosystem.toml) — regenerate the README table with `scripts/gen-readme-products.sh` after editing it. Don't invent a second long-lived branch; there is only `main`. ## Remotes - `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8d141b2..3535b7a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,9 +1,12 @@ # Contributing -This repo hosts `bakery` (the ecosystem package manager) and `bread-theme` -(the shared theming crate). Other ecosystem products (`bread`, `breadbar`, -`breadbox`, …) live in their own repos under `Breadway/` but follow the same -workflow described here. +This repo is a Cargo workspace. Bakery-channel products shipped from here +are `bakery` (the ecosystem package manager) and `bread-theme` (the shared +theming crate). Shared crates that sibling apps pin — not bakery packages +of their own — are `bread-utils`, `bread-onnx`, `bread-screenshots`, and +`bread-capture`. Other ecosystem products (`bread`, `breadbar`, `breadbox`, +…) live in their own repos under `Breadway/` but follow the same workflow +described here. The product list is `registry/bread-ecosystem.toml`. ## Branches @@ -76,8 +79,10 @@ cargo build --release -p bakery cargo test --release -p bakery ``` -Both `bakery` and `bread-theme` are members of this workspace's Cargo.toml. -Run the same commands with `-p bread-theme --bin bread-theme` for that crate. +`bakery`, `bread-theme`, `bread-utils`, `bread-onnx`, `bread-screenshots`, +and `bread-capture` are all workspace members. Run the same commands with +`-p bread-theme --bin bread-theme` for that crate, or `-p bread-utils +--features bread-client` for the IPC client. ## CI diff --git a/README.md b/README.md index 22c8ceb..d3ed536 100644 --- a/README.md +++ b/README.md @@ -4,20 +4,36 @@ A collection of Rust tools for the Linux desktop (Hyprland / Wayland / Arch). Install any product with a single command — no Rust toolchain required. ```sh -curl https://breadway.dev/get | sh +curl -fsSL https://get.breadway.dev | sh bakery install breadbar ``` ## Products +The table below is generated from [`registry/bread-ecosystem.toml`](registry/bread-ecosystem.toml). Regenerate with `scripts/gen-readme-products.sh`. + + + | Package | Description | |---------|-------------| -| `bread` | Reactive automation daemon (`breadd`) + CLI — Lua scripting over Hyprland, udev, power, network, and Bluetooth events | -| `breadbar` | GTK4 status bar (workspaces, clock, CPU/RAM/battery/WiFi/Bluetooth) and D-Bus notification daemon for Hyprland | -| `breadbox` | GTK4 fuzzy app launcher for Hyprland with context-aware sorting; ships an icon-sync daemon (`breadbox-sync`) | -| `breadcrumbs` | Profile-aware Wi-Fi state machine with Tailscale exit-node management and a self-healing watch daemon | -| `breadpad` | Quick-capture scratchpad popup with AI-powered note classification, reminders, recurrence, and a full note viewer (`breadman`) | +| `bakery` | Bread ecosystem package manager | +| `bread-theme` | Shared pywal-accented, fixed-dark-base theming CLI for the bread ecosystem | +| `bread` | Reactive automation daemon and CLI for Linux desktops | +| `breadbar` | Minimal status bar and notification daemon for Hyprland | +| `breadbox` | App launcher for Hyprland / Wayland | +| `breadcrumbs` | Profile-aware Wi-Fi state machine with Tailscale integration | +| `breadpad` | Quick-capture scratchpad and note viewer with AI classification | | `breadpaper` | Wallpaper manager for the bread desktop | +| `breadmon` | Terminal UI monitor manager for Hyprland | +| `breadsearch` | Semantic system-wide search for BOS | +| `breadclip` | Wayland clipboard history manager for Hyprland | +| `breadshot` | Screenshot utility for the bread ecosystem | +| `bos-settings` | System settings app for Bread OS | +| `breadhelp` | Onboarding and help center for Bread OS | +| `breadcast` | Cast your screen to any Chromecast/Google TV or DLNA renderer — daemon + GTK4 popup — Bakery product; not included in the BOS ISO | +| `breadarr` | Single-daemon Sonarr+Radarr+Prowlarr replacement — release watching, matching, grabbing, importing, and a terminal UI, no web UI — Homelab, not shipped on BOS | + + ## Recommended keybinds @@ -68,9 +84,7 @@ spacing, radii, colour roles) the stylesheet is built from. `bakery` is the package manager for the ecosystem. Install it with the bootstrap script: ```sh -curl https://breadway.dev/get | sh -# or -curl -sSfL https://get.breadway.dev | sh +curl -fsSL https://get.breadway.dev | sh ``` The script downloads the prebuilt `bakery` binary to `~/.local/bin/bakery` and prints a note if that directory isn't on your `PATH` yet. @@ -109,16 +123,23 @@ Install all required deps with `sudo pacman -S `. Use `pacman -Q ## Workspace -This repo is a Cargo workspace: +This repo is a Cargo workspace. Bakery-channel products shipped from here +are `bakery` and `bread-theme`; the other members are shared crates sibling +apps pin, not bakery packages of their own. ``` bread-ecosystem/ -├── bakery/ # package manager binary -├── bread-theme/ # shared pywal + fixed-dark-base theming crate -├── registry/ # bread-ecosystem.toml — product registry +├── bakery/ # package manager binary +├── bread-theme/ # shared pywal + fixed-dark-base theming crate +├── bread-utils/ # shared plumbing (Hyprland IPC, singleton, XDG, BreadClient, …) +├── bread-onnx/ # shared ONNX runtime helpers +├── bread-screenshots/ # grim capture primitive used by app `--screenshot` modes +├── bread-capture/ # orchestrator that drives those `--screenshot` modes +├── registry/ # bread-ecosystem.toml — product registry └── scripts/ - ├── get.sh # curl | sh bootstrap - └── gen-index.sh # generates dl.breadway.dev/index.json from release artifacts + ├── get.sh # curl | sh bootstrap + ├── gen-index.sh # generates dl.breadway.dev/index.json from release artifacts + └── gen-readme-products.sh # rewrites the Products table from the registry ``` ## Release pipeline @@ -133,7 +154,7 @@ and mirrors the binary to GitHub Releases as a fallback. to the GitHub Release URL recorded in the manifest. Beyond stable releases, most products also publish **dev** and **beta** -tracks — continuous builds off the `dev` and `beta` branches, respectively. +tracks — continuous builds off `main` (dev) and `vX.Y.Z-rc.N` tags (beta). See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the branch/release workflow and [`docs/release-channels.md`](docs/release-channels.md) for the full track policy. Switch tracks with `bakery track set `. diff --git a/bread-utils/src/bread_client.rs b/bread-utils/src/bread_client.rs index 9976fc6..6a0a895 100644 --- a/bread-utils/src/bread_client.rs +++ b/bread-utils/src/bread_client.rs @@ -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..*` 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..` as an **unsourced** IPC emit +//! (`params` is `{ event, data }` only — no `source`/`kind`). The +//! daemon treats unsourced `bread.command..*` 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..` 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..*` 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 { + 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 { + 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(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, Option); + 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] diff --git a/bread-utils/src/lib.rs b/bread-utils/src/lib.rs index 0817ad3..517b74a 100644 --- a/bread-utils/src/lib.rs +++ b/bread-utils/src/lib.rs @@ -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; diff --git a/bread-utils/src/screenshot_cli.rs b/bread-utils/src/screenshot_cli.rs new file mode 100644 index 0000000..34cc072 --- /dev/null +++ b/bread-utils/src/screenshot_cli.rs @@ -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, + /// PNG path to write (`--output`). Required together with `screenshot`. + pub output: Option, + /// 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) + ); + } +} diff --git a/docs/release-channels.md b/docs/release-channels.md index ad2b286..242b03b 100644 --- a/docs/release-channels.md +++ b/docs/release-channels.md @@ -38,13 +38,16 @@ only if: `archlinux:latest` container and `curl -X PUT`s the resulting `.pkg.tar.zst` to `https://git.breadway.dev/api/packages/Breadway/arch/os`. -A repo can be on **both** channels (most GUI/daemon apps are — see -breadbar, breadbox, breadcrumbs, bread, breadpad, breadpaper), **bakery -only** (breadclip, breadmon, breadsearch, breadshot, bread-theme, bakery -itself), **pacman only** (breadlock, breadhelp — both are OS-integration -pieces where package-manager rigor matters more than a curl-script), or -**neither** (dev-only / not yet released; no bakery.toml, no PKGBUILD, no -release or package workflow — just the repo itself, e.g. breadarr today). +A repo can be on **both** channels, **bakery only** (bread, breadbar, +breadbox, breadcrumbs, breadpad, breadpaper, breadclip, breadmon, +breadsearch, breadshot, breadhelp, bos-settings, bread-theme, breadcast, +breadarr, bakery itself), **pacman only** (breadlock — installs a +root-owned `/etc/pam.d/breadlock` PAM service file with no per-user +equivalent, so it can never move to bakery), or **neither** (dev-only / +not yet released). Desktop apps dropped pacman packaging; bakery-channel +install is the supported path. `bakery` still carries a leftover +`package.yml` / `packaging/arch/PKGBUILD` from when it was also published +to the `[breadway]` pacman repo. `bos` is a fourth, deliberately special case: it ships as an ISO, not a binary, via its own `release-iso.yml`. It is never on either channel and @@ -159,14 +162,15 @@ missing it; that gap is intentional and about to be moot everywhere. | Repo | bakery | pacman | tracks | notes | |---|---|---|---|---| -| bread-ecosystem (bakery product) | yes | yes | stable, beta, dev | single-trunk model; `release-bakery.yml` recovered from a dead `.github/workflows/release.yml` that referenced a `hestia` self-hosted runner GitHub never had registered | +| bread-ecosystem (bakery product) | yes | leftover `package.yml` | stable, beta, dev | bakery-channel (`curl -fsSL https://get.breadway.dev \| sh`) is the supported install; `package.yml` + `packaging/arch/PKGBUILD` remain from when bakery was also published to `[breadway]` | | bread-ecosystem (bread-theme product) | yes | no | stable, beta, dev | single-trunk model | | bread, breadbar, breadbox, breadcrumbs, breadpad, breadpaper | yes | no | stable, beta, dev | pacman packaging (PKGBUILD + `package.yml`) dropped — bakery-only, single-trunk model | | breadclip, breadmon, breadsearch, breadshot | yes | no | stable, beta, dev | single-trunk model | -| breadhelp, bos-settings | yes | no | stable, beta, dev | both moved onto bakery this cycle (previously pacman-only or partially wired); single-trunk model | +| breadhelp, bos-settings | yes | no | stable, beta, dev | bakery-channel desktop/settings apps; not pacman | | breadlock | no | yes | n/a | deliberate, permanent exception — installs a root-owned `/etc/pam.d/breadlock` PAM service file with no per-user equivalent, so it can never move to bakery | | bos | no | no | n/a | ISO-only via `release-iso.yml`; ships via a manual local build (`build-local.sh`), not a CI track — see its own branch note below | -| breadarr | no | no | n/a | had an orphaned `bakery.toml` with no registry entry and zero workflows; removed. Not yet assigned a channel — do that deliberately when it's ready to ship, don't infer it from a stray config file | +| breadcast | yes | no | stable, beta, dev | bakery product; not included in the BOS ISO | +| breadarr | yes | no | stable, beta, dev | bakery product; homelab, not shipped on BOS | `bos` doesn't follow the tracks table above (it has no `dev`/`beta`/`stable` publish cadence — ISO builds are deliberate and manual) but does share the diff --git a/registry/bread-ecosystem.toml b/registry/bread-ecosystem.toml index d68a09d..a60aa9d 100644 --- a/registry/bread-ecosystem.toml +++ b/registry/bread-ecosystem.toml @@ -81,8 +81,10 @@ description = "Onboarding and help center for Bread OS" name = "breadcast" repo = "Breadway/breadcast" description = "Cast your screen to any Chromecast/Google TV or DLNA renderer — daemon + GTK4 popup" +notes = "Bakery product; not included in the BOS ISO" [[products]] name = "breadarr" repo = "Breadway/breadarr" description = "Single-daemon Sonarr+Radarr+Prowlarr replacement — release watching, matching, grabbing, importing, and a terminal UI, no web UI" +notes = "Homelab, not shipped on BOS" diff --git a/scripts/gen-readme-products.sh b/scripts/gen-readme-products.sh new file mode 100755 index 0000000..6abfe06 --- /dev/null +++ b/scripts/gen-readme-products.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Rewrite the marked Products table in README.md from +# registry/bread-ecosystem.toml (the source of truth). +# +# Markers (must exist in README.md): +# +# ...generated markdown... +# +# +# Optional per-product `notes` in the registry is appended to the +# description after an em-dash (used for "homelab, not BOS" / "not in ISO"). +# +# Usage: scripts/gen-readme-products.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REGISTRY="${SCRIPT_DIR}/registry/bread-ecosystem.toml" +README="${SCRIPT_DIR}/README.md" +START="" +END="" + +if [[ ! -f "${REGISTRY}" ]]; then + echo "error: registry not found at ${REGISTRY}" >&2 + exit 2 +fi +if [[ ! -f "${README}" ]]; then + echo "error: README not found at ${README}" >&2 + exit 2 +fi + +python3 - "${REGISTRY}" "${README}" "${START}" "${END}" <<'PY' +import sys +from pathlib import Path + +try: + import tomllib +except ImportError: # pragma: no cover — 3.11+ is required + import tomli as tomllib # type: ignore + +registry_path, readme_path, start, end = sys.argv[1:] + +with open(registry_path, "rb") as f: + registry = tomllib.load(f) + +products = registry.get("products") or [] +if not products: + print("error: registry has no [[products]]", file=sys.stderr) + sys.exit(1) + +lines = ["| Package | Description |", "|---------|-------------|"] +for product in products: + name = product["name"] + desc = str(product.get("description") or "").replace("|", "\\|") + notes = str(product.get("notes") or "").replace("|", "\\|") + if notes: + desc = f"{desc} — {notes}" + lines.append(f"| `{name}` | {desc} |") +table = "\n".join(lines) + +readme = Path(readme_path) +text = readme.read_text() +start_at = text.find(start) +end_at = text.find(end) +if start_at < 0 or end_at < 0 or end_at < start_at: + print( + f"error: README.md is missing markers {start!r} / {end!r}", + file=sys.stderr, + ) + sys.exit(1) + +rewritten = text[:start_at] + start + "\n\n" + table + "\n\n" + end + text[end_at + len(end):] +if rewritten != text: + readme.write_text(rewritten) + print(f"updated {readme_path} ({len(products)} products)") +else: + print(f"{readme_path} already matches the registry ({len(products)} products)") +PY diff --git a/scripts/get.sh b/scripts/get.sh index 6a1707e..182456e 100755 --- a/scripts/get.sh +++ b/scripts/get.sh @@ -1,7 +1,6 @@ #!/bin/sh # Bootstrap script: downloads and installs the `bakery` binary. -# Usage: curl https://breadway.dev/get | sh -# Or: curl -sSfL https://breadway.dev/get | sh +# Usage: curl -fsSL https://get.breadway.dev | sh set -eu # Pinned minisign public key for the bakery release binary. Matches the @@ -20,6 +19,14 @@ die() { echo "error: $*" >&2; exit 1; } uname -m | grep -q x86_64 || die "bakery only supports x86_64 (got $(uname -m))" uname -s | grep -q Linux || die "bakery only supports Linux (got $(uname -s))" +# Signature verification is mandatory. Checksum-only is not sufficient — +# the binary and its .sha256 typically come from the same server, so a +# compromised host can serve a matching pair. Fail closed if minisign +# isn't here rather than downloading something we refuse to trust. +if ! command -v minisign >/dev/null 2>&1; then + die "minisign is required to verify bakery. Install it: pacman -S minisign / apt install minisign" +fi + # Build download URLs. GitHub's "latest" redirect lives at a different path from # versioned releases, so we handle them separately and always prefix tags with 'v'. if [ "${BAKERY_VERSION}" = "latest" ]; then @@ -55,42 +62,33 @@ echo "downloading bakery…" if fetch "${DL_PRIMARY}" "${TMP}" 2>/dev/null; then echo " from dl.breadway.dev" sig_url="${SIG_URL}" - checksum_only_fallback_note=" warning: could not fetch checksum — skipping verification" + sig_url_alt="${SIG_FALLBACK}" elif fetch "${DL_FALLBACK}" "${TMP}" 2>/dev/null; then echo " from GitHub (fallback)" sig_url="${SIG_FALLBACK}" - checksum_only_fallback_note=" warning: no checksum available for GitHub fallback download" + sig_url_alt="${SIG_URL}" else die "failed to download bakery from both primary and fallback URLs" fi -# Signature verification is the authoritative check: it proves the binary -# was produced by whoever holds the bakery signing key, not just that bytes -# match whatever the same (possibly compromised) server also reports as the -# checksum. Prefer it whenever both a .minisig is published and a minisign -# verifier is available on this machine. -sig_verified=0 +# Signature is required. A missing .minisig is a refuse-to-install, not a +# warning — checksum-only is not a substitute. if fetch "${sig_url}" "${TMP}.minisig" 2>/dev/null; then - if command -v minisign >/dev/null 2>&1; then - if minisign -V -q -m "${TMP}" -x "${TMP}.minisig" -P "${BAKERY_MINISIGN_PUBKEY}"; then - echo " signature verified (minisign)" - sig_verified=1 - else - die "minisign signature verification FAILED — refusing to install a binary that doesn't match the pinned bakery key" - fi - else - echo " warning: 'minisign' is not installed — cannot verify the binary's" >&2 - echo " warning: signature, only its checksum. Install minisign for the" >&2 - echo " warning: strongest guarantee: pacman -S minisign / apt install minisign" >&2 - fi + : +elif [ "${sig_url_alt}" != "${sig_url}" ] && fetch "${sig_url_alt}" "${TMP}.minisig" 2>/dev/null; then + echo " signature fetched from fallback URL" else - echo " warning: no .minisig published for this release yet — signature not verified" >&2 + die "could not fetch bakery-x86_64.minisig — refusing to install an unsigned binary" fi -# Checksum is a secondary, best-effort check (kept for defense in depth and -# for the case where minisign isn't installed). It is not a substitute for -# signature verification: both the binary and its checksum typically come -# from the same server, so a compromised server can serve a matching pair. +if minisign -V -q -m "${TMP}" -x "${TMP}.minisig" -P "${BAKERY_MINISIGN_PUBKEY}"; then + echo " signature verified (minisign)" +else + die "minisign signature verification FAILED — refusing to install a binary that doesn't match the pinned bakery key" +fi + +# Checksum is defense-in-depth only, and never enough on its own. A +# mismatch still dies; a missing .sha256 is fine once the signature passed. if fetch "${SHA256_URL}" "${TMP}.sha256" 2>/dev/null; then expected="$(awk '{print $1}' "${TMP}.sha256")" actual="$(sha256sum "${TMP}" | awk '{print $1}')" @@ -98,12 +96,6 @@ if fetch "${SHA256_URL}" "${TMP}.sha256" 2>/dev/null; then die "SHA-256 checksum mismatch (expected ${expected}, got ${actual})" fi echo " checksum verified" -else - echo "${checksum_only_fallback_note}" -fi - -if [ "${sig_verified}" -ne 1 ]; then - echo " warning: proceeding WITHOUT a verified signature on the bakery binary" >&2 fi chmod +x "${TMP}"