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

@ -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.

View file

@ -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

View file

@ -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`.
<!-- gen-readme-products:start -->
| 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 |
<!-- gen-readme-products:end -->
## 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 <packages>`. Use `pacman -Q <pkg>
## 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
├── 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
├── 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 <stable|beta|dev>`.

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": {
fire_and_forget_emit(json!({
"event": event,
"source": self.app_id,
"kind": event,
"data": data,
}));
}
});
let Ok(line) = serde_json::to_string(&request) else {
return;
};
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");
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)
);
}
}

View file

@ -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

View file

@ -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"

77
scripts/gen-readme-products.sh Executable file
View file

@ -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):
# <!-- gen-readme-products:start -->
# ...generated markdown...
# <!-- gen-readme-products:end -->
#
# 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="<!-- gen-readme-products:start -->"
END="<!-- gen-readme-products: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

View file

@ -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}"