From 394a252f9e0c85fc15a038a65b114b214d1f4654 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 17 Jul 2026 03:37:51 +0800 Subject: [PATCH 01/99] Fix audit findings: bakery index signing, artifact checksums, stale theme docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add minisign-based signing/verification for the bakery index: scripts/gen-index.sh signs index.json (MINISIGN_SEC_KEY env var, dormant no-op with a loud warning until a key is provisioned); bakery/src/manifest.rs fetches index.json.minisig and verifies it with minisign-verify against a hardcoded PUBKEY before parsing/caching, and re-verifies the cached copy on every load (falls back to one re-fetch if the cache predates signing or fails verification; a fresh fetch that fails verification is a hard error). - Close the previously-unchecksummed config-example and systemd-unit downloads in bakery/src/install.rs (scaffold_config, install_service): index.json now carries `sha256`/`example_sha256` for these artifacts (computed in gen-index.sh), verified via the same download::verify_sha256 used for binaries. Downloads without a matching sha256 in the index are refused rather than installed unverified. - scripts/get.sh now verifies the bakery release binary itself against a pinned minisign public key before installing it (falls back to the existing sha256-only check with a loud warning if no .minisig is published yet or minisign isn't installed; a present-but-invalid signature is a hard failure). - Add dormant "sign release binary" steps to the bakery and bread-theme release workflows (.github/workflows/release.yml, .forgejo/workflows/release-bread-theme.yml), gated on secrets that are not yet configured — binaries ship unsigned exactly as before until the owner wires up the secret. - .gitignore: add *.minisign-sec / minisign.key so the signing key can never be committed by accident. - bread-theme: fix stale docs describing a "Catppuccin Mocha fallback" (BREAD_DESIGN_SYSTEM.md, README.md, Cargo.toml/bakery.toml/registry descriptions) — the actual implementation (palette.rs) uses a fixed BOS dark base with only accent colors from pywal. - bread-theme: fix the legacy css_vars() path, which had its own hand-written @define-color block that predated the `accent` and computed `on-*` ink colors used by the rest of the stylesheet — any caller whose CSS referenced those names against css_vars()'s output would hit undefined colors (the illegible-text bug). css_vars() now delegates to the same define_colors() the full stylesheet uses, so the two can't drift apart again. --- .forgejo/workflows/release-bread-theme.yml | 31 ++++- .github/workflows/release.yml | 42 +++++- .gitignore | 6 + BREAD_DESIGN_SYSTEM.md | 28 ++-- Cargo.lock | 7 + Cargo.toml | 1 + README.md | 14 +- bakery/Cargo.toml | 1 + bakery/src/download.rs | 7 +- bakery/src/install.rs | 47 +++++-- bakery/src/manifest.rs | 142 +++++++++++++++++++-- bread-theme/Cargo.toml | 2 +- bread-theme/bakery.toml | 2 +- bread-theme/src/lib.rs | 65 ++++++---- registry/bread-ecosystem.toml | 2 +- scripts/gen-index.sh | 79 +++++++++++- scripts/get.sh | 72 +++++++++-- scripts/test-gen-index.sh | 8 ++ 18 files changed, 472 insertions(+), 84 deletions(-) diff --git a/.forgejo/workflows/release-bread-theme.yml b/.forgejo/workflows/release-bread-theme.yml index c5e2426..335f982 100644 --- a/.forgejo/workflows/release-bread-theme.yml +++ b/.forgejo/workflows/release-bread-theme.yml @@ -31,7 +31,31 @@ jobs: cp src/bread-theme/bakery.toml "${PKG_DIR}/bakery.toml" ln -sfn "${VERSION}" "/srv/breadway-dl/bread-theme/latest" + # Signs the bread-theme binary with the shared bakery ecosystem signing + # key (same key that signs index.json — get.sh / manifest.rs pin the + # matching public key). BAKERY_MINISIGN_SEC_KEY_PATH is a *path on this + # runner's disk* (hestia has persistent storage, unlike a fresh + # GitHub-hosted runner), not the key contents — see the handoff note + # in scripts/gen-index.sh. Dormant (binary ships unsigned, as today) + # until that secret is provisioned. + - name: sign release binary + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="/srv/breadway-dl/bread-theme/${VERSION}" + if [ -n "${MINISIGN_SEC_KEY:-}" ]; then + minisign -W -S -s "${MINISIGN_SEC_KEY}" -m "${PKG_DIR}/bread-theme-x86_64" \ + -x "${PKG_DIR}/bread-theme-x86_64.minisig" /dev/null || true - gh release upload "${GITHUB_REF_NAME}" --repo Breadway/bread-ecosystem \ - "${PKG_DIR}/bread-theme-x86_64" \ - "${PKG_DIR}/bread-theme-x86_64.sha256" \ - --clobber + ASSETS="${PKG_DIR}/bread-theme-x86_64 ${PKG_DIR}/bread-theme-x86_64.sha256" + [ -f "${PKG_DIR}/bread-theme-x86_64.minisig" ] && ASSETS="${ASSETS} ${PKG_DIR}/bread-theme-x86_64.minisig" + gh release upload "${GITHUB_REF_NAME}" --repo Breadway/bread-ecosystem ${ASSETS} --clobber diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a977bc2..1f9675b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,8 +36,41 @@ jobs: cp bakery.toml "${PKG_DIR}/bakery.toml" ln -sfn "${VERSION}" "${DL_DIR}/bakery/latest" + # Signs the bakery binary itself with the same minisign key that signs + # index.json (get.sh pins the matching public key). Dormant until the + # BAKERY_MINISIGN_SEC_KEY secret is actually provisioned in this repo's + # Actions settings — until then this step logs a warning and the + # binary ships unsigned, exactly as it does today. + - name: sign release binary + env: + MINISIGN_SEC_KEY_CONTENTS: ${{ secrets.BAKERY_MINISIGN_SEC_KEY }} + run: | + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="${DL_DIR}/bakery/${VERSION}" + if [ -n "${MINISIGN_SEC_KEY_CONTENTS}" ]; then + command -v minisign >/dev/null 2>&1 || { echo "::error::minisign not installed on runner"; exit 1; } + KEY_FILE="$(mktemp)" + trap 'shred -u "${KEY_FILE}" 2>/dev/null || rm -f "${KEY_FILE}"' EXIT + printf '%s' "${MINISIGN_SEC_KEY_CONTENTS}" > "${KEY_FILE}" + minisign -W -S -s "${KEY_FILE}" -m "${PKG_DIR}/bakery-x86_64" \ + -x "${PKG_DIR}/bakery-x86_64.minisig" /dev/null || rm -f "${KEY_FILE}"' EXIT + printf '%s' "${MINISIGN_SEC_KEY_CONTENTS}" > "${KEY_FILE}" + MINISIGN_SEC_KEY="${KEY_FILE}" bash "${GITHUB_WORKSPACE}/scripts/gen-index.sh" + else + bash "${GITHUB_WORKSPACE}/scripts/gen-index.sh" + fi - name: upload to GitHub Release env: @@ -47,7 +80,6 @@ jobs: PKG_DIR="${DL_DIR}/bakery/${VERSION}" gh release create "${GITHUB_REF_NAME}" \ --title "bakery v${VERSION}" --generate-notes 2>/dev/null || true - gh release upload "${GITHUB_REF_NAME}" \ - "${PKG_DIR}/bakery-x86_64" \ - "${PKG_DIR}/bakery-x86_64.sha256" \ - --clobber + ASSETS="${PKG_DIR}/bakery-x86_64 ${PKG_DIR}/bakery-x86_64.sha256" + [ -f "${PKG_DIR}/bakery-x86_64.minisig" ] && ASSETS="${ASSETS} ${PKG_DIR}/bakery-x86_64.minisig" + gh release upload "${GITHUB_REF_NAME}" ${ASSETS} --clobber diff --git a/.gitignore b/.gitignore index b83d222..4c046ac 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,7 @@ /target/ + +# minisign secret keys must never be committed — the bakery/index signing +# key lives outside this repo entirely (see scripts/gen-index.sh / +# scripts/get.sh for how it's consumed via MINISIGN_SEC_KEY). +*.minisign-sec +minisign.key diff --git a/BREAD_DESIGN_SYSTEM.md b/BREAD_DESIGN_SYSTEM.md index 942f7a2..ae57901 100644 --- a/BREAD_DESIGN_SYSTEM.md +++ b/BREAD_DESIGN_SYSTEM.md @@ -48,16 +48,27 @@ Establish a visual hierarchy with consistent rounding: ## Color System -All projects use **pywal dynamic theming** with **Catppuccin Mocha** as the fallback palette: +All projects use **pywal dynamic theming** for accents, layered on a **fixed BOS +dark base** — background, surface, overlay, and foreground never come from +pywal, only the accent slots (color1–6) track the current wallpaper: -- **Background**: `#1e1e2e` (Catppuccin) -- **Foreground**: `#cdd6f4` (Catppuccin) -- **Surface**: `#181825` (Catppuccin) -- **Accent**: Dynamic (from pywal) +- **Background**: `#0c0c0c` (fixed) +- **Foreground**: `#e8e8e8` (fixed) +- **Surface**: `#1a1a1a` (fixed, `color0`) +- **Overlay**: `#d8d8d8` (fixed, `color7`) +- **Accent**: Dynamic (from pywal `color4`), with curated bread-toned defaults + before any wallpaper has been set + +Without pinning bg/surface/overlay, a light or muddy-toned wallpaper makes +pywal hand back a light or off-hue background, and every bread GUI's panels +inherit it — see `bread-theme/src/palette.rs` for the implementation. Color palette slots (via wal): -- color0–color7: ANSI colors +- color0–color7: ANSI colors (0 and 7 fixed, 1–6 pywal-derived) - Semantic: red, green, yellow, blue, pink, teal +- Computed ink: `on-bg`, `on-surface`, `on-accent`, `on-red`, `on-overlay` — + black or white text, whichever is legible against that background (see + `bread_theme::ink_on`) ## Component Standards @@ -104,8 +115,9 @@ add only app-specific rules: hardcoded Nord palette; migrated to the shared stylesheet). - **breadcrumbs** — CLI tool; ANSI colours only, no GUI styling. -> Palette note: the fallback is Catppuccin Mocha, but installs (e.g. BOS) drive -> the real palette from pywal — BOS ships a black-base palette. +> Palette note: background/surface/overlay/foreground are a fixed BOS dark +> base, never pywal-derived; only the accent slots (color1–6) track the +> current wallpaper via pywal. ## Future Consistency Checks diff --git a/Cargo.lock b/Cargo.lock index 36707a1..44703a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,6 +88,7 @@ dependencies = [ "clap", "dirs", "hex", + "minisign-verify", "serde", "serde_json", "sha2", @@ -973,6 +974,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" diff --git a/Cargo.toml b/Cargo.toml index 354c58c..be6b60d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ sha2 = "0.10" hex = "0.4" clap = { version = "4", features = ["derive", "env"] } chrono = "0.4" +minisign-verify = "0.2" [profile.release] lto = "thin" diff --git a/README.md b/README.md index 1ec4cff..c01995a 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,9 @@ to keys. ## Theming All GUIs share one look via `bread-theme`. The `bread-theme` CLI renders the -component stylesheet from your pywal palette (Catppuccin Mocha fallback) to +component stylesheet from your pywal palette, layered on a fixed BOS dark +base (background/surface/overlay never come from pywal — only the accent +colors do, so a light wallpaper can't wash out the UI), to `$XDG_RUNTIME_DIR/bread/theme.css`; every app loads that file and **live-reloads** it, so changing your wallpaper recolours the whole ecosystem with no rebuilds: @@ -95,8 +97,12 @@ Install all required deps with `sudo pacman -S `. Use `pacman -Q ## Theming All GUI products (breadbar, breadbox, breadpad) read pywal colors from -`~/.cache/wal/colors.json` and fall back to Catppuccin Mocha when that file -is absent. Per-app CSS overrides live at `~/.config//style.css`. +`~/.cache/wal/colors.json` for accents only; background, surface, overlay, +and foreground are always BOS's fixed dark values (see +[`BREAD_DESIGN_SYSTEM.md`](BREAD_DESIGN_SYSTEM.md#color-system)) regardless +of what pywal extracted from the wallpaper. When `colors.json` is absent, +accents fall back to BOS's curated bread-toned defaults. Per-app CSS +overrides live at `~/.config//style.css`. The shared theming logic lives in the `bread-theme` crate in this repo. @@ -107,7 +113,7 @@ This repo is a Cargo workspace: ``` bread-ecosystem/ ├── bakery/ # package manager binary -├── bread-theme/ # shared pywal + Catppuccin theming crate +├── bread-theme/ # shared pywal + fixed-dark-base theming crate ├── registry/ # bread-ecosystem.toml — product registry └── scripts/ ├── get.sh # curl | sh bootstrap diff --git a/bakery/Cargo.toml b/bakery/Cargo.toml index df5b7c1..3664980 100644 --- a/bakery/Cargo.toml +++ b/bakery/Cargo.toml @@ -18,6 +18,7 @@ sha2 = { workspace = true } hex = { workspace = true } clap = { workspace = true } chrono = { workspace = true } +minisign-verify = { workspace = true } [dev-dependencies] tempfile = "3" diff --git a/bakery/src/download.rs b/bakery/src/download.rs index c89744c..3362bd0 100644 --- a/bakery/src/download.rs +++ b/bakery/src/download.rs @@ -32,7 +32,12 @@ pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result<()> { Ok(()) } -fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<()> { +/// Verify that `bytes` hashes to `expected_hex` under SHA-256. +/// +/// Shared by every artifact download path — binaries (via +/// [`fetch_and_place`]), and config-example / systemd-unit downloads in +/// `install.rs` — so all downloaded artifacts get the same integrity check. +pub fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<()> { let mut hasher = Sha256::new(); hasher.update(bytes); let actual = hex::encode(hasher.finalize()); diff --git a/bakery/src/install.rs b/bakery/src/install.rs index 03fb65f..3b4c925 100644 --- a/bakery/src/install.rs +++ b/bakery/src/install.rs @@ -2,7 +2,7 @@ use anyhow::{Context, Result}; use std::path::{Path, PathBuf}; use std::process::Command; -use crate::download::fetch_and_place; +use crate::download::{fetch_and_place, verify_sha256}; use crate::manifest::{fetch_binary, Package, Service}; use crate::state::{InstalledPackage, State}; @@ -119,11 +119,28 @@ fn scaffold_config(cfg: &crate::manifest::ConfigScaffold, pkg: &Package) -> Resu if !dest.exists() { if let Some((primary, fallback)) = pkg.artifact_urls(example) { match fetch_binary(&primary, &fallback) { - Ok(bytes) => { - std::fs::write(&dest, &bytes) - .with_context(|| format!("writing {}", dest.display()))?; - println!(" installed example config at {}", dest.display()); - } + Ok(bytes) => match &cfg.example_sha256 { + Some(expected) => match verify_sha256(&bytes, expected) { + Ok(()) => { + std::fs::write(&dest, &bytes) + .with_context(|| format!("writing {}", dest.display()))?; + println!(" installed example config at {}", dest.display()); + } + Err(e) => { + eprintln!( + " warning: checksum mismatch for example config {example}: {e} — not installed" + ); + println!(" config dir created at {}", dir.display()); + } + }, + None => { + eprintln!( + " warning: index.json has no sha256 for example config \ + {example} — refusing to install an unverified download" + ); + println!(" config dir created at {}", dir.display()); + } + }, Err(e) => { eprintln!(" warning: could not download example config {example}: {e}"); println!(" config dir created at {}", dir.display()); @@ -151,11 +168,19 @@ fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> { if !unit_path.exists() { if let Some((primary, fallback)) = pkg.artifact_urls(&svc.unit) { match fetch_binary(&primary, &fallback) { - Ok(bytes) => { - std::fs::write(&unit_path, &bytes) - .with_context(|| format!("writing {}", unit_path.display()))?; - println!(" downloaded unit {}", unit_path.display()); - } + Ok(bytes) => match verify_sha256(&bytes, &svc.sha256) { + Ok(()) => { + std::fs::write(&unit_path, &bytes) + .with_context(|| format!("writing {}", unit_path.display()))?; + println!(" downloaded unit {}", unit_path.display()); + } + Err(e) => { + eprintln!( + " warning: checksum mismatch for unit {}: {e} — not installed", + svc.unit + ); + } + }, Err(e) => { eprintln!(" warning: could not download {}: {e}", svc.unit); } diff --git a/bakery/src/manifest.rs b/bakery/src/manifest.rs index 5106646..2a8e7ac 100644 --- a/bakery/src/manifest.rs +++ b/bakery/src/manifest.rs @@ -1,11 +1,45 @@ use anyhow::{bail, Context, Result}; +use minisign_verify::{PublicKey, Signature}; use serde::{Deserialize, Serialize}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime}; const PRIMARY_URL: &str = "https://dl.breadway.dev/index.json"; +const SIG_URL: &str = "https://dl.breadway.dev/index.json.minisig"; const CACHE_MAX_AGE: Duration = Duration::from_secs(24 * 3600); +/// The bakery index-signing public key. +/// +/// The matching secret key is used offline (never on this machine, never in +/// this repo) to sign `index.json` with `minisign` as part of publishing a +/// new index — see `scripts/gen-index.sh`. Every fetch of `index.json`, and +/// every load of the on-disk cache, must verify against this key before the +/// bytes are trusted or parsed. This is the single control point: the +/// per-artifact `sha256` fields and `post_install` hook strings all live +/// inside `index.json` itself, so a valid signature transitively covers them. +const PUBKEY: &str = "RWRh2Zr5SUinvVFCtD7S7HwGjfrye6j31Xq2mYXRdkGFDWe3yHF7W11K"; + +/// Verify `bytes` against `sig_text` (the contents of an `index.json.minisig` +/// file) using the pinned [`PUBKEY`]. Returns an error on any failure — +/// missing/malformed signature, wrong key, or a hash mismatch. +fn verify_index_signature(bytes: &[u8], sig_text: &str) -> Result<()> { + verify_against_key(bytes, sig_text, PUBKEY) +} + +/// Verify `bytes` against a minisign `sig_text` using an arbitrary base64 +/// public key. Split out from [`verify_index_signature`] purely so tests can +/// exercise the verification logic with a throwaway keypair instead of the +/// real production key. +fn verify_against_key(bytes: &[u8], sig_text: &str, pubkey_b64: &str) -> Result<()> { + let public_key = + PublicKey::from_base64(pubkey_b64).context("public key is malformed")?; + let signature = + Signature::decode(sig_text).context("index.json.minisig is malformed or unreadable")?; + public_key + .verify(bytes, &signature, false) + .context("index.json failed signature verification against the pinned bakery key") +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Binary { pub name: String, @@ -18,6 +52,10 @@ pub struct Binary { pub struct Service { pub unit: String, pub enable: bool, + /// SHA-256 of the unit file artifact. Required to verify the download in + /// `install::install_service`, same as binaries; `index.json` carries it + /// (and is itself minisign-signed, which is what makes it trustworthy). + pub sha256: String, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -25,6 +63,10 @@ pub struct ConfigScaffold { pub dir: String, /// Example config filename, relative to the release artifact directory. pub example: Option, + /// SHA-256 of the example config artifact, when `example` is set. + /// Verified in `install::scaffold_config` the same way binaries are. + #[serde(default)] + pub example_sha256: Option, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -80,15 +122,38 @@ impl Index { /// Load the manifest, using the on-disk cache when it is fresh enough. /// Always fetches if `force_refresh` is true. +/// +/// Every path — fresh fetch or cached read — verifies the minisign +/// signature over the raw `index.json` bytes before the JSON is parsed or +/// trusted. A signature failure on a freshly fetched index is always a hard +/// error. A signature failure on the *cached* copy is treated as a +/// (possibly tampered, possibly just stale-format) cache and triggers one +/// re-fetch from the network rather than bricking the CLI outright; if the +/// freshly fetched copy also fails to verify, that's a hard error. pub fn load(force_refresh: bool) -> Result { let cache_path = cache_path(); + let sig_cache_path = sig_cache_path(&cache_path); if !force_refresh && cache_is_fresh(&cache_path) { - let text = std::fs::read_to_string(&cache_path).context("reading cached index")?; - return serde_json::from_str(&text).context("parsing cached index"); + match read_and_verify_cache(&cache_path, &sig_cache_path) { + Ok(index) => return Ok(index), + Err(err) => { + eprintln!( + " warning: cached index.json failed verification ({err}), re-fetching…" + ); + } + } } - fetch_and_cache(&cache_path) + fetch_and_cache(&cache_path, &sig_cache_path) +} + +fn read_and_verify_cache(cache_path: &PathBuf, sig_cache_path: &PathBuf) -> Result { + let bytes = std::fs::read(cache_path).context("reading cached index")?; + let sig_text = std::fs::read_to_string(sig_cache_path) + .context("reading cached index.json.minisig (cache predates signing support)")?; + verify_index_signature(&bytes, &sig_text)?; + serde_json::from_slice(&bytes).context("parsing cached index") } fn cache_is_fresh(path: &PathBuf) -> bool { @@ -98,13 +163,26 @@ fn cache_is_fresh(path: &PathBuf) -> bool { .unwrap_or(false) } -fn fetch_and_cache(cache_path: &PathBuf) -> Result { - let text = fetch_text(PRIMARY_URL)?; +fn fetch_and_cache(cache_path: &PathBuf, sig_cache_path: &PathBuf) -> Result { + let bytes = fetch_bytes(PRIMARY_URL)?; + let sig_text = fetch_text(SIG_URL).context( + "fetching index.json.minisig — the index must be signed before it can be trusted", + )?; + verify_index_signature(&bytes, &sig_text) + .context("freshly fetched index.json failed signature verification")?; + if let Some(dir) = cache_path.parent() { std::fs::create_dir_all(dir)?; } - std::fs::write(cache_path, &text)?; - serde_json::from_str(&text).context("parsing index.json") + std::fs::write(cache_path, &bytes)?; + std::fs::write(sig_cache_path, &sig_text)?; + serde_json::from_slice(&bytes).context("parsing index.json") +} + +fn sig_cache_path(cache_path: &Path) -> PathBuf { + let mut name = cache_path.file_name().unwrap_or_default().to_os_string(); + name.push(".minisig"); + cache_path.with_file_name(name) } fn fetch_text(url: &str) -> Result { @@ -151,3 +229,51 @@ fn fetch_bytes(url: &str) -> Result> { .context("reading response")?; Ok(buf) } + +#[cfg(test)] +mod tests { + use super::*; + + // A throwaway test-only minisign keypair, generated solely to produce + // these fixtures (`minisign -G` then `minisign -S`). It has no + // relationship to the real bakery signing key (PUBKEY above) and the + // matching secret key was discarded — these are just fixed vectors to + // exercise the verification code path deterministically. + const TEST_PUBKEY: &str = "RWQTYQi9Fe4trQDQmbb9txWDxzUIPYs57J//A5wG9BHcZXgC8YP0Cf59"; + const TEST_DATA: &[u8] = b"{\"hello\":\"world\"}\n"; + const TEST_SIG: &str = "untrusted comment: signature from minisign secret key\n\ +RUQTYQi9Fe4trXY/WBxk++476WhTqtVd3hlNWQj5h5DF8keP8sEJn22LDG2hloNgJesXt6HsTQs9uktayRVp/HB4XfC6e+rhYAs=\n\ +trusted comment: timestamp:1784230084\tfile:test-data.json\thashed\n\ +znmVfINB4jFDR2a4wuY8rOKlUBeSDOFjMkHYDXV3vxvAjK+r4V12ae9ZRQkfVtQ1YIEmFXbnJfbxywg+NR/1AA==\n"; + + #[test] + fn valid_signature_verifies() { + verify_against_key(TEST_DATA, TEST_SIG, TEST_PUBKEY) + .expect("known-good signature must verify"); + } + + #[test] + fn tampered_bytes_fail_verification() { + let tampered = b"{\"hello\":\"world!\"}\n".to_vec(); + assert!(verify_against_key(&tampered, TEST_SIG, TEST_PUBKEY).is_err()); + } + + #[test] + fn wrong_key_fails_verification() { + // PUBKEY is the real production key — unrelated to the throwaway + // TEST_PUBKEY the fixture was signed with, so it must not verify. + assert!(verify_against_key(TEST_DATA, TEST_SIG, PUBKEY).is_err()); + } + + #[test] + fn malformed_signature_text_errors_cleanly() { + assert!(verify_against_key(TEST_DATA, "not a real signature", TEST_PUBKEY).is_err()); + } + + #[test] + fn production_pubkey_constant_is_well_formed() { + // Guards against a future typo/truncation in the hardcoded PUBKEY — + // it must at least parse as a valid minisign public key. + PublicKey::from_base64(PUBKEY).expect("PUBKEY must be a valid minisign public key"); + } +} diff --git a/bread-theme/Cargo.toml b/bread-theme/Cargo.toml index 8dc41e7..8547e24 100644 --- a/bread-theme/Cargo.toml +++ b/bread-theme/Cargo.toml @@ -4,7 +4,7 @@ version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true -description = "Shared pywal + Catppuccin theming crate for the bread ecosystem" +description = "Shared pywal-accented, fixed-dark-base theming crate for the bread ecosystem" repository = "https://github.com/Breadway/bread-ecosystem" keywords = ["theming", "pywal", "gtk4", "wayland"] diff --git a/bread-theme/bakery.toml b/bread-theme/bakery.toml index a64c968..548fd3e 100644 --- a/bread-theme/bakery.toml +++ b/bread-theme/bakery.toml @@ -1,5 +1,5 @@ name = "bread-theme" -description = "Shared pywal + Catppuccin theming CLI for the bread ecosystem — generates the shared GTK4 stylesheet every bread app loads" +description = "Shared pywal-accented, fixed-dark-base theming CLI for the bread ecosystem — generates the shared GTK4 stylesheet every bread app loads" binaries = ["bread-theme"] system_deps = [] optional_system_deps = ["python-pywal"] diff --git a/bread-theme/src/lib.rs b/bread-theme/src/lib.rs index 2ee2307..ab0156b 100644 --- a/bread-theme/src/lib.rs +++ b/bread-theme/src/lib.rs @@ -24,31 +24,23 @@ pub mod tokens { pub const RADIUS_PILL: u16 = 999; } -/// Emit the `@define-color` block that all bread apps use. -/// Apps append their own rules below this; user CSS goes on top. +/// Emit the `@define-color` block that all bread apps use, plus the shared +/// font rule. +/// +/// Kept for API compatibility with older callers that only want the color +/// variables (not the full [`stylesheet`] component rules). It used to carry +/// its own hand-written `@define-color` block that predated the `accent` and +/// computed-ink (`on-*`) colors — that duplication is exactly what let it +/// drift out of sync and reintroduce the illegible-text bug (light pywal +/// colors + no computed ink meant white-on-white / black-on-black text +/// wherever a caller's own CSS referenced `@on-surface`, `@on-accent`, etc., +/// since those names simply didn't exist in this block). It now delegates +/// to the same [`define_colors`] the full stylesheet uses, so there is only +/// one color-block implementation and it cannot drift again. pub fn css_vars(p: &Palette) -> String { format!( - "@define-color bg {bg};\n\ - @define-color fg {fg};\n\ - @define-color surface {c0};\n\ - @define-color red {c1};\n\ - @define-color green {c2};\n\ - @define-color yellow {c3};\n\ - @define-color blue {c4};\n\ - @define-color pink {c5};\n\ - @define-color teal {c6};\n\ - @define-color overlay {c7};\n\ - * {{ font-family: '{font}'; font-size: {size}px; }}\n", - bg = p.background, - fg = p.foreground, - c0 = p.color0, - c1 = p.color1, - c2 = p.color2, - c3 = p.color3, - c4 = p.color4, - c5 = p.color5, - c6 = p.color6, - c7 = p.color7, + "{vars}* {{ font-family: '{font}'; font-size: {size}px; }}\n", + vars = define_colors(p), font = tokens::FONT_FAMILY, size = tokens::FONT_SIZE_BASE, ) @@ -241,6 +233,33 @@ mod tests { assert!(css.contains("14px")); } + #[test] + fn css_vars_includes_accent_and_computed_ink_colors() { + // Regression test: css_vars() used to be a second, hand-written + // @define-color block that predated `accent` and the computed `on-*` + // ink colors. Any caller whose own CSS referenced `@on-surface` / + // `@on-accent` etc. against that older block would hit an undefined + // color name — the illegible-text bug. css_vars() must now emit + // exactly the same color set as the full stylesheet. + let css = css_vars(&Palette::default()); + for name in &["accent", "on-bg", "on-surface", "on-accent", "on-red", "on-overlay"] { + assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}"); + } + } + + #[test] + fn css_vars_and_stylesheet_agree_on_color_block() { + // Both must derive their color variables from the same + // `define_colors` implementation, so they can't drift apart again. + let p = Palette::default(); + let vars = css_vars(&p); + let sheet = stylesheet(&p); + for name in &["bg", "fg", "surface", "overlay", "accent", "on-bg", "on-surface", "on-accent"] { + let needle = format!("@define-color {name} "); + assert!(vars.contains(&needle) && sheet.contains(&needle)); + } + } + #[test] fn stylesheet_defines_canonical_colors_and_components() { let css = stylesheet(&Palette::default()); diff --git a/registry/bread-ecosystem.toml b/registry/bread-ecosystem.toml index 06706f0..43abdc5 100644 --- a/registry/bread-ecosystem.toml +++ b/registry/bread-ecosystem.toml @@ -15,7 +15,7 @@ description = "Bread ecosystem package manager" [[products]] name = "bread-theme" repo = "Breadway/bread-ecosystem" -description = "Shared pywal + Catppuccin theming CLI for the bread ecosystem" +description = "Shared pywal-accented, fixed-dark-base theming CLI for the bread ecosystem" [[products]] name = "bread" diff --git a/scripts/gen-index.sh b/scripts/gen-index.sh index 86d3f40..9991adb 100755 --- a/scripts/gen-index.sh +++ b/scripts/gen-index.sh @@ -119,8 +119,12 @@ with open('${bakery_toml}', 'rb') as f: print(json.dumps(d.get('bread_deps', []))) " 2>/dev/null || echo "[]")" - # [[service]] entries → [{unit, enable}] - services="$(python3 -c " + # [[service]] entries → [{unit, enable, sha256}]. sha256 comes from the + # actual unit file shipped in this version dir — the same + # artifact-integrity guarantee binaries already get. A missing unit file + # gets an empty sha256; install.rs refuses to install an unverified + # download rather than silently skipping the check. + service_units="$(python3 -c " import tomllib, json with open('${bakery_toml}', 'rb') as f: d = tomllib.load(f) @@ -128,7 +132,24 @@ svcs = d.get('service', []) print(json.dumps([{'unit': s['unit'], 'enable': s.get('enable', False)} for s in svcs])) " 2>/dev/null || echo "[]")" - # [config] → {dir, example?} or null + services="[]" + while IFS= read -r svc_entry; do + [[ -z "${svc_entry}" ]] && continue + unit_name="$(echo "${svc_entry}" | jq -r '.unit')" + enable="$(echo "${svc_entry}" | jq -r '.enable')" + unit_path="${version_dir}/${unit_name}" + unit_sha256="" + if [[ -f "${unit_path}" ]]; then + unit_sha256="$(sha256sum "${unit_path}" | awk '{print $1}')" + else + echo " warning: service unit '${unit_name}' not found at ${unit_path}" >&2 + fi + svc_json="$(jq -n --arg unit "${unit_name}" --argjson enable "${enable}" --arg sha256 "${unit_sha256}" \ + '{unit: $unit, enable: $enable, sha256: $sha256}')" + services="$(jq -n --argjson arr "${services}" --argjson e "${svc_json}" '$arr + [$e]')" + done < <(echo "${service_units}" | jq -c '.[]') + + # [config] → {dir, example?, example_sha256?} or null config="$(python3 -c " import tomllib, json with open('${bakery_toml}', 'rb') as f: @@ -142,6 +163,19 @@ if cfg: else: print('null') " 2>/dev/null || echo "null")" + if [[ "${config}" != "null" ]]; then + example_name="$(echo "${config}" | jq -r '.example // empty')" + if [[ -n "${example_name}" ]]; then + example_path="${version_dir}/${example_name}" + example_sha256="" + if [[ -f "${example_path}" ]]; then + example_sha256="$(sha256sum "${example_path}" | awk '{print $1}')" + else + echo " warning: config.example '${example_name}' not found at ${example_path}" >&2 + fi + config="$(echo "${config}" | jq -c --arg sha "${example_sha256}" '. + {example_sha256: $sha}')" + fi + fi post_install="$(python3 -c " import tomllib, json @@ -194,3 +228,42 @@ jq -n \ > "${OUT}" echo "wrote ${OUT}" + +# Sign the index so `bakery` can verify it before trusting a single byte. +# Every artifact sha256 and post_install hook string lives inside index.json, +# so a valid signature over these raw bytes transitively covers all of it — +# no separate per-artifact signing is needed. +# +# MINISIGN_SEC_KEY must point at the *secret* key file generated with +# `minisign -G`. It is intentionally never read from inside either git repo; +# point it at wherever the key actually lives on the machine that runs this +# script (e.g. a root-only path on hestia), and set MINISIGN_SEC_KEY_PASSWORD +# too if the key was generated with a password. +# +# This step is a no-op (with a loud warning) if the key isn't configured, so +# existing unsigned publishing flows don't break until the key is actually +# wired up — see the handoff note in the fix commit for this repo. +if [[ -n "${MINISIGN_SEC_KEY:-}" ]]; then + if [[ ! -f "${MINISIGN_SEC_KEY}" ]]; then + echo "ERROR: MINISIGN_SEC_KEY=${MINISIGN_SEC_KEY} does not exist" >&2 + exit 1 + fi + if ! command -v minisign >/dev/null 2>&1; then + echo "ERROR: MINISIGN_SEC_KEY is set but the 'minisign' binary is not installed" >&2 + exit 1 + fi + sign_args=(-S -s "${MINISIGN_SEC_KEY}" -m "${OUT}" -x "${OUT}.minisig") + if [[ -n "${MINISIGN_SEC_KEY_PASSWORD:-}" ]]; then + MINISIGN_PASSWORD="${MINISIGN_SEC_KEY_PASSWORD}" minisign "${sign_args[@]}" ${OUT}.minisig" +else + echo "WARNING: MINISIGN_SEC_KEY not set — index.json was NOT signed." >&2 + echo " bakery clients built with signature verification will reject" >&2 + echo " this index. Set MINISIGN_SEC_KEY before running this in" >&2 + echo " production once the signing key has been provisioned." >&2 +fi diff --git a/scripts/get.sh b/scripts/get.sh index cc343f0..a2df2eb 100755 --- a/scripts/get.sh +++ b/scripts/get.sh @@ -4,6 +4,13 @@ # Or: curl -sSfL https://breadway.dev/get | sh set -eu +# Pinned minisign public key for the bakery release binary. Matches the +# PUBKEY constant in bakery/src/manifest.rs (same keypair signs both +# index.json and the bakery binary itself). Do not source this from the +# network — it must be baked into this script so a compromised dl server +# can't swap it out along with a malicious binary. +BAKERY_MINISIGN_PUBKEY="RWRh2Zr5SUinvVFCtD7S7HwGjfrye6j31Xq2mYXRdkGFDWe3yHF7W11K" + BAKERY_VERSION="${BAKERY_VERSION:-latest}" BIN_DIR="${BAKERY_BIN_DIR:-$HOME/.local/bin}" @@ -19,12 +26,16 @@ if [ "${BAKERY_VERSION}" = "latest" ]; then DL_PRIMARY="https://dl.breadway.dev/bakery/latest/bakery-x86_64" DL_FALLBACK="https://github.com/Breadway/bread-ecosystem/releases/latest/download/bakery-x86_64" SHA256_URL="https://dl.breadway.dev/bakery/latest/bakery-x86_64.sha256" + SIG_URL="https://dl.breadway.dev/bakery/latest/bakery-x86_64.minisig" + SIG_FALLBACK="https://github.com/Breadway/bread-ecosystem/releases/latest/download/bakery-x86_64.minisig" else # Strip a leading 'v' if the caller included it, then add it back consistently. ver="${BAKERY_VERSION#v}" DL_PRIMARY="https://dl.breadway.dev/bakery/${ver}/bakery-x86_64" DL_FALLBACK="https://github.com/Breadway/bread-ecosystem/releases/download/v${ver}/bakery-x86_64" SHA256_URL="https://dl.breadway.dev/bakery/${ver}/bakery-x86_64.sha256" + SIG_URL="https://dl.breadway.dev/bakery/${ver}/bakery-x86_64.minisig" + SIG_FALLBACK="https://github.com/Breadway/bread-ecosystem/releases/download/v${ver}/bakery-x86_64.minisig" fi # Pick a download tool. @@ -38,30 +49,63 @@ fi mkdir -p "${BIN_DIR}" TMP="$(mktemp)" -trap 'rm -f "${TMP}" "${TMP}.sha256"' EXIT +trap 'rm -f "${TMP}" "${TMP}.sha256" "${TMP}.minisig"' EXIT echo "downloading bakery…" if fetch "${DL_PRIMARY}" "${TMP}" 2>/dev/null; then echo " from dl.breadway.dev" - # Verify checksum when available from primary. - if fetch "${SHA256_URL}" "${TMP}.sha256" 2>/dev/null; then - expected="$(awk '{print $1}' "${TMP}.sha256")" - actual="$(sha256sum "${TMP}" | awk '{print $1}')" - if [ "${expected}" != "${actual}" ]; then - die "SHA-256 checksum mismatch (expected ${expected}, got ${actual})" - fi - echo " checksum verified" - else - echo " warning: could not fetch checksum — skipping verification" - fi + sig_url="${SIG_URL}" + checksum_only_fallback_note=" warning: could not fetch checksum — skipping verification" elif fetch "${DL_FALLBACK}" "${TMP}" 2>/dev/null; then echo " from GitHub (fallback)" - # No .sha256 on the GitHub fallback path; proceed without verification. - echo " warning: checksum not verified for GitHub fallback download" + sig_url="${SIG_FALLBACK}" + checksum_only_fallback_note=" warning: no checksum available for GitHub fallback download" 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 +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 +else + echo " warning: no .minisig published for this release yet — signature not verified" >&2 +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 fetch "${SHA256_URL}" "${TMP}.sha256" 2>/dev/null; then + expected="$(awk '{print $1}' "${TMP}.sha256")" + actual="$(sha256sum "${TMP}" | awk '{print $1}')" + if [ "${expected}" != "${actual}" ]; 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}" cp "${TMP}" "${BIN_DIR}/bakery" echo "installed bakery to ${BIN_DIR}/bakery" diff --git a/scripts/test-gen-index.sh b/scripts/test-gen-index.sh index 5a2733a..a496ca0 100755 --- a/scripts/test-gen-index.sh +++ b/scripts/test-gen-index.sh @@ -110,4 +110,12 @@ check "post_install[0]" \ "echo installed" \ "$(jq -r '.packages.fakepkg.post_install[0]' "${OUT}")" +check "services[0].sha256" \ + "$(sha256sum "${PKG_VER_DIR}/fakepkg.service" | awk '{print $1}')" \ + "$(jq -r '.packages.fakepkg.services[0].sha256' "${OUT}")" + +check "config.example_sha256" \ + "$(sha256sum "${PKG_VER_DIR}/fakepkg.example.toml" | awk '{print $1}')" \ + "$(jq -r '.packages.fakepkg.config.example_sha256' "${OUT}")" + echo "OK: all gen-index assertions passed" From 853ee3341572885ff2cb1d5d251e9280fa2ee463 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 17 Jul 2026 09:15:54 +0800 Subject: [PATCH 02/99] Add bread-utils and bread-onnx: shared crates for ecosystem-wide duplication bread-utils extracts genuinely duplicated logic found across breadbox, breadclip, breadmon, breadcrumbs, bos-settings, and breadhelp: - hypr: Hyprland socket1 request/response client (breadbox's get_active_workspace + breadclip's position.rs hyprctl_json were near-identical), socket2 path resolution (breadmon), and a version-tolerant `fullscreen` field parser (Hyprland has shipped both bool and int representations across versions). - singleton: correct flock-based single-instance toggle, replacing the TOCTOU-prone read-pid/check-proc/kill/write-pid pattern duplicated verbatim between breadbox and breadclip (breadclip's own comment says "matches breadbox pattern"). - proc: breadcrumbs' timeout-guarded subprocess runner, promoted verbatim as the one implementation in the ecosystem that already got this right. - atomic + xdg: atomic (temp-then-rename) file writes with an optional .bak-before-overwrite variant, and XDG path helpers that never fall back to a literal "~/..." string (the exact breadclip-core and breadpad-shared bug: PathBuf never expands `~`). - tomlcfg (feature "toml"): the load_doc/save_doc TOML-editing discipline bos-settings and breadhelp both implemented byte-for-byte identically in the same fix pass that introduced it. - gtk_popup (feature "gtk"): layer-shell overlay window setup, visible-row navigation, and click-outside-close, deduplicated from breadbox and breadclip (~150 duplicated lines, per both apps' own "same as breadbox" comments). bread-onnx extracts the embedding pipeline (tokenize -> tensor build -> mean-pool -> L2-normalize) duplicated near-verbatim between breadarr and breadsearch, a shared execution-provider session builder with loud EP- registration logging, and a model download+integrity helper. Defaults AMD iGPU acceleration to ort::ep::MIGraphX (not ROCm) per this machine's own breadsearch-gpu-backends lesson: ROCMExecutionProvider silently no-ops to CPU on distro ROCm onnxruntime builds compiled with --use_migraphx. Both crates build and pass their own test suites standalone. Consumer migrations follow in subsequent commits. --- Cargo.lock | 775 ++++++++++++++++++++++++++++++++++- Cargo.toml | 3 +- bread-onnx/Cargo.toml | 34 ++ bread-onnx/src/download.rs | 112 +++++ bread-onnx/src/embedding.rs | 220 ++++++++++ bread-onnx/src/lib.rs | 32 ++ bread-onnx/src/provider.rs | 113 +++++ bread-onnx/src/session.rs | 49 +++ bread-utils/Cargo.toml | 30 ++ bread-utils/src/atomic.rs | 185 +++++++++ bread-utils/src/gtk_popup.rs | 111 +++++ bread-utils/src/hypr.rs | 240 +++++++++++ bread-utils/src/lib.rs | 32 ++ bread-utils/src/proc.rs | 174 ++++++++ bread-utils/src/singleton.rs | 203 +++++++++ bread-utils/src/tomlcfg.rs | 100 +++++ bread-utils/src/xdg.rs | 95 +++++ 17 files changed, 2503 insertions(+), 5 deletions(-) create mode 100644 bread-onnx/Cargo.toml create mode 100644 bread-onnx/src/download.rs create mode 100644 bread-onnx/src/embedding.rs create mode 100644 bread-onnx/src/lib.rs create mode 100644 bread-onnx/src/provider.rs create mode 100644 bread-onnx/src/session.rs create mode 100644 bread-utils/Cargo.toml create mode 100644 bread-utils/src/atomic.rs create mode 100644 bread-utils/src/gtk_popup.rs create mode 100644 bread-utils/src/hypr.rs create mode 100644 bread-utils/src/lib.rs create mode 100644 bread-utils/src/proc.rs create mode 100644 bread-utils/src/singleton.rs create mode 100644 bread-utils/src/tomlcfg.rs create mode 100644 bread-utils/src/xdg.rs diff --git a/Cargo.lock b/Cargo.lock index 44703a2..678ab4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,29 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -97,6 +120,12 @@ dependencies = [ "ureq", ] +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.22.1" @@ -118,6 +147,21 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bread-onnx" +version = "0.2.3" +dependencies = [ + "anyhow", + "bread-utils", + "hex", + "ort", + "sha2", + "tempfile", + "tokenizers", + "tracing", + "ureq", +] + [[package]] name = "bread-theme" version = "0.2.3" @@ -128,6 +172,19 @@ dependencies = [ "serde_json", ] +[[package]] +name = "bread-utils" +version = "0.2.3" +dependencies = [ + "dirs", + "gtk4", + "gtk4-layer-shell", + "serde", + "serde_json", + "tempfile", + "toml_edit 0.22.27", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -157,6 +214,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.63" @@ -242,6 +308,33 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", +] + +[[package]] +name = "console" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -266,6 +359,31 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + [[package]] name = "crypto-common" version = "0.1.7" @@ -276,6 +394,87 @@ dependencies = [ "typenum", ] +[[package]] +name = "daachorse" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f55d7153ba3b507595872a3874803f07a8a81d1e888abed8e5db7da0597d6e2" + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" +dependencies = [ + "serde", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + [[package]] name = "digest" version = "0.10.7" @@ -318,6 +517,18 @@ dependencies = [ "syn", ] +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.2" @@ -334,6 +545,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "esaxx-rs" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" +dependencies = [ + "cc", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -366,6 +586,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -478,6 +704,7 @@ dependencies = [ "gdk-pixbuf", "gdk4-sys", "gio", + "gl", "glib", "libc", "pango", @@ -521,6 +748,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -529,7 +768,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -564,6 +803,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "gl" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a94edab108827d67608095e269cf862e60d920f144a5026d3dbcfd8b877fb404" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + [[package]] name = "glib" version = "0.22.7" @@ -693,6 +952,34 @@ dependencies = [ "pango", ] +[[package]] +name = "gtk4-layer-shell" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4069987ff4793699511a251028cc336b438e46565b463f111250148d574752a" +dependencies = [ + "bitflags", + "gdk4", + "glib", + "glib-sys", + "gtk4", + "gtk4-layer-shell-sys", + "libc", +] + +[[package]] +name = "gtk4-layer-shell-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f566a5ec5bcc454e7fcf2ab76930887ced5365afce12c1e5201bb296b95f1b9" +dependencies = [ + "gdk4-sys", + "glib-sys", + "gtk4-sys", + "libc", + "system-deps", +] + [[package]] name = "gtk4-macros" version = "0.11.0" @@ -863,6 +1150,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -896,12 +1189,34 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indicatif" +version = "0.18.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" +dependencies = [ + "console", + "portable-atomic", + "unicode-width", + "unit-prefix", + "web-time", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -920,6 +1235,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -959,6 +1280,32 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +[[package]] +name = "macro_rules_attribute" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65049d7923698040cd0b1ddcced9b0eb14dd22c5f86ae59c3740eab64a676520" +dependencies = [ + "macro_rules_attribute-proc_macro", + "paste", +] + +[[package]] +name = "macro_rules_attribute-proc_macro" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" + +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "memchr" version = "2.8.1" @@ -974,6 +1321,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "minisign-verify" version = "0.2.5" @@ -990,6 +1343,71 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "monostate" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" +dependencies = [ + "monostate-impl", + "serde", + "serde_core", +] + +[[package]] +name = "monostate-impl" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1011,12 +1429,52 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "onig" +version = "6.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" +dependencies = [ + "bitflags", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ort" +version = "2.0.0-rc.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133" +dependencies = [ + "ndarray", + "ort-sys", + "smallvec", + "tracing", +] + +[[package]] +name = "ort-sys" +version = "2.0.0-rc.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90" + [[package]] name = "pango" version = "0.22.6" @@ -1041,6 +1499,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1059,6 +1523,21 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1068,6 +1547,15 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -1105,12 +1593,84 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-cond" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" +dependencies = [ + "either", + "itertools", + "rayon", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_users" version = "0.4.6" @@ -1119,9 +1679,38 @@ checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror", + "thiserror 1.0.69", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "ring" version = "0.17.14" @@ -1199,6 +1788,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "semver" version = "1.0.28" @@ -1301,12 +1896,30 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "spm_precompiled" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" +dependencies = [ + "base64 0.13.1", + "nom", + "serde", + "unicode-segmentation", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" @@ -1379,7 +1992,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", ] [[package]] @@ -1393,6 +2015,17 @@ dependencies = [ "syn", ] +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -1403,6 +2036,40 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tokenizers" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44e5bea67576e04b6ff8564c5d9e09c2ef0cf476502245f2f120e497769d3112" +dependencies = [ + "ahash", + "compact_str", + "daachorse", + "dary_heap", + "derive_builder", + "esaxx-rs", + "getrandom 0.3.4", + "indicatif", + "itertools", + "log", + "macro_rules_attribute", + "monostate", + "onig", + "paste", + "rand", + "rayon", + "rayon-cond", + "regex", + "regex-syntax", + "serde", + "serde_json", + "spm_precompiled", + "thiserror 2.0.18", + "unicode-normalization-alignments", + "unicode-segmentation", + "unicode_categories", +] + [[package]] name = "toml" version = "0.8.23" @@ -1495,6 +2162,37 @@ version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "typenum" version = "1.20.1" @@ -1507,12 +2205,45 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization-alignments" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de" +dependencies = [ + "smallvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + [[package]] name = "untrusted" version = "0.9.0" @@ -1525,7 +2256,7 @@ version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" dependencies = [ - "base64", + "base64 0.22.1", "flate2", "log", "once_cell", @@ -1676,6 +2407,16 @@ dependencies = [ "semver", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -2019,6 +2760,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + [[package]] name = "yoke" version = "0.8.3" @@ -2042,6 +2789,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.8" diff --git a/Cargo.toml b/Cargo.toml index be6b60d..b51552a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["bakery", "bread-theme"] +members = ["bakery", "bread-theme", "bread-utils", "bread-onnx"] resolver = "2" [workspace.package] @@ -20,6 +20,7 @@ hex = "0.4" clap = { version = "4", features = ["derive", "env"] } chrono = "0.4" minisign-verify = "0.2" +tracing = "0.1" [profile.release] lto = "thin" diff --git a/bread-onnx/Cargo.toml b/bread-onnx/Cargo.toml new file mode 100644 index 0000000..d8cb08a --- /dev/null +++ b/bread-onnx/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "bread-onnx" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "Shared ONNX Runtime plumbing for the bread ecosystem: session building, execution-provider fallback with loud diagnostics, embedding-pipeline tensor math, and verified model downloads" +repository = "https://github.com/Breadway/bread-ecosystem" +keywords = ["onnx", "onnxruntime", "ml", "embeddings"] + +[dependencies] +bread-utils = { path = "../bread-utils" } +# Left at default-features = false, with no api-XX/download-binaries/ +# load-dynamic/tls-native features of our own: those choices (how each app +# obtains/links its onnxruntime .so, and which ONNX Runtime C API version to +# bind) are consumer-build-environment decisions that stay in each app's own +# Cargo.toml (breadarr, breadmill, and breadpad already each pin different +# ones). Cargo's feature unification means this crate's minimal declaration +# just rides along with whatever the consuming app already selected. +ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "tracing"] } +# Default features left on (unlike `ort` above) — breadarr and breadmill +# both already build against plain default-featured tokenizers; only +# breadpad customizes this (http, fancy-regex), and Cargo's feature +# unification only ever adds features on top of this minimal baseline, so +# breadpad's own selection still applies in its own build. +tokenizers = "0.23" +tracing = { workspace = true } +ureq = { workspace = true } +sha2 = { workspace = true } +hex = { workspace = true } +anyhow = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/bread-onnx/src/download.rs b/bread-onnx/src/download.rs new file mode 100644 index 0000000..64dd443 --- /dev/null +++ b/bread-onnx/src/download.rs @@ -0,0 +1,112 @@ +//! Model download + integrity checking. +//! +//! `breadarrd/src/matcher/mod.rs::download` (async, `reqwest`) and +//! `breadmill/src/main.rs::download_if_missing` (sync, `ureq`) independently +//! implement "download to a temp file, then rename over the destination" +//! for fetching an ONNX model/tokenizer if it isn't already present — +//! genuinely duplicated intent, different HTTP clients. Neither verifies +//! the download's integrity beyond "the response wasn't empty". This module +//! is a fresh, shared implementation (sync, `ureq` — matching this +//! workspace's existing `bakery` convention for downloads) that adds an +//! optional SHA-256 check, built on [`bread_utils::atomic::write_atomic_bytes`] +//! for the same crash-safety property both originals already had. +//! +//! `breadarrd`'s async caller should wrap a call to [`ensure_file`] in +//! `tokio::task::spawn_blocking` rather than block its async runtime +//! directly — see that crate's migration for the concrete pattern. + +use std::io::Read; +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +/// Download `url` to `dest` if `dest` doesn't already exist. If +/// `expected_sha256` is given, verifies the downloaded bytes against it +/// (case-insensitive hex) before the atomic rename and returns an error on +/// mismatch — the temp file is discarded, `dest` is left untouched. An +/// already-present `dest` is trusted as-is and not re-verified (matches +/// both original implementations' "if it exists, skip" behavior; re-hashing +/// a ~90MB+ model file on every startup would be wasted work for the common +/// case of a stable, previously-verified file). +pub fn ensure_file(url: &str, dest: &Path, expected_sha256: Option<&str>) -> anyhow::Result { + if dest.exists() { + return Ok(dest.to_path_buf()); + } + + tracing::info!("bread-onnx: downloading {url} -> {}", dest.display()); + let agent = ureq::AgentBuilder::new() + .timeout(std::time::Duration::from_secs(300)) + .build(); + let response = agent + .get(url) + .call() + .map_err(|e| anyhow::anyhow!("failed to download {url}: {e}"))?; + + let mut bytes = Vec::new(); + response + .into_reader() + .read_to_end(&mut bytes) + .map_err(|e| anyhow::anyhow!("failed to read response body from {url}: {e}"))?; + + if bytes.is_empty() { + anyhow::bail!("empty download from {url}"); + } + + if let Some(expected) = expected_sha256 { + let actual = sha256_hex(&bytes); + if !actual.eq_ignore_ascii_case(expected) { + anyhow::bail!( + "checksum mismatch for {url}: expected {expected}, got {actual} — refusing to install" + ); + } + tracing::info!("bread-onnx: verified sha256 for {}", dest.display()); + } + + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + bread_utils::atomic::write_atomic_bytes(dest, &bytes, None) + .map_err(|e| anyhow::anyhow!("failed to write {}: {e}", dest.display()))?; + + tracing::info!( + "bread-onnx: saved {} ({:.1} MB)", + dest.display(), + bytes.len() as f64 / 1_048_576.0 + ); + Ok(dest.to_path_buf()) +} + +pub fn sha256_hex(data: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(data); + hex::encode(hasher.finalize()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sha256_hex_matches_known_vector() { + // sha256("") — well-known empty-input digest. + assert_eq!( + sha256_hex(b""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + } + + #[test] + fn ensure_file_skips_download_when_already_present() { + let dir = std::env::temp_dir().join(format!("bread-onnx-download-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let dest = dir.join("model.onnx"); + std::fs::write(&dest, b"already here").unwrap(); + + // A bogus URL would fail if actually requested — success here proves + // the existing-file short-circuit fired instead of dialing out. + let result = ensure_file("http://127.0.0.1:1/unreachable", &dest, None); + assert!(result.is_ok()); + assert_eq!(std::fs::read(&dest).unwrap(), b"already here"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/bread-onnx/src/embedding.rs b/bread-onnx/src/embedding.rs new file mode 100644 index 0000000..e8ddb88 --- /dev/null +++ b/bread-onnx/src/embedding.rs @@ -0,0 +1,220 @@ +//! Shared BERT-family embedding pipeline: tokenize → build `input_ids`/ +//! `attention_mask`/`token_type_ids` tensors → run → mean-pool the +//! non-padded positions of `last_hidden_state` → L2-normalize → clamp/pad to +//! a configured output dimension. +//! +//! This is extracted from two independently-written but essentially +//! byte-identical implementations: +//! - `breadarrd/src/matcher/embed.rs::OrtEmbedder::embed` (lines 45-111) and +//! its `l2_normalize` (lines 114-121) +//! - `breadmill/src/embed.rs::OrtEmbedder::embed_with_prefix` (lines 65-153) +//! and its `l2_normalize` (lines 156-163) +//! +//! Both truncate to a max sequence length, build the same three `i64` +//! tensors, run the same `input_ids`/`attention_mask`/`token_type_ids` → +//! `last_hidden_state` shape contract, mean-pool over `actual_seq.min(mask.len())` +//! positions (both already independently arrived at the same `.min()` guard +//! for execution providers that pad the output sequence dimension), and +//! L2-normalize with the same `1e-10` epsilon. `breadmill`'s only real +//! difference is prepending a document/query prefix string before +//! tokenizing, which stays the caller's responsibility here — pass the +//! already-prefixed text to [`EmbeddingSession::embed`]. + +use std::path::Path; + +use ort::session::builder::GraphOptimizationLevel; +use ort::session::Session; +use ort::value::Tensor; +use tokenizers::Tokenizer; + +use crate::provider::Provider; +use crate::session::build_session; + +pub struct EmbeddingSession { + session: Session, + tokenizer: Tokenizer, + dim: usize, + max_seq_len: usize, +} + +impl EmbeddingSession { + /// Load a BERT-family embedding model + tokenizer, selecting execution + /// providers via [`build_session`]. `dim` is the output embedding + /// dimension (results are truncated/zero-padded to it — matches how + /// both original implementations handled a model whose `dim` config + /// might not exactly match `last_hidden_state`'s actual width). `max_seq_len` + /// caps tokenized input length before inference (truncating, not + /// erroring) to bound attention memory on pathological inputs. + pub fn load( + model_path: &Path, + tokenizer_path: &Path, + dim: usize, + max_seq_len: usize, + providers: &[Provider], + ) -> anyhow::Result { + let session = build_session(model_path, GraphOptimizationLevel::Level3, providers)?; + let tokenizer = Tokenizer::from_file(tokenizer_path) + .map_err(|e| anyhow::anyhow!("failed to load tokenizer: {e}"))?; + Ok(Self { session, tokenizer, dim, max_seq_len }) + } + + /// Embed `text` (already prefixed by the caller, if the model expects a + /// document/query prefix). Returns an L2-normalized vector of length + /// `dim`. + pub fn embed(&mut self, text: &str) -> anyhow::Result> { + let encoding = self + .tokenizer + .encode(text, true) + .map_err(|e| anyhow::anyhow!("tokenization failed: {e}"))?; + + let mut ids: Vec = encoding.get_ids().iter().map(|&x| x as i64).collect(); + let mut mask: Vec = encoding.get_attention_mask().iter().map(|&x| x as i64).collect(); + let mut type_ids: Vec = encoding.get_type_ids().iter().map(|&x| x as i64).collect(); + + ids.truncate(self.max_seq_len); + mask.truncate(self.max_seq_len); + type_ids.truncate(self.max_seq_len); + + let seq_len = ids.len() as i64; + let id_tensor = Tensor::::from_array((vec![1i64, seq_len], ids)) + .map_err(|e| anyhow::anyhow!("failed to build input_ids tensor: {e}"))?; + let mask_tensor = Tensor::::from_array((vec![1i64, seq_len], mask.clone())) + .map_err(|e| anyhow::anyhow!("failed to build attention_mask tensor: {e}"))?; + let type_tensor = Tensor::::from_array((vec![1i64, seq_len], type_ids)) + .map_err(|e| anyhow::anyhow!("failed to build token_type_ids tensor: {e}"))?; + + let outputs = self + .session + .run(ort::inputs! { + "input_ids" => id_tensor, + "attention_mask" => mask_tensor, + "token_type_ids" => type_tensor, + }) + .map_err(|e| anyhow::anyhow!("ort inference failed: {e}"))?; + + let (shape, data) = outputs["last_hidden_state"] + .try_extract_tensor::() + .map_err(|e| anyhow::anyhow!("failed to extract last_hidden_state: {e}"))?; + + let actual_seq = shape[1] as usize; + let actual_dim = shape[2] as usize; + + Ok(mean_pool_normalize(data, &mask, actual_seq, actual_dim, self.dim)) + } +} + +/// Mean-pool `data` (flattened `[1, actual_seq, actual_dim]`) over the +/// positions `mask` marks as non-padding, L2-normalize the result, then +/// clamp/zero-pad to `target_dim`. `actual_seq.min(mask.len())` guards +/// against execution providers (MIGraphX observed doing this) that pad the +/// output sequence dimension for kernel efficiency, making `actual_seq` +/// exceed the caller's own `mask` length. +fn mean_pool_normalize(data: &[f32], mask: &[i64], actual_seq: usize, actual_dim: usize, target_dim: usize) -> Vec { + let mut result = vec![0.0f32; actual_dim]; + let mut count = 0usize; + for t in 0..actual_seq.min(mask.len()) { + if mask[t] > 0 { + for d in 0..actual_dim { + result[d] += data[t * actual_dim + d]; + } + count += 1; + } + } + if count > 0 { + for x in &mut result { + *x /= count as f32; + } + } + + l2_normalize(&mut result); + result.truncate(target_dim); + while result.len() < target_dim { + result.push(0.0); + } + result +} + +fn l2_normalize(v: &mut [f32]) { + let norm: f32 = v.iter().map(|x| x * x).sum::().sqrt(); + if norm > 1e-10 { + for x in v.iter_mut() { + *x /= norm; + } + } +} + +pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 { + a.iter().zip(b).map(|(x, y)| x * y).sum() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn l2_normalize_produces_unit_vector() { + let mut v = vec![3.0, 4.0]; + l2_normalize(&mut v); + let norm: f32 = v.iter().map(|x| x * x).sum::().sqrt(); + assert!((norm - 1.0).abs() < 1e-6); + } + + #[test] + fn l2_normalize_leaves_zero_vector_untouched() { + let mut v = vec![0.0, 0.0, 0.0]; + l2_normalize(&mut v); + assert_eq!(v, vec![0.0, 0.0, 0.0]); + } + + #[test] + fn cosine_similarity_of_identical_unit_vectors_is_one() { + let mut v = vec![1.0, 2.0, 3.0]; + l2_normalize(&mut v); + let sim = cosine_similarity(&v, &v); + assert!((sim - 1.0).abs() < 1e-6); + } + + #[test] + fn cosine_similarity_of_orthogonal_vectors_is_zero() { + let a = vec![1.0, 0.0]; + let b = vec![0.0, 1.0]; + assert!(cosine_similarity(&a, &b).abs() < 1e-6); + } + + #[test] + fn mean_pool_ignores_padded_positions() { + // actual_dim = 2, 3 positions: two real tokens + one padded (mask=0) + let data = vec![ + 1.0, 1.0, // t0: real + 9.0, 9.0, // t1: padded, should be ignored + 3.0, 3.0, // t2: real + ]; + let mask = vec![1, 0, 1]; + let pooled = mean_pool_normalize(&data, &mask, 3, 2, 2); + // Mean of (1,1) and (3,3) is (2,2), normalized to unit length. + let expected_norm = (2.0f32 * 2.0 + 2.0 * 2.0).sqrt(); + assert!((pooled[0] - 2.0 / expected_norm).abs() < 1e-5); + assert!((pooled[1] - 2.0 / expected_norm).abs() < 1e-5); + } + + #[test] + fn mean_pool_clamps_actual_seq_to_mask_len_for_padded_ep_output() { + // Regression guard for the MIGraphX-padded-output-sequence case both + // original implementations independently guarded against: actual_seq + // (4) exceeds mask.len() (2) — must not index out of the mask. + let data = vec![1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]; + let mask = vec![1, 1]; + let pooled = mean_pool_normalize(&data, &mask, 4, 2, 2); + assert!(pooled.iter().all(|x| x.is_finite())); + } + + #[test] + fn mean_pool_pads_short_result_to_target_dim() { + let data = vec![1.0, 1.0]; + let mask = vec![1]; + let pooled = mean_pool_normalize(&data, &mask, 1, 1, 4); + assert_eq!(pooled.len(), 4); + assert_eq!(pooled[2], 0.0); + assert_eq!(pooled[3], 0.0); + } +} diff --git a/bread-onnx/src/lib.rs b/bread-onnx/src/lib.rs new file mode 100644 index 0000000..1ac4581 --- /dev/null +++ b/bread-onnx/src/lib.rs @@ -0,0 +1,32 @@ +//! Shared ONNX Runtime plumbing for the bread ecosystem. +//! +//! Extracted from breadarr, breadsearch, and breadpad during the +//! 2026-07-16 ecosystem-wide utility audit — see each module's doc comment +//! for the original file:line duplication it replaces. +//! +//! **Important**: [`session::build_session`] logs execution-provider +//! selection via the `tracing` crate, but does *not* initialize a +//! subscriber itself. Without one, ONNX Runtime's own "successfully +//! registered `XExecutionProvider`" log line (and this crate's own +//! selection logging) go nowhere — which is exactly how a GPU execution +//! provider can silently no-op back to CPU with zero visible error (see +//! [`provider`]'s doc comment for the concrete history behind this). All +//! three current consumers already call `tracing_subscriber::fmt().init()` +//! (or an `EnvFilter`-configured equivalent) at startup; any new consumer +//! must do the same before calling [`session::build_session`]. +//! +//! - [`provider`] — the [`provider::Provider`] enum and the +//! MIGraphX-not-ROCm default rationale. +//! - [`session`] — session construction with EP fallback + loud logging. +//! - [`embedding`] — the shared tokenize → tensor → mean-pool → normalize +//! pipeline for BERT-family embedding models. +//! - [`download`] — model download with atomic write + optional SHA-256 +//! integrity check. + +pub mod download; +pub mod embedding; +pub mod provider; +pub mod session; + +pub use provider::Provider; +pub use session::build_session; diff --git a/bread-onnx/src/provider.rs b/bread-onnx/src/provider.rs new file mode 100644 index 0000000..aeba01e --- /dev/null +++ b/bread-onnx/src/provider.rs @@ -0,0 +1,113 @@ +//! Execution-provider selection. +//! +//! This crate defaults AMD iGPU acceleration to +//! [`ort::ep::MIGraphX`](ort::ep::MIGraphX), *not* +//! [`ort::ep::ROCm`](ort::ep::ROCm), on purpose. `breadpad-shared/src/ +//! classifier.rs::try_load_session` used the classic `ROCMExecutionProvider` +//! and — per the hard-won lesson recorded in this machine's own operator +//! notes (`breadsearch-gpu-backends`, from `breadsearch`'s own history) — +//! that EP silently no-ops on this class of system and falls back to CPU +//! with zero visible error: distro ROCm ONNX Runtime builds (e.g. Arch's +//! `onnxruntime-rocm`) are commonly compiled with `--use_migraphx`, not +//! `--use_rocm`, so `ROCMExecutionProvider` never actually registers, and +//! nothing surfaces that fact unless a `tracing` subscriber is initialized +//! to catch ONNX Runtime's own EP-registration log line. `breadmill/src/ +//! embed.rs::rocm_session` already got this right; this module promotes +//! that provider choice (and the loud logging around it) to the shared +//! crate so it can't silently regress in any consumer again. + +use std::path::PathBuf; + +/// A requested execution provider, in the shared vocabulary consumers use. +/// Convert to an `ort` dispatch entry with [`Provider::to_dispatch`]. +#[derive(Debug, Clone)] +pub enum Provider { + Cpu, + /// AMD iGPU/dGPU via MIGraphX (ROCm-backed onnxruntime builds). See this + /// module's doc comment for why this — not `ROCm` — is the correct + /// choice on this class of system. + MiGraphX { device_id: i32 }, + /// NVIDIA GPU via CUDA. + Cuda { device_id: i32 }, + /// Intel iGPU/dGPU (Arc) via OpenVINO. `cache_dir` stores OpenVINO's + /// compiled-model blobs between runs. + OpenVino { device_type: String, cache_dir: PathBuf }, + /// AMD XDNA NPU via the VitisAI execution provider (Ryzen AI SDK). + /// `cache_dir` stores the compiled NPU model between runs. + Vitis { + config_file: PathBuf, + cache_dir: PathBuf, + cache_key: String, + }, +} + +impl Provider { + pub fn name(&self) -> &'static str { + match self { + Provider::Cpu => "CPU", + Provider::MiGraphX { .. } => "MIGraphX (AMD iGPU/dGPU)", + Provider::Cuda { .. } => "CUDA (NVIDIA GPU)", + Provider::OpenVino { .. } => "OpenVINO (Intel iGPU/dGPU)", + Provider::Vitis { .. } => "VitisAI (AMD XDNA NPU)", + } + } + + /// The literal execution-provider name ONNX Runtime's own log line + /// reports on successful registration (e.g. `"Successfully registered + /// \`MIGraphXExecutionProvider\`"`) — used to build the loud log hint in + /// [`crate::session::build_session`]. + fn ort_registration_name(&self) -> &'static str { + match self { + Provider::Cpu => "CPUExecutionProvider", + Provider::MiGraphX { .. } => "MIGraphXExecutionProvider", + Provider::Cuda { .. } => "CUDAExecutionProvider", + Provider::OpenVino { .. } => "OpenVINOExecutionProvider", + Provider::Vitis { .. } => "VitisAIExecutionProvider", + } + } + + pub(crate) fn to_dispatch(&self) -> anyhow::Result { + Ok(match self { + Provider::Cpu => ort::ep::CPU::default().build(), + Provider::MiGraphX { device_id } => { + ort::ep::MIGraphX::default().with_device_id(*device_id).build() + } + Provider::Cuda { device_id } => { + ort::ep::CUDA::default().with_device_id(*device_id).build() + } + Provider::OpenVino { device_type, cache_dir } => { + std::fs::create_dir_all(cache_dir)?; + ort::ep::OpenVINO::default() + .with_device_type(device_type.clone()) + .with_cache_dir(cache_dir.to_string_lossy()) + .build() + } + Provider::Vitis { config_file, cache_dir, cache_key } => { + std::fs::create_dir_all(cache_dir)?; + ort::ep::Vitis::default() + .with_config_file(config_file.to_string_lossy()) + .with_cache_dir(cache_dir.to_string_lossy()) + .with_cache_key(cache_key.clone()) + .build() + } + }) + } + + /// Log a loud, consistent "using X" line plus (for non-CPU providers) a + /// reminder of exactly what to grep ONNX Runtime's own log output for — + /// this is the "at minimum log EP registration success/failure loudly + /// by default" half of the fix, independent of whether the caller has + /// wired up `tracing_subscriber` (see [`crate::init_tracing`]). + pub(crate) fn log_selection(&self) { + tracing::info!("bread-onnx: requesting {} execution provider", self.name()); + if !matches!(self, Provider::Cpu) { + tracing::info!( + "bread-onnx: check ONNX Runtime's own log output for \"Successfully registered \ + `{}`\" — if it's missing, the ONNX Runtime build in use wasn't compiled/shipped \ + with this provider and inference silently fell back to CPU. This line only \ + appears if a `tracing` subscriber is initialized (see `bread_onnx::init_tracing`).", + self.ort_registration_name() + ); + } + } +} diff --git a/bread-onnx/src/session.rs b/bread-onnx/src/session.rs new file mode 100644 index 0000000..83dfa21 --- /dev/null +++ b/bread-onnx/src/session.rs @@ -0,0 +1,49 @@ +//! Session construction with execution-provider fallback. +//! +//! Builds one `ort::session::Session` whose execution-provider dispatch +//! list is exactly `providers` (in order) with an implicit `CPU` appended +//! if the caller didn't already include one — ONNX Runtime tries each +//! listed EP per-node and falls through the list on failure, so this +//! mirrors (and replaces) the identical `.with_execution_providers([primary, +//! CPU])` pattern already proven out in `breadmill/src/embed.rs::rocm_session` +//! /`cuda_session`/`openvino_session`/`npu_session`. + +use std::path::Path; + +use ort::session::builder::GraphOptimizationLevel; +use ort::session::Session; + +use crate::provider::Provider; + +/// Build a session, trying each of `providers` in order (ONNX Runtime falls +/// through per-node on registration failure) with a trailing `CPU` fallback +/// implicitly appended if not already present. Always logs which provider +/// was requested — see [`Provider::log_selection`] — regardless of whether +/// `tracing_subscriber` is initialized, so at minimum the *attempt* is +/// visible even without wired-up logging; the actual per-EP success/failure +/// detail only surfaces once a subscriber is listening. +pub fn build_session( + model_path: &Path, + opt_level: GraphOptimizationLevel, + providers: &[Provider], +) -> anyhow::Result { + let mut dispatch = Vec::with_capacity(providers.len() + 1); + for p in providers { + p.log_selection(); + dispatch.push(p.to_dispatch()?); + } + if !providers.iter().any(|p| matches!(p, Provider::Cpu)) { + dispatch.push(Provider::Cpu.to_dispatch()?); + } + + let mut builder = Session::builder() + .map_err(|e| anyhow::anyhow!("failed to create ort session builder: {e}"))? + .with_optimization_level(opt_level) + .map_err(|e| anyhow::anyhow!("failed to set optimization level: {e}"))? + .with_execution_providers(dispatch) + .map_err(|e| anyhow::anyhow!("failed to configure execution providers: {e}"))?; + + builder + .commit_from_file(model_path) + .map_err(|e| anyhow::anyhow!("failed to load model from {}: {e}", model_path.display())) +} diff --git a/bread-utils/Cargo.toml b/bread-utils/Cargo.toml new file mode 100644 index 0000000..851c3e2 --- /dev/null +++ b/bread-utils/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "bread-utils" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "Shared plumbing for the bread ecosystem: Hyprland IPC, single-instance toggling, timeout-guarded subprocess execution, atomic file writes, XDG paths, and a GTK4 layer-shell popup scaffold" +repository = "https://github.com/Breadway/bread-ecosystem" +keywords = ["hyprland", "wayland", "xdg", "gtk4"] + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +dirs = { workspace = true } +gtk4 = { version = "0.11", features = ["v4_12"], optional = true } +gtk4-layer-shell = { version = "0.8", optional = true } +toml_edit = { version = "0.22", optional = true } + +[features] +# Enable the layer-shell popup scaffold (breadbox, breadclip). Kept optional +# so headless/daemon consumers (breadmon, breadhelp's CLI half, breadcrumbs) +# don't have to pull in GTK4 + layer-shell just for `hypr`/`proc`/`xdg`. +gtk = ["dep:gtk4", "dep:gtk4-layer-shell"] +# Enable the non-destructive TOML doc load/save discipline (bos-settings, +# breadhelp). Optional so consumers that don't edit TOML configs (breadbox, +# breadclip, breadmon, ...) don't pull in toml_edit. +toml = ["dep:toml_edit"] + +[dev-dependencies] +tempfile = "3" diff --git a/bread-utils/src/atomic.rs b/bread-utils/src/atomic.rs new file mode 100644 index 0000000..3ee6d32 --- /dev/null +++ b/bread-utils/src/atomic.rs @@ -0,0 +1,185 @@ +//! Atomic file writes: write to a sibling temp file, then `rename` over the +//! target so a crash, power loss, or disk-full error mid-write never leaves +//! a truncated/corrupt file behind (a same-filesystem rename is atomic). +//! +//! Two flavors, both extracted from real (and identical) duplication: +//! +//! - [`write_atomic`] — temp-then-rename, with an optional Unix `mode` set +//! up front (so secrets never exist world-readable even briefly). This is +//! `breadcrumbs/src/util.rs::write_atomic`, promoted verbatim. +//! - [`write_atomic_backed_up`] — temp-then-rename *plus* a best-effort +//! `.bak` copy of whatever was there before, so a successful-but-wrong +//! write is always recoverable. This is `bos-settings/src/config/mod.rs`'s +//! `atomic_write`, which `breadhelp/src/config.rs` re-implemented +//! byte-for-byte in the same fix pass that introduced it (its own doc +//! comment says "same discipline as bos-settings/src/config/mod.rs") — +//! exactly the kind of fresh duplication this crate exists to remove. + +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +/// Write `contents` to `path` atomically. `mode` (Unix only) is applied to +/// the temp file *before* any data is written, so a file that must stay +/// private (secrets, tokens) is never briefly world-readable. +pub fn write_atomic(path: &Path, contents: &str, mode: Option) -> io::Result<()> { + write_atomic_bytes(path, contents.as_bytes(), mode) +} + +/// Byte-oriented sibling of [`write_atomic`], for binary payloads (e.g. a +/// downloaded ONNX model file — see `bread-onnx`'s downloader). +pub fn write_atomic_bytes(path: &Path, contents: &[u8], mode: Option) -> io::Result<()> { + let dir = path.parent().unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(dir)?; + let tmp = tmp_path(path, dir); + + let mut open = fs::OpenOptions::new(); + open.write(true).create(true).truncate(true); + #[cfg(unix)] + if let Some(mode) = mode { + use std::os::unix::fs::OpenOptionsExt; + open.mode(mode); + } + #[cfg(not(unix))] + let _ = mode; + + let res = (|| { + use std::io::Write; + let mut f = open.open(&tmp)?; + f.write_all(contents)?; + f.sync_all()?; + fs::rename(&tmp, path) + })(); + if res.is_err() { + let _ = fs::remove_file(&tmp); + } + res +} + +/// Like [`write_atomic`] (no `mode`), but first best-effort copies whatever +/// is currently at `path` to `.bak`. The backup is best-effort — a +/// failure to back up (e.g. read-only source, first-ever write) does not +/// block the write itself. +pub fn write_atomic_backed_up(path: &Path, contents: &str) -> io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + if path.exists() { + let backup = backup_path(path); + let _ = fs::copy(path, &backup); + } + write_atomic(path, contents, None) +} + +fn tmp_path(path: &Path, dir: &Path) -> PathBuf { + let stem = path.file_name().and_then(|s| s.to_str()).unwrap_or("bread"); + dir.join(format!(".{stem}.tmp.{}", std::process::id())) +} + +fn backup_path(path: &Path) -> PathBuf { + backup_path_for(path) +} + +/// `.bak` — shared with [`crate::tomlcfg`] so its own backup-before- +/// falling-back-to-defaults logging points at the same file this module +/// would have backed up to on a write. +pub(crate) fn backup_path_for(path: &Path) -> PathBuf { + PathBuf::from(format!("{}.bak", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Read; + + fn tmp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("bread-utils-atomic-test-{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn write_atomic_creates_file_with_contents() { + let dir = tmp_dir("basic"); + let path = dir.join("config.toml"); + write_atomic(&path, "hello", None).unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), "hello"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn write_atomic_leaves_no_tmp_file_behind() { + let dir = tmp_dir("no-leftover"); + let path = dir.join("config.toml"); + write_atomic(&path, "hello", None).unwrap(); + let leftover: Vec<_> = fs::read_dir(&dir) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp.")) + .collect(); + assert!(leftover.is_empty(), "leftover tmp files: {leftover:?}"); + let _ = fs::remove_dir_all(&dir); + } + + #[cfg(unix)] + #[test] + fn write_atomic_applies_mode_before_any_data_hits_disk() { + use std::os::unix::fs::PermissionsExt; + let dir = tmp_dir("mode"); + let path = dir.join("secret"); + write_atomic(&path, "token", Some(0o600)).unwrap(); + let perms = fs::metadata(&path).unwrap().permissions(); + assert_eq!(perms.mode() & 0o777, 0o600); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn write_atomic_backed_up_backs_up_previous_contents() { + let dir = tmp_dir("backup"); + let path = dir.join("state.toml"); + let backup = dir.join("state.toml.bak"); + + write_atomic_backed_up(&path, "first").unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), "first"); + assert!(!backup.exists(), "no backup should exist before the first overwrite"); + + write_atomic_backed_up(&path, "second").unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), "second"); + assert_eq!(fs::read_to_string(&backup).unwrap(), "first"); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn write_atomic_backed_up_leaves_no_tmp_file_behind() { + let dir = tmp_dir("backup-no-leftover"); + let path = dir.join("state.toml"); + write_atomic_backed_up(&path, "first").unwrap(); + write_atomic_backed_up(&path, "second").unwrap(); + let leftover: Vec<_> = fs::read_dir(&dir) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp.")) + .collect(); + assert!(leftover.is_empty(), "leftover tmp files: {leftover:?}"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn write_atomic_overwrite_never_leaves_partial_contents_visible() { + // Not a true crash-injection test (hard to do portably), but pins + // down the observable contract: after a successful call, the file + // is either fully old or fully new, never truncated. + let dir = tmp_dir("no-partial"); + let path = dir.join("f"); + write_atomic(&path, "aaaaaaaaaa", None).unwrap(); + write_atomic(&path, "b", None).unwrap(); + let mut s = String::new(); + fs::File::open(&path).unwrap().read_to_string(&mut s).unwrap(); + assert_eq!(s, "b"); + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/bread-utils/src/gtk_popup.rs b/bread-utils/src/gtk_popup.rs new file mode 100644 index 0000000..60c7f97 --- /dev/null +++ b/bread-utils/src/gtk_popup.rs @@ -0,0 +1,111 @@ +//! Shared GTK4 layer-shell popup scaffold: the full-screen transparent +//! overlay window setup, `ListBox` up/down visible-row navigation, and +//! click-outside-to-close gesture were duplicated near-verbatim between +//! `breadbox/src/main.rs` and `breadclip/src/main.rs`: +//! +//! - Layer-shell window setup: `breadbox/src/main.rs:357-365` / +//! `breadclip/src/main.rs:231-239` — identical `init_layer_shell` + +//! namespace + `Layer::Overlay` + `KeyboardMode::Exclusive` + anchor all +//! four edges + zero exclusive zone. +//! - Up/Down navigation loop: `breadbox/src/main.rs:515-546` / +//! `breadclip/src/main.rs:423-454` — byte-for-byte identical "find the +//! next/previous *visible* row" loop (breadclip's own comment even reads +//! `// ---- Keyboard handler (capture phase, same as breadbox) ----`). +//! - Click-outside-close: `breadbox/src/main.rs:566-581` / +//! `breadclip/src/main.rs:474-...` — identical bounds-check against a +//! content widget (breadclip: `// ---- Click outside panel → close (same +//! pattern as breadbox) ----`). +//! +//! Deliberately *not* extracted: the rest of each app's `EventControllerKey` +//! handling (Enter/Delete semantics, filter chips, search) — those differ +//! per app (`do_launch` vs `do_copy`+`Delete`-to-remove) and forcing them +//! into one callback-owning "scaffold" struct would be a leakier +//! abstraction than the ~5 free functions below. +//! +//! Requires the `gtk` feature. + +use gtk4::prelude::*; +use gtk4::{ApplicationWindow, GestureClick}; +use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; + +/// Build the full-screen transparent overlay window every layer-shell popup +/// in this ecosystem starts from: layered above normal windows, keyboard- +/// exclusive (so Escape/Enter/arrow keys reach the popup instead of the +/// focused client behind it), anchored to all four edges with zero +/// exclusive zone (so it doesn't reserve screen space or push other layer +/// clients around). +pub fn new_overlay_window(app: >k4::Application, namespace: &str) -> ApplicationWindow { + let window = ApplicationWindow::builder().application(app).build(); + window.init_layer_shell(); + window.set_namespace(Some(namespace)); + window.set_layer(Layer::Overlay); + window.set_keyboard_mode(KeyboardMode::Exclusive); + for edge in [Edge::Top, Edge::Bottom, Edge::Left, Edge::Right] { + window.set_anchor(edge, true); + } + window.set_exclusive_zone(0); + window +} + +/// Select the next *visible* row after the current selection (rows can be +/// hidden by a live search filter — a plain "select index + 1" would land +/// on a filtered-out row). No-op if there is no next visible row. +pub fn select_next_visible(list: >k4::ListBox) { + let cur = list.selected_row().map(|r| r.index()).unwrap_or(-1); + let mut i = cur + 1; + loop { + match list.row_at_index(i) { + Some(r) if r.is_visible() => { + list.select_row(Some(&r)); + break; + } + Some(_) => i += 1, + None => break, + } + } +} + +/// Select the previous *visible* row before the current selection. No-op if +/// there is no previous visible row. +pub fn select_prev_visible(list: >k4::ListBox) { + let cur = list.selected_row().map(|r| r.index()).unwrap_or(0); + let mut i = cur - 1; + loop { + if i < 0 { + break; + } + match list.row_at_index(i) { + Some(r) if r.is_visible() => { + list.select_row(Some(&r)); + break; + } + Some(_) => i -= 1, + None => break, + } + } +} + +/// Attach a click gesture to `window` that calls `on_outside` whenever a +/// click lands outside `content`'s bounds (e.g. clicking the transparent +/// full-screen backdrop around a centered launcher/panel widget). +pub fn close_on_outside_click( + window: &ApplicationWindow, + content: &impl IsA, + on_outside: impl Fn() + 'static, +) { + let content = content.clone().upcast::(); + let win_ref = window.clone(); + let gesture = GestureClick::new(); + gesture.connect_pressed(move |_, _, x, y| { + if let Some(b) = content.compute_bounds(&win_ref) { + if x < b.x() as f64 + || x > (b.x() + b.width()) as f64 + || y < b.y() as f64 + || y > (b.y() + b.height()) as f64 + { + on_outside(); + } + } + }); + window.add_controller(gesture); +} diff --git a/bread-utils/src/hypr.rs b/bread-utils/src/hypr.rs new file mode 100644 index 0000000..9bcdf26 --- /dev/null +++ b/bread-utils/src/hypr.rs @@ -0,0 +1,240 @@ +//! Hyprland IPC client: socket1 request/response (JSON) and socket2 path +//! resolution. +//! +//! The socket-path resolution + raw request/response round trip was +//! duplicated near-verbatim in `breadbox/src/main.rs` (`get_active_workspace`, +//! lines 26-42) and `breadclip/src/position.rs` (`hyprctl_json`, lines +//! 58-71) — same `HYPRLAND_INSTANCE_SIGNATURE`/`XDG_RUNTIME_DIR` env lookup, +//! same `.socket.sock` path format, same connect/write/shutdown-write/ +//! read-to-string sequence. `breadmon/src/main.rs`'s `hyprland_socket2_path` +//! duplicates just the path-resolution half for the event socket. +//! +//! `active_window`'s `fullscreen` field deserializes leniently as either a +//! JSON bool or integer: Hyprland has changed this field's type across +//! versions (older releases emit a bool, `0`/`1`; newer ones emit an +//! integer fullscreen *mode* — `0` none, `1` maximized, `2` fullscreen), and +//! a client hard-coded to one shape silently misreads the other instead of +//! erroring. `breadclip`'s own version (`as_i64().unwrap_or(0) != 0`) only +//! handles the integer shape; a bool `true` would `.as_i64()` to `None` and +//! silently read as "not fullscreen". + +use serde::Deserialize; +use std::env; +use std::io::{Read, Write}; +use std::os::unix::net::UnixStream; +use std::path::PathBuf; + +/// Which of Hyprland's two IPC sockets: `.socket.sock` (request/response) or +/// `.socket2.sock` (event stream). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Socket { + Request, + Events, +} + +/// Resolve the path to one of Hyprland's IPC sockets from +/// `HYPRLAND_INSTANCE_SIGNATURE` + `XDG_RUNTIME_DIR`. Returns `None` if +/// `HYPRLAND_INSTANCE_SIGNATURE` isn't set (Hyprland isn't running, or we're +/// not inside a Hyprland session) — `XDG_RUNTIME_DIR` falls back to +/// `/run/user/1000` if unset, matching `breadmon`'s existing fallback. +pub fn socket_path(kind: Socket) -> Option { + let sig = env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?; + let rt = env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".to_string()); + let file = match kind { + Socket::Request => ".socket.sock", + Socket::Events => ".socket2.sock", + }; + Some(PathBuf::from(format!("{rt}/hypr/{sig}/{file}"))) +} + +/// Send `request` (e.g. `"j/activewindow"`, `"j/monitors"`) to the socket1 +/// IPC socket and return the raw response body. Blocking/synchronous — this +/// matches every current consumer (breadbox, breadclip), which call it from +/// non-async GTK app code. +pub fn request(request: &str) -> Option { + let socket = socket_path(Socket::Request)?; + let mut stream = UnixStream::connect(&socket).ok()?; + stream.write_all(request.as_bytes()).ok()?; + stream.shutdown(std::net::Shutdown::Write).ok()?; + + let mut buf = String::new(); + stream.read_to_string(&mut buf).ok()?; + Some(buf) +} + +/// Like [`request`], parsed as JSON. `request` should already carry the `j/` +/// prefix Hyprland expects for JSON responses (e.g. `"j/activewindow"`). +pub fn request_json(request_str: &str) -> Option { + serde_json::from_str(&request(request_str)?).ok() +} + +/// Connect to the socket2 event stream. Callers read newline-delimited +/// `EVENT>>DATA` lines from the returned stream themselves — event framing +/// and reconnect/backoff policy are genuinely per-consumer (see +/// `breadmon`'s hotplug listener), so this only replaces the duplicated +/// path-resolution + connect boilerplate, not a full event-loop +/// abstraction. +pub fn connect_events() -> Option { + let socket = socket_path(Socket::Events)?; + UnixStream::connect(&socket).ok() +} + +/// Hyprland's `fullscreen` field, tolerant of either representation it has +/// shipped across versions: a plain bool, or an integer fullscreen mode +/// (`0` = none, nonzero = some fullscreen mode). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct FullscreenState(bool); + +impl FullscreenState { + pub fn is_fullscreen(self) -> bool { + self.0 + } +} + +impl<'de> Deserialize<'de> for FullscreenState { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum Repr { + Bool(bool), + Int(i64), + } + Ok(match Repr::deserialize(deserializer)? { + Repr::Bool(b) => FullscreenState(b), + Repr::Int(i) => FullscreenState(i != 0), + }) + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ActiveWindow { + #[serde(default)] + pub class: String, + #[serde(default)] + pub fullscreen: FullscreenState, + pub at: (i32, i32), + pub size: (i32, i32), +} + +impl ActiveWindow { + pub fn x(&self) -> i32 { + self.at.0 + } + pub fn y(&self) -> i32 { + self.at.1 + } + pub fn width(&self) -> i32 { + self.size.0 + } + pub fn height(&self) -> i32 { + self.size.1 + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Monitor { + pub name: String, + pub x: i32, + pub y: i32, + pub width: i32, + pub height: i32, + #[serde(default)] + pub focused: bool, +} + +/// Query the currently active (focused) window. Returns `None` if the +/// window is fullscreen or no window is focused — same "centre the popup +/// instead" contract `breadclip`'s original `get_active_window` had. +pub fn active_window() -> Option { + let win: ActiveWindow = serde_json::from_value(request_json("j/activewindow")?).ok()?; + if win.fullscreen.is_fullscreen() || win.class.is_empty() { + return None; + } + Some(win) +} + +/// Query all monitors and return the focused one (or the first, if none +/// report as focused). +pub fn focused_monitor() -> Option { + let monitors: Vec = serde_json::from_value(request_json("j/monitors")?).ok()?; + monitors + .iter() + .find(|m| m.focused) + .or_else(|| monitors.first()) + .cloned() +} + +/// The active workspace's name (e.g. `"1"`, `"special:scratch"`). +pub fn active_workspace_name() -> Option { + request_json("j/activeworkspace")? + .get("name") + .and_then(|v| v.as_str()) + .map(str::to_string) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fullscreen_state_deserializes_from_bool() { + let s: FullscreenState = serde_json::from_str("true").unwrap(); + assert!(s.is_fullscreen()); + let s: FullscreenState = serde_json::from_str("false").unwrap(); + assert!(!s.is_fullscreen()); + } + + #[test] + fn fullscreen_state_deserializes_from_int() { + let s: FullscreenState = serde_json::from_str("0").unwrap(); + assert!(!s.is_fullscreen()); + let s: FullscreenState = serde_json::from_str("2").unwrap(); + assert!(s.is_fullscreen()); + } + + #[test] + fn active_window_parses_bool_fullscreen_shape() { + let json = r#"{"class":"kitty","fullscreen":true,"at":[10,20],"size":[300,400]}"#; + let win: ActiveWindow = serde_json::from_str(json).unwrap(); + assert!(win.fullscreen.is_fullscreen()); + assert_eq!(win.x(), 10); + assert_eq!(win.height(), 400); + } + + #[test] + fn active_window_parses_int_fullscreen_shape() { + let json = r#"{"class":"kitty","fullscreen":1,"at":[0,0],"size":[100,100]}"#; + let win: ActiveWindow = serde_json::from_str(json).unwrap(); + assert!(win.fullscreen.is_fullscreen()); + } + + // Both env-var-dependent cases share one test function: `set_var`/ + // `remove_var` are process-global, and cargo runs tests in parallel + // threads by default, so two separate #[test] fns racing on the same + // vars would be flaky. + #[test] + fn socket_path_env_var_behavior() { + unsafe { env::remove_var("HYPRLAND_INSTANCE_SIGNATURE") }; + assert!(socket_path(Socket::Request).is_none()); + + unsafe { + env::set_var("HYPRLAND_INSTANCE_SIGNATURE", "test-sig"); + env::set_var("XDG_RUNTIME_DIR", "/run/user/9999"); + } + assert_eq!( + socket_path(Socket::Request).unwrap(), + PathBuf::from("/run/user/9999/hypr/test-sig/.socket.sock") + ); + assert_eq!( + socket_path(Socket::Events).unwrap(), + PathBuf::from("/run/user/9999/hypr/test-sig/.socket2.sock") + ); + unsafe { + env::remove_var("HYPRLAND_INSTANCE_SIGNATURE"); + env::remove_var("XDG_RUNTIME_DIR"); + } + } +} diff --git a/bread-utils/src/lib.rs b/bread-utils/src/lib.rs new file mode 100644 index 0000000..84e8621 --- /dev/null +++ b/bread-utils/src/lib.rs @@ -0,0 +1,32 @@ +//! Shared plumbing for the bread desktop-automation ecosystem. +//! +//! Extracted from genuine, verified duplication across breadbox, breadclip, +//! breadmon, breadcrumbs, bos-settings, and breadhelp during the 2026-07-16 +//! ecosystem-wide utility audit. Each module's doc comment cites the +//! original file:line locations the code was extracted from. +//! +//! - [`hypr`] — Hyprland IPC: socket path resolution, socket1 +//! request/response, typed `activewindow`/`monitors` queries with +//! version-tolerant `fullscreen` field parsing. +//! - [`singleton`] — correct, TOCTOU-free single-instance/PID-toggle. +//! - [`proc`] — timeout-guarded subprocess execution. +//! - [`atomic`] — atomic (temp-then-rename) file writes, with an optional +//! `.bak`-before-overwrite variant. +//! - [`xdg`] — XDG base directory helpers with a real (never literal-tilde) +//! `$HOME` fallback. +//! - [`tomlcfg`] (feature `toml`) — non-destructive TOML document +//! load/save discipline built on [`atomic`]. +//! - [`gtk_popup`] (feature `gtk`) — shared layer-shell popup window setup, +//! list navigation, and click-outside-to-close. + +pub mod atomic; +pub mod hypr; +pub mod proc; +pub mod singleton; +pub mod xdg; + +#[cfg(feature = "toml")] +pub mod tomlcfg; + +#[cfg(feature = "gtk")] +pub mod gtk_popup; diff --git a/bread-utils/src/proc.rs b/bread-utils/src/proc.rs new file mode 100644 index 0000000..fe4b29b --- /dev/null +++ b/bread-utils/src/proc.rs @@ -0,0 +1,174 @@ +//! Timeout-guarded subprocess execution. +//! +//! Promoted verbatim from `breadcrumbs/src/util.rs` (the one implementation +//! in the ecosystem that already got this right — see the audit note in +//! `bread-utils`'s crate root). Several other repos shell out to +//! Wayland/Hyprland tools (`hyprctl`, `grim`, `wl-paste`, ...) via bare +//! `std::process::Command` with no timeout at all, so a hung child can wedge +//! the whole caller indefinitely. `run`/`run_with_stdin` below kill the +//! child and return a failed [`Output`] once `timeout` elapses instead. + +use std::io::{Read, Write}; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +#[derive(Debug, Clone)] +pub struct Output { + pub success: bool, + pub stdout: String, + pub stderr: String, +} + +impl Output { + pub fn failed() -> Output { + Output { + success: false, + stdout: String::new(), + stderr: String::new(), + } + } +} + +/// Run a command with a hard timeout. The child is killed if it overruns so +/// a hung subprocess can never wedge the caller. +pub fn run(prog: &str, args: &[&str], timeout: Duration) -> Output { + run_with_stdin(prog, args, None, timeout) +} + +/// Like [`run`], but feeds `stdin` to the child's standard input. Useful for +/// handing secrets (e.g. Wi-Fi PSKs, API tokens) to a CLI without exposing +/// them in argv, where any local user could read them via `ps`. +pub fn run_with_stdin(prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration) -> Output { + let stdin_cfg = if stdin.is_some() { + Stdio::piped() + } else { + Stdio::null() + }; + let mut child = match Command::new(prog) + .args(args) + // Pin the C locale so message text callers parse (hyprctl JSON keys, + // status output, ...) is stable regardless of the user's LANG. + .env("LC_ALL", "C") + .env("LANG", "C") + .stdin(stdin_cfg) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + { + Ok(c) => c, + Err(_) => return Output::failed(), + }; + + let mut stdout_pipe = child.stdout.take(); + let mut stderr_pipe = child.stderr.take(); + + let out_handle = thread::spawn(move || { + let mut buf = String::new(); + if let Some(ref mut p) = stdout_pipe { + let _ = p.read_to_string(&mut buf); + } + buf + }); + let err_handle = thread::spawn(move || { + let mut buf = String::new(); + if let Some(ref mut p) = stderr_pipe { + let _ = p.read_to_string(&mut buf); + } + buf + }); + + // Feed stdin only after the reader threads are draining stdout/stderr, so + // a child that writes more than a pipe buffer before consuming stdin + // can't deadlock against our blocking write. + if let Some(data) = stdin { + if let Some(mut sink) = child.stdin.take() { + let _ = sink.write_all(data.as_bytes()); + // Drop closes the pipe so the child's read sees EOF. + } + } + + let start = Instant::now(); + let status = loop { + match child.try_wait() { + Ok(Some(s)) => break Some(s), + Ok(None) => { + if start.elapsed() >= timeout { + let _ = child.kill(); + let _ = child.wait(); + break None; + } + thread::sleep(Duration::from_millis(50)); + } + Err(_) => break None, + } + }; + + let stdout = out_handle.join().unwrap_or_default(); + let stderr = err_handle.join().unwrap_or_default(); + + Output { + success: status.map(|s| s.success()).unwrap_or(false), + stdout, + stderr, + } +} + +pub fn run_ok(prog: &str, args: &[&str], timeout: Duration) -> bool { + run(prog, args, timeout).success +} + +/// Run a command and parse its stdout as JSON on success. Convenience for the +/// very common `hyprctl -j ` / ` --json` pattern. +pub fn run_json(prog: &str, args: &[&str], timeout: Duration) -> Option { + let out = run(prog, args, timeout); + if !out.success { + return None; + } + serde_json::from_str(&out.stdout).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn run_captures_stdout() { + let out = run("printf", &["hello"], Duration::from_secs(2)); + assert!(out.success); + assert_eq!(out.stdout, "hello"); + } + + #[test] + fn run_reports_failure_for_nonzero_exit() { + let out = run("sh", &["-c", "exit 3"], Duration::from_secs(2)); + assert!(!out.success); + } + + #[test] + fn run_kills_hung_child_after_timeout() { + let start = Instant::now(); + let out = run("sleep", &["30"], Duration::from_millis(200)); + assert!(!out.success); + assert!(start.elapsed() < Duration::from_secs(5), "child was not killed promptly"); + } + + #[test] + fn run_with_stdin_feeds_child_input() { + let out = run_with_stdin("cat", &[], Some("secret-data"), Duration::from_secs(2)); + assert!(out.success); + assert_eq!(out.stdout, "secret-data"); + } + + #[test] + fn run_json_parses_stdout() { + let out = run_json("printf", &["{\"a\":1}"], Duration::from_secs(2)); + assert_eq!(out.unwrap()["a"], 1); + } + + #[test] + fn run_json_returns_none_on_failure() { + let out = run_json("sh", &["-c", "exit 1"], Duration::from_secs(2)); + assert!(out.is_none()); + } +} diff --git a/bread-utils/src/singleton.rs b/bread-utils/src/singleton.rs new file mode 100644 index 0000000..2fa9eee --- /dev/null +++ b/bread-utils/src/singleton.rs @@ -0,0 +1,203 @@ +//! Correct single-instance / PID-toggle, replacing the TOCTOU-prone pattern +//! duplicated in `breadbox/src/main.rs` (`toggle_or_continue`/`pid_file`, +//! ~30 lines) and `breadclip/src/main.rs` (same function names, whose own +//! comment reads `// ---- PID file toggle (single-instance, matches breadbox +//! pattern) ----`). +//! +//! The old pattern: read the PID file, `/proc//comm`-check whether it's +//! still this app, `kill` it if so, otherwise `fs::write` our own PID over +//! it. That's three separate, non-atomic steps — two instances launched at +//! once can both read "no valid PID" and both proceed as the "first" +//! instance; a stale PID file left by a crash can also collide with an +//! unrelated process that was later assigned the same PID by the kernel, +//! sending it a `kill` it never asked for. +//! +//! This module instead holds an exclusive, kernel-atomic advisory lock +//! (`std::fs::File::try_lock`, i.e. `flock(2)`) on the PID file for the +//! entire lifetime of the process that acquires it. Lock ownership itself +//! *is* the liveness check — there is no window where two processes can +//! both believe they're the sole instance, and a crashed process's lock is +//! released by the kernel the instant it dies, so there's no stale-lock +//! case to reason about at all. +//! +//! [`try_acquire`] is the side-effect-free primitive (no signals sent); +//! [`toggle_or_kill`] layers breadbox/breadclip's actual desired behavior +//! (kill whoever's running, then exit) on top of it. + +use std::fs::{File, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::PathBuf; + +/// Held for the lifetime of the running instance. Dropping it releases the +/// flock and removes the PID file. Keep this alive (e.g. in a `let _guard = +/// ...` bound in `main`) for as long as the app should be considered "the" +/// running instance. +pub struct Guard { + _file: File, + path: PathBuf, +} + +impl Drop for Guard { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +pub enum Acquire { + /// No other instance was running; we now hold the lock. + Acquired(Guard), + /// Another instance already holds the lock and is therefore alive right + /// now. Carries whatever PID it last recorded, if the file contents + /// parsed as one. + HeldByOther(Option), +} + +pub enum Toggle { + /// No other instance was running; we now hold the lock. Keep the guard + /// alive for the process's lifetime. + Started(Guard), + /// Another instance was already running and (if a PID could be read + /// from the file) has been sent `SIGTERM`. The caller should exit + /// immediately without starting. + KilledExisting, +} + +/// `$XDG_RUNTIME_DIR/.pid` (falling back to `/tmp`, matching every +/// existing consumer's own fallback) — same location `breadbox`/`breadclip` +/// already used. +pub fn pid_file_path(app: &str) -> PathBuf { + crate::xdg::runtime_dir().join(format!("{app}.pid")) +} + +/// Try to become the single instance of `app`, with no side effects beyond +/// the lock/file itself — in particular, unlike [`toggle_or_kill`], this +/// never signals another process. Prefer this if your app wants different +/// behavior than "kill the existing instance" (e.g. just refuse to start a +/// second copy). +pub fn try_acquire(app: &str) -> std::io::Result { + let path = pid_file_path(app); + let mut file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .open(&path)?; + + match file.try_lock() { + Ok(()) => { + file.set_len(0)?; + file.seek(SeekFrom::Start(0))?; + write!(file, "{}", std::process::id())?; + file.sync_all()?; + Ok(Acquire::Acquired(Guard { _file: file, path })) + } + Err(_) => { + let mut contents = String::new(); + let _ = file.read_to_string(&mut contents); + Ok(Acquire::HeldByOther(contents.trim().parse::().ok())) + } + } +} + +/// Toggle behavior: acquire the single-instance lock for `app`. If already +/// held by another live process, signal it to quit (`SIGTERM` via `kill`) +/// and return [`Toggle::KilledExisting`] — the caller should exit. Otherwise +/// take the lock and return [`Toggle::Started`] — the caller should proceed +/// and keep the guard alive. +pub fn toggle_or_kill(app: &str) -> std::io::Result { + Ok(match try_acquire(app)? { + Acquire::Acquired(guard) => Toggle::Started(guard), + Acquire::HeldByOther(Some(pid)) => { + kill(pid); + Toggle::KilledExisting + } + Acquire::HeldByOther(None) => Toggle::KilledExisting, + }) +} + +#[cfg(unix)] +fn kill(pid: u32) { + // Shells out rather than binding libc directly, matching how every + // existing consumer already did this (`Command::new("kill")`) — no new + // dependency for a one-shot signal. + let _ = std::process::Command::new("kill") + .arg(pid.to_string()) + .status(); +} + +#[cfg(not(unix))] +fn kill(_pid: u32) {} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + fn unique_app(name: &str) -> String { + format!("bread-utils-singleton-test-{name}-{}", std::process::id()) + } + + #[test] + fn first_acquire_succeeds_and_releases_on_drop() { + let app = unique_app("first"); + match try_acquire(&app).unwrap() { + Acquire::Acquired(_guard) => {} + Acquire::HeldByOther(_) => panic!("expected to be the first instance"), + } + // Guard dropped at end of scope; pid file should be gone. + std::thread::sleep(Duration::from_millis(10)); + assert!(!pid_file_path(&app).exists()); + } + + #[test] + fn second_acquire_while_first_is_held_reports_held_by_other_with_our_pid() { + let app = unique_app("second"); + let guard = match try_acquire(&app).unwrap() { + Acquire::Acquired(g) => g, + Acquire::HeldByOther(_) => panic!("expected to be the first instance"), + }; + + // A second attempt while the first guard is still held must not be + // able to acquire the lock too — that's the whole point. No signal + // is sent by `try_acquire` itself (that's `toggle_or_kill`'s job), + // so this is safe to assert without affecting the test process. + match try_acquire(&app).unwrap() { + Acquire::HeldByOther(pid) => assert_eq!(pid, Some(std::process::id())), + Acquire::Acquired(_) => panic!("second acquire succeeded while the first still holds the lock"), + } + + drop(guard); + } + + #[test] + fn lock_is_released_after_guard_drop_so_a_later_instance_can_acquire() { + let app = unique_app("release"); + let guard = match try_acquire(&app).unwrap() { + Acquire::Acquired(g) => g, + Acquire::HeldByOther(_) => panic!("expected to be the first instance"), + }; + drop(guard); + + match try_acquire(&app).unwrap() { + Acquire::Acquired(_g) => {} + Acquire::HeldByOther(_) => panic!("lock should have been released when the guard was dropped"), + } + } + + #[test] + fn toggle_or_kill_starts_when_nothing_else_is_running() { + let app = unique_app("toggle-start"); + match toggle_or_kill(&app).unwrap() { + Toggle::Started(_guard) => {} + Toggle::KilledExisting => panic!("expected to start as the first instance"), + } + } + + // Deliberately not unit-tested: `toggle_or_kill`'s kill-the-existing- + // instance branch. Exercising it for real means sending a real SIGTERM + // to a real process; the only PID a test process can safely target is + // its own (as a stand-in "other instance" via a shared PID file), and + // doing that would SIGTERM the test binary itself. The branch is a + // two-line, directly-inspectable call to `kill()` gated on + // `HeldByOther(Some(pid))`, which the `second_acquire_...` test above + // already exercises up to (and excluding) the signal send. +} diff --git a/bread-utils/src/tomlcfg.rs b/bread-utils/src/tomlcfg.rs new file mode 100644 index 0000000..bc4c841 --- /dev/null +++ b/bread-utils/src/tomlcfg.rs @@ -0,0 +1,100 @@ +//! Non-destructive TOML config editing discipline. +//! +//! Extracted from `bos-settings/src/config/mod.rs` (`load_doc`/`save_doc`) +//! and `breadhelp/src/config.rs`, which re-implemented the exact same +//! function bodies in the same fix pass that introduced `bos-settings`'s +//! version — right down to the eprintln wording template. Both parse into a +//! `toml_edit::DocumentMut` (preserving keys/comments/formatting this app +//! doesn't model) and back up a file that exists but fails to parse, once, +//! before falling back to an empty document — so a bad edit is always +//! recoverable from `.bak` instead of silently destroying whatever the +//! file used to hold. +//! +//! Requires the `toml` feature. + +use std::path::Path; +use toml_edit::DocumentMut; + +/// Load a TOML file into an editable document. A missing file yields an +/// empty document (normal for a fresh install). A file that *exists* but +/// fails to parse is backed up to `.bak` once before falling back to +/// an empty document, so the next [`save_doc`] doesn't silently overwrite an +/// unparseable-but-recoverable file with only the caller's modelled keys. +/// +/// `app` is used only to prefix the parse-failure log line (e.g. +/// `"breadhelp"`, `"bos-settings"`). +pub fn load_doc(app: &str, path: &Path) -> DocumentMut { + let Ok(text) = std::fs::read_to_string(path) else { + return DocumentMut::default(); + }; + match text.parse::() { + Ok(doc) => doc, + Err(e) => { + let backup = super::atomic::backup_path_for(path); + eprintln!( + "{app}: {} failed to parse ({e}); backed up to {} before falling back to defaults", + path.display(), + backup.display() + ); + let _ = std::fs::write(&backup, &text); + DocumentMut::default() + } + } +} + +/// Write the document back to disk atomically (temp-then-rename), backing up +/// whatever was there before overwriting it — see +/// [`crate::atomic::write_atomic_backed_up`]. +pub fn save_doc(path: &Path, doc: &DocumentMut) -> std::io::Result<()> { + super::atomic::write_atomic_backed_up(path, &doc.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use toml_edit::value; + + fn tmp_dir(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("bread-utils-tomlcfg-test-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn missing_file_yields_empty_document() { + let dir = tmp_dir("missing"); + let doc = load_doc("test", &dir.join("nope.toml")); + assert!(doc.is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn save_then_load_round_trips() { + let dir = tmp_dir("roundtrip"); + let path = dir.join("state.toml"); + let mut doc = DocumentMut::default(); + doc["general"]["mode"] = value("dad"); + save_doc(&path, &doc).unwrap(); + + let loaded = load_doc("test", &path); + assert_eq!( + loaded.get("general").and_then(|t| t.get("mode")).and_then(|v| v.as_str()), + Some("dad") + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn unparseable_existing_file_is_backed_up_before_falling_back() { + let dir = tmp_dir("bad-parse"); + let path = dir.join("state.toml"); + std::fs::write(&path, "this is not [ valid toml").unwrap(); + + let doc = load_doc("test", &path); + assert!(doc.is_empty()); + let backup = dir.join("state.toml.bak"); + assert_eq!(std::fs::read_to_string(&backup).unwrap(), "this is not [ valid toml"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/bread-utils/src/xdg.rs b/bread-utils/src/xdg.rs new file mode 100644 index 0000000..75ca70e --- /dev/null +++ b/bread-utils/src/xdg.rs @@ -0,0 +1,95 @@ +//! XDG base directory helpers. +//! +//! Several repos independently rolled `dirs::data_local_dir().unwrap_or_else(|| +//! PathBuf::from("~/.local/share"))`-shaped fallbacks. The literal-tilde +//! string is the bug: `PathBuf`/`std::fs` never expand `~`, so on the rare +//! box where `dirs` can't resolve a home directory (no `HOME` env var, e.g. +//! some container/systemd-service contexts) the fallback silently resolves +//! to a directory literally named `~` in the process's current working +//! directory instead of the user's actual home. Confirmed present in: +//! - `breadclip-core/src/lib.rs:171-175` (`data_dir`) +//! - `breadpad-shared/src/classifier.rs:34-39` (`model_dir`) +//! - `breadpad-shared/src/config.rs:214-219` and `:221-226` +//! (`config_path`, `style_css_path`) +//! +//! The helpers here resolve a real `$HOME` (via `dirs::home_dir()`, which +//! itself falls back to reading `HOME` directly) before ever falling back, +//! so the fallback path is always an absolute, expanded path. + +use std::path::PathBuf; + +fn home_or_root() -> PathBuf { + dirs::home_dir().unwrap_or_else(|| PathBuf::from("/root")) +} + +/// `$XDG_CONFIG_HOME` (only if it's set to an absolute path) or `~/.config`, +/// joined with `app`. +pub fn config_dir(app: &str) -> PathBuf { + base_config_dir().join(app) +} + +/// `$XDG_DATA_HOME` (only if absolute) or `~/.local/share`, joined with `app`. +pub fn data_dir(app: &str) -> PathBuf { + dirs::data_local_dir() + .unwrap_or_else(|| home_or_root().join(".local/share")) + .join(app) +} + +/// `$XDG_CACHE_HOME` (only if absolute) or `~/.cache`, joined with `app`. +pub fn cache_dir(app: &str) -> PathBuf { + dirs::cache_dir() + .unwrap_or_else(|| home_or_root().join(".cache")) + .join(app) +} + +/// `$XDG_RUNTIME_DIR`, falling back to `/tmp` — matches the fallback every +/// consumer (breadbox, breadclip, breadmon) already used for PID/socket +/// scratch files, which don't need to survive a reboot. +pub fn runtime_dir() -> PathBuf { + std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/tmp")) +} + +fn base_config_dir() -> PathBuf { + if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") { + let p = PathBuf::from(xdg); + if p.is_absolute() { + return p; + } + } + dirs::config_dir().unwrap_or_else(|| home_or_root().join(".config")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_dir_joins_app_name() { + let d = config_dir("breadpad"); + assert!(d.ends_with("breadpad")); + assert!(d.is_absolute()); + } + + #[test] + fn data_dir_never_contains_literal_tilde() { + // Regression guard for the exact bug this module replaces: the + // fallback must never be a literal "~/..." path component. + let d = data_dir("breadclip"); + assert!(!d.components().any(|c| c.as_os_str() == "~")); + assert!(d.is_absolute()); + } + + #[test] + fn cache_dir_is_absolute() { + assert!(cache_dir("breadsearch").is_absolute()); + } + + #[test] + fn runtime_dir_falls_back_to_tmp() { + // We don't unset XDG_RUNTIME_DIR here (test isolation), just confirm + // the function returns *something* absolute either way. + assert!(runtime_dir().is_absolute()); + } +} From 49c63c8ccf021243e1dff3136a69e9192fa17f5b Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 17 Jul 2026 10:02:42 +0800 Subject: [PATCH 03/99] bread-utils: fix flaky test isolation around process-global env vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit singleton.rs's tests used to call the real kill() on the current test process itself (the recorded "other instance" PID was our own, since tests run single-process) — split try_acquire (side-effect-free) out from toggle_or_kill (sends the signal) so the concurrency assertions no longer risk SIGTERM-ing the test binary. Separately, hypr.rs's env-var test mutates HYPRLAND_INSTANCE_SIGNATURE/ XDG_RUNTIME_DIR process-globally; cargo runs tests in parallel threads by default, so it could race a concurrently-running singleton or xdg test expecting the real XDG_RUNTIME_DIR, intermittently failing them with ENOENT. Added a shared env_test_lock() all env-var-touching tests now acquire for their duration. Verified via 5 repeated full test runs with zero flakes (28/28 passing each time). --- bread-utils/src/hypr.rs | 1 + bread-utils/src/lib.rs | 12 ++++++++++++ bread-utils/src/singleton.rs | 6 ++++++ bread-utils/src/xdg.rs | 11 ++++++++++- 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/bread-utils/src/hypr.rs b/bread-utils/src/hypr.rs index 9bcdf26..a1b18a7 100644 --- a/bread-utils/src/hypr.rs +++ b/bread-utils/src/hypr.rs @@ -217,6 +217,7 @@ mod tests { // vars would be flaky. #[test] fn socket_path_env_var_behavior() { + let _lock = crate::env_test_lock().lock().unwrap_or_else(|e| e.into_inner()); unsafe { env::remove_var("HYPRLAND_INSTANCE_SIGNATURE") }; assert!(socket_path(Socket::Request).is_none()); diff --git a/bread-utils/src/lib.rs b/bread-utils/src/lib.rs index 84e8621..12f6bf5 100644 --- a/bread-utils/src/lib.rs +++ b/bread-utils/src/lib.rs @@ -25,6 +25,18 @@ pub mod proc; pub mod singleton; pub mod xdg; +/// Serializes tests that read or mutate process-global env vars +/// (`XDG_RUNTIME_DIR`, `HYPRLAND_INSTANCE_SIGNATURE`) — `cargo test` runs +/// tests in parallel threads within one process by default, and +/// `std::env::set_var` is process-wide, so a `hypr` test temporarily +/// pointing `XDG_RUNTIME_DIR` at a nonexistent path can otherwise race a +/// concurrently-running `singleton` or `xdg` test that expects the real one. +#[cfg(test)] +pub(crate) fn env_test_lock() -> &'static std::sync::Mutex<()> { + static LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); + LOCK.get_or_init(|| std::sync::Mutex::new(())) +} + #[cfg(feature = "toml")] pub mod tomlcfg; diff --git a/bread-utils/src/singleton.rs b/bread-utils/src/singleton.rs index 2fa9eee..6809747 100644 --- a/bread-utils/src/singleton.rs +++ b/bread-utils/src/singleton.rs @@ -138,6 +138,9 @@ mod tests { #[test] fn first_acquire_succeeds_and_releases_on_drop() { + // Guards against `hypr`'s env-var test concurrently pointing + // XDG_RUNTIME_DIR at a nonexistent path mid-test — see `env_test_lock`. + let _lock = crate::env_test_lock().lock().unwrap_or_else(|e| e.into_inner()); let app = unique_app("first"); match try_acquire(&app).unwrap() { Acquire::Acquired(_guard) => {} @@ -150,6 +153,7 @@ mod tests { #[test] fn second_acquire_while_first_is_held_reports_held_by_other_with_our_pid() { + let _lock = crate::env_test_lock().lock().unwrap_or_else(|e| e.into_inner()); let app = unique_app("second"); let guard = match try_acquire(&app).unwrap() { Acquire::Acquired(g) => g, @@ -170,6 +174,7 @@ mod tests { #[test] fn lock_is_released_after_guard_drop_so_a_later_instance_can_acquire() { + let _lock = crate::env_test_lock().lock().unwrap_or_else(|e| e.into_inner()); let app = unique_app("release"); let guard = match try_acquire(&app).unwrap() { Acquire::Acquired(g) => g, @@ -185,6 +190,7 @@ mod tests { #[test] fn toggle_or_kill_starts_when_nothing_else_is_running() { + let _lock = crate::env_test_lock().lock().unwrap_or_else(|e| e.into_inner()); let app = unique_app("toggle-start"); match toggle_or_kill(&app).unwrap() { Toggle::Started(_guard) => {} diff --git a/bread-utils/src/xdg.rs b/bread-utils/src/xdg.rs index 75ca70e..30542f5 100644 --- a/bread-utils/src/xdg.rs +++ b/bread-utils/src/xdg.rs @@ -25,7 +25,15 @@ fn home_or_root() -> PathBuf { /// `$XDG_CONFIG_HOME` (only if it's set to an absolute path) or `~/.config`, /// joined with `app`. pub fn config_dir(app: &str) -> PathBuf { - base_config_dir().join(app) + config_home().join(app) +} + +/// The bare `$XDG_CONFIG_HOME` (or `~/.config`) directory, with no app name +/// joined on — for callers that build up multiple sub-paths themselves +/// (e.g. `bos-settings`, which joins a different bread* app's name per +/// config file it edits). +pub fn config_home() -> PathBuf { + base_config_dir() } /// `$XDG_DATA_HOME` (only if absolute) or `~/.local/share`, joined with `app`. @@ -88,6 +96,7 @@ mod tests { #[test] fn runtime_dir_falls_back_to_tmp() { + let _lock = crate::env_test_lock().lock().unwrap_or_else(|e| e.into_inner()); // We don't unset XDG_RUNTIME_DIR here (test isolation), just confirm // the function returns *something* absolute either way. assert!(runtime_dir().is_absolute()); From 41276479aaa4cf85df30b89cead5b1bbc034223d Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 17 Jul 2026 10:05:26 +0800 Subject: [PATCH 04/99] bread-utils: add socket timeouts to hypr::request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neither original implementation this replaces (breadbox's get_active_workspace, breadclip's hyprctl_json) set a read/write timeout on the Hyprland IPC socket — a wedged or mid-reload Hyprland instance could hang the call indefinitely, and every current caller runs it on the GTK main thread, so a hang here freezes the whole UI. Found while reviewing the crate for this pass's "any existing bugs" sweep; same class of bug as everything else bread_utils::proc/hypr exists to fix, just one I'd introduced myself by porting the original code faithfully without also porting the missing safety net. --- bread-utils/src/hypr.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/bread-utils/src/hypr.rs b/bread-utils/src/hypr.rs index a1b18a7..def3e98 100644 --- a/bread-utils/src/hypr.rs +++ b/bread-utils/src/hypr.rs @@ -51,9 +51,20 @@ pub fn socket_path(kind: Socket) -> Option { /// IPC socket and return the raw response body. Blocking/synchronous — this /// matches every current consumer (breadbox, breadclip), which call it from /// non-async GTK app code. +/// +/// Read/write timeouts are set on the socket (both original hand-rolled +/// implementations this replaces — breadbox's `get_active_workspace`, +/// breadclip's `hyprctl_json` — had none): a Hyprland instance that's +/// wedged or mid-reload could otherwise hang this call, and every current +/// caller runs it on the GTK main thread, so a hang here freezes the whole +/// UI, not just this query. +const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); + pub fn request(request: &str) -> Option { let socket = socket_path(Socket::Request)?; let mut stream = UnixStream::connect(&socket).ok()?; + stream.set_read_timeout(Some(REQUEST_TIMEOUT)).ok()?; + stream.set_write_timeout(Some(REQUEST_TIMEOUT)).ok()?; stream.write_all(request.as_bytes()).ok()?; stream.shutdown(std::net::Shutdown::Write).ok()?; From 49b384c5494976686339880f1d2703722d1728f9 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 17 Jul 2026 10:08:26 +0800 Subject: [PATCH 05/99] bread-onnx: default a MIGraphX kernel cache path if the caller hasn't set one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while investigating why breadpad's test suite went from fast (silent CPU-only ROCm no-op) to multi-minutes slow after fixing the ROCm->MIGraphX bug in the previous commit: MIGraphX has no Rust-level cache-directory builder (unlike OpenVINO/Vitis) — it's controlled purely by the ORT_MIGRAPHX_MODEL_CACHE_PATH env var, read by the underlying MIGraphX library. Left unset, every session build recompiles every kernel from scratch with no persistence at all, visible in breadpad's test output as repeated `migraphx_save: Error: ... write_buffer: Failure opening file: ""/.mxr` lines (empty path prefix = var never set). breadmill's own packaged systemd unit already sets this explicitly (packaging/breadmill.service: ORT_MIGRAPHX_MODEL_CACHE_PATH=%h/.cache/ breadsearch/migraphx-cache) but nothing enforced any other consumer doing the same — breadpad had no systemd unit or cache path at all. Provider:: MiGraphX's to_dispatch now defaults ORT_MIGRAPHX_MODEL_CACHE_PATH to ~/.cache/bread-onnx/migraphx if unset, so every consumer gets kernel-cache persistence for free instead of only the ones that remembered to configure it themselves. --- bread-onnx/src/provider.rs | 43 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/bread-onnx/src/provider.rs b/bread-onnx/src/provider.rs index aeba01e..02460e5 100644 --- a/bread-onnx/src/provider.rs +++ b/bread-onnx/src/provider.rs @@ -70,6 +70,7 @@ impl Provider { Ok(match self { Provider::Cpu => ort::ep::CPU::default().build(), Provider::MiGraphX { device_id } => { + ensure_migraphx_cache_path_default()?; ort::ep::MIGraphX::default().with_device_id(*device_id).build() } Provider::Cuda { device_id } => { @@ -97,7 +98,8 @@ impl Provider { /// reminder of exactly what to grep ONNX Runtime's own log output for — /// this is the "at minimum log EP registration success/failure loudly /// by default" half of the fix, independent of whether the caller has - /// wired up `tracing_subscriber` (see [`crate::init_tracing`]). + /// wired up `tracing_subscriber` (all three current consumers already + /// do, at their own startup). pub(crate) fn log_selection(&self) { tracing::info!("bread-onnx: requesting {} execution provider", self.name()); if !matches!(self, Provider::Cpu) { @@ -105,9 +107,46 @@ impl Provider { "bread-onnx: check ONNX Runtime's own log output for \"Successfully registered \ `{}`\" — if it's missing, the ONNX Runtime build in use wasn't compiled/shipped \ with this provider and inference silently fell back to CPU. This line only \ - appears if a `tracing` subscriber is initialized (see `bread_onnx::init_tracing`).", + appears if a `tracing` subscriber is initialized.", self.ort_registration_name() ); } } } + +/// MIGraphX has no Rust-level "cache directory" builder (unlike OpenVINO/ +/// Vitis above) — it's controlled purely by the `ORT_MIGRAPHX_MODEL_CACHE_PATH` +/// environment variable, read by the underlying MIGraphX library at EP- +/// registration time. Left unset, MIGraphX still *works*, but recompiles +/// every kernel from scratch on every single session build — no persistence +/// between runs, or even between two sessions in the same process. Found via +/// this pass's own migration: `breadmill`'s packaged systemd unit already +/// sets this explicitly (`packaging/breadmill.service`), but nothing +/// enforced any other consumer doing the same, and `breadpad` (which had no +/// systemd unit or cache path at all) hit exactly this — every +/// `Classifier::load` call during its own test suite recompiled from a cold +/// cache, visible as repeated `migraphx_save: Error: ... write_buffer: +/// Failure opening file: ""/.mxr` log lines (an empty path prefix, +/// i.e. the env var was never set) and multi-minute test runs. +/// +/// This sets a sensible shared default (`~/.cache/bread-onnx/migraphx`) if +/// the caller hasn't already set one — so every consumer gets kernel-cache +/// persistence for free instead of only the ones that remembered to +/// configure it themselves. +fn ensure_migraphx_cache_path_default() -> anyhow::Result<()> { + if std::env::var_os("ORT_MIGRAPHX_MODEL_CACHE_PATH").is_some() { + return Ok(()); + } + let dir = bread_utils::xdg::cache_dir("bread-onnx").join("migraphx"); + std::fs::create_dir_all(&dir)?; + tracing::info!( + "bread-onnx: ORT_MIGRAPHX_MODEL_CACHE_PATH not set; defaulting to {} \ + so MIGraphX kernel compiles persist across runs", + dir.display() + ); + // SAFETY: this runs before any session build spawns worker threads that + // might read the environment concurrently — same caveat as any + // `set_var` call, documented here rather than papered over. + unsafe { std::env::set_var("ORT_MIGRAPHX_MODEL_CACHE_PATH", &dir) }; + Ok(()) +} From 6ae7edb83c7a8775a24e060aee3249e115c528c3 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 17 Jul 2026 10:11:53 +0800 Subject: [PATCH 06/99] bread-utils: expose xdg::home_dir(); document two more tilde-fallback bug sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit home_dir() was already computed internally (home_or_root) but not exposed — breadarr-shared's own expand_home() helper needs it to fix the same bug class documented in this module: its own fallback (when HOME itself isn't set) returned the literal unexpanded "~/..." input string instead of a real path. Also documented breadmon/src/profile.rs's profiles_dir(), fixed in that repo's own commit. --- bread-utils/src/xdg.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/bread-utils/src/xdg.rs b/bread-utils/src/xdg.rs index 30542f5..4409fd0 100644 --- a/bread-utils/src/xdg.rs +++ b/bread-utils/src/xdg.rs @@ -11,6 +11,11 @@ //! - `breadpad-shared/src/classifier.rs:34-39` (`model_dir`) //! - `breadpad-shared/src/config.rs:214-219` and `:221-226` //! (`config_path`, `style_css_path`) +//! - `breadmon/src/profile.rs:31-35` (`profiles_dir`) +//! - `breadarr-shared/src/config.rs:316-321`'s own `expand_home` helper, +//! which had the same bug in a different shape: its *own* fallback (when +//! `HOME` itself isn't set) returned the literal, unexpanded input string +//! rather than a real path. //! //! The helpers here resolve a real `$HOME` (via `dirs::home_dir()`, which //! itself falls back to reading `HOME` directly) before ever falling back, @@ -18,6 +23,13 @@ use std::path::PathBuf; +/// A real, absolute home directory — `dirs::home_dir()`, falling back to +/// `/root` only if that itself fails (no `HOME` env var *and* no passwd-db +/// entry, e.g. some minimal container contexts). Never a literal `"~"`. +pub fn home_dir() -> PathBuf { + home_or_root() +} + fn home_or_root() -> PathBuf { dirs::home_dir().unwrap_or_else(|| PathBuf::from("/root")) } @@ -80,6 +92,13 @@ mod tests { assert!(d.is_absolute()); } + #[test] + fn home_dir_is_absolute_and_never_a_literal_tilde() { + let d = home_dir(); + assert!(d.is_absolute()); + assert!(!d.components().any(|c| c.as_os_str() == "~")); + } + #[test] fn data_dir_never_contains_literal_tilde() { // Regression guard for the exact bug this module replaces: the From 025e27b49634f713a5e94da21531442c962eb634 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 17 Jul 2026 14:06:12 +0800 Subject: [PATCH 07/99] Move bakery's own release workflow from .github to .forgejo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .github/workflows/release.yml built and published the bakery binary itself, but it lived under .github/ and targeted runs-on: [self-hosted, hestia] — a runner label only registered against Forgejo, never against GitHub Actions. It has therefore never run; get.sh has been pointing at dl.breadway.dev/bakery/... this whole time with nothing actually publishing there. Recreated the same logic as .forgejo/workflows/release-bakery.yml, matching the sibling release-bread-theme.yml in this repo (manual clone instead of actions/checkout, GH_RELEASE_TOKEN instead of the GitHub-provided GITHUB_TOKEN, same dormant-until-provisioned minisign signing step). Removed the dead .github copy. --- .forgejo/workflows/release-bakery.yml | 74 ++++++++++++++ .github/workflows/release.yml | 85 ---------------- docs/release-channels.md | 94 ++++++++++++++++++ scripts/doctor-channels.sh | 133 ++++++++++++++++++++++++++ 4 files changed, 301 insertions(+), 85 deletions(-) create mode 100644 .forgejo/workflows/release-bakery.yml delete mode 100644 .github/workflows/release.yml create mode 100644 docs/release-channels.md create mode 100755 scripts/doctor-channels.sh diff --git a/.forgejo/workflows/release-bakery.yml b/.forgejo/workflows/release-bakery.yml new file mode 100644 index 0000000..283153b --- /dev/null +++ b/.forgejo/workflows/release-bakery.yml @@ -0,0 +1,74 @@ +name: release bakery + +on: + push: + tags: ['v*'] + +jobs: + build: + runs-on: [self-hosted, hestia] + steps: + - name: checkout + run: | + set -euo pipefail + rm -rf src && mkdir src + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src + + - name: build + run: cd src && cargo build --release --locked -p bakery + + - name: test + run: cd src && cargo test --release --locked -p bakery + + - name: prepare artifacts + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="/srv/breadway-dl/bakery/${VERSION}" + mkdir -p "${PKG_DIR}" + cp "src/target/release/bakery" "${PKG_DIR}/bakery-x86_64" + strip "${PKG_DIR}/bakery-x86_64" + sha256sum "${PKG_DIR}/bakery-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/bakery-x86_64.sha256" + cp src/bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${VERSION}" "/srv/breadway-dl/bakery/latest" + + # Signs the bakery binary itself with the shared bakery ecosystem signing + # key (same key that signs index.json and bread-theme — see + # release-bread-theme.yml). BAKERY_MINISIGN_SEC_KEY_PATH is a *path on + # this runner's disk* (hestia has persistent storage), not the key + # contents. Dormant (binary ships unsigned, as today) until that secret + # is provisioned. + - name: sign release binary + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="/srv/breadway-dl/bakery/${VERSION}" + if [ -n "${MINISIGN_SEC_KEY:-}" ]; then + minisign -W -S -s "${MINISIGN_SEC_KEY}" -m "${PKG_DIR}/bakery-x86_64" \ + -x "${PKG_DIR}/bakery-x86_64.minisig" /dev/null || true + ASSETS="${PKG_DIR}/bakery-x86_64 ${PKG_DIR}/bakery-x86_64.sha256" + [ -f "${PKG_DIR}/bakery-x86_64.minisig" ] && ASSETS="${ASSETS} ${PKG_DIR}/bakery-x86_64.minisig" + gh release upload "${GITHUB_REF_NAME}" --repo Breadway/bread-ecosystem ${ASSETS} --clobber diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 1f9675b..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,85 +0,0 @@ -name: release - -on: - push: - tags: ["v*"] - -permissions: - contents: write - -env: - DL_DIR: /srv/breadway-dl - -jobs: - build: - runs-on: [self-hosted, hestia] - steps: - - uses: actions/checkout@v4 - - - name: build - run: cargo build --release --locked -p bakery - - - name: test - run: cargo test --locked --workspace - - - name: prepare artifacts - run: | - VERSION="${GITHUB_REF_NAME#v}" - PKG_DIR="${DL_DIR}/bakery/${VERSION}" - mkdir -p "${PKG_DIR}" - - cp target/release/bakery "${PKG_DIR}/bakery-x86_64" - strip "${PKG_DIR}/bakery-x86_64" - sha256sum "${PKG_DIR}/bakery-x86_64" | awk '{print $1}' \ - > "${PKG_DIR}/bakery-x86_64.sha256" - - cp bakery.toml "${PKG_DIR}/bakery.toml" - ln -sfn "${VERSION}" "${DL_DIR}/bakery/latest" - - # Signs the bakery binary itself with the same minisign key that signs - # index.json (get.sh pins the matching public key). Dormant until the - # BAKERY_MINISIGN_SEC_KEY secret is actually provisioned in this repo's - # Actions settings — until then this step logs a warning and the - # binary ships unsigned, exactly as it does today. - - name: sign release binary - env: - MINISIGN_SEC_KEY_CONTENTS: ${{ secrets.BAKERY_MINISIGN_SEC_KEY }} - run: | - VERSION="${GITHUB_REF_NAME#v}" - PKG_DIR="${DL_DIR}/bakery/${VERSION}" - if [ -n "${MINISIGN_SEC_KEY_CONTENTS}" ]; then - command -v minisign >/dev/null 2>&1 || { echo "::error::minisign not installed on runner"; exit 1; } - KEY_FILE="$(mktemp)" - trap 'shred -u "${KEY_FILE}" 2>/dev/null || rm -f "${KEY_FILE}"' EXIT - printf '%s' "${MINISIGN_SEC_KEY_CONTENTS}" > "${KEY_FILE}" - minisign -W -S -s "${KEY_FILE}" -m "${PKG_DIR}/bakery-x86_64" \ - -x "${PKG_DIR}/bakery-x86_64.minisig" /dev/null || rm -f "${KEY_FILE}"' EXIT - printf '%s' "${MINISIGN_SEC_KEY_CONTENTS}" > "${KEY_FILE}" - MINISIGN_SEC_KEY="${KEY_FILE}" bash "${GITHUB_WORKSPACE}/scripts/gen-index.sh" - else - bash "${GITHUB_WORKSPACE}/scripts/gen-index.sh" - fi - - - name: upload to GitHub Release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - VERSION="${GITHUB_REF_NAME#v}" - PKG_DIR="${DL_DIR}/bakery/${VERSION}" - gh release create "${GITHUB_REF_NAME}" \ - --title "bakery v${VERSION}" --generate-notes 2>/dev/null || true - ASSETS="${PKG_DIR}/bakery-x86_64 ${PKG_DIR}/bakery-x86_64.sha256" - [ -f "${PKG_DIR}/bakery-x86_64.minisig" ] && ASSETS="${ASSETS} ${PKG_DIR}/bakery-x86_64.minisig" - gh release upload "${GITHUB_REF_NAME}" ${ASSETS} --clobber diff --git a/docs/release-channels.md b/docs/release-channels.md new file mode 100644 index 0000000..952c9c2 --- /dev/null +++ b/docs/release-channels.md @@ -0,0 +1,94 @@ +# Release channel policy + +There are two independent distribution channels in the bread ecosystem, plus +a third "neither" state for repos that aren't distributed yet. Every repo +under `Breadway/` should sit in exactly one of these three buckets, and its +`.forgejo/workflows/` directory + packaging metadata should match that +bucket exactly — no more files, no fewer. + +## The two channels + +**bakery channel** (`bakery install `, `curl .../get | sh`, or a raw +binary download from dl.breadway.dev / the GitHub release page). A repo is on +this channel if and only if **all** of the following are true: + +1. It has a `bakery.toml` at the root (or, for a multi-product repo like + bread-ecosystem, one per product directory). +2. It has an entry in `bread-ecosystem`'s `registry/bread-ecosystem.toml`. + `scripts/gen-index.sh` only ever looks at repos listed there — a + `bakery.toml` that isn't backed by a registry entry is inert. +3. It has a `.forgejo/workflows/release.yml` (or a product-specific name + like `release-bread-theme.yml` / `release-bakery.yml` for multi-product + repos) that builds the binary, drops it under `/srv/breadway-dl//`, + copies `bakery.toml` alongside it, regenerates `index.json` via + `bread-ecosystem/scripts/gen-index.sh`, and uploads the same artifacts to + a GitHub release as a fallback mirror. + +All three must be present together. Two out of three is a bug, not a +partial rollout — either finish the third piece or remove the other two. + +**pacman channel** (`pacman -S ` from the self-hosted `[breadway]` +repo, built via AUR-style `PKGBUILD`s). A repo is on this channel if and +only if: + +1. It has a `PKGBUILD` under `packaging/` (either `packaging/PKGBUILD` or + `packaging/arch/PKGBUILD` — both patterns exist in the wild, pick + whichever a sibling repo of the same shape already uses). +2. It has a `.forgejo/workflows/package.yml` that builds the package in an + `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). + +`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 +should never carry a `bakery.toml` or `PKGBUILD`. + +## mirror.yml is not part of this policy + +Every repo previously carried its own `.forgejo/workflows/mirror.yml` doing +a `git clone --mirror` + push to GitHub with a per-repo `MIRROR_TOKEN` +secret. That pattern is being replaced ecosystem-wide by Forgejo's native +Push Mirror feature, provisioned centrally by +`bread-ecosystem/scripts/setup-push-mirrors.sh` against the live repo list +— see that script and `scripts/cleanup-old-mirror-workflows.sh`. Once the +migration is confirmed working, no repo should have a `mirror.yml` and this +document doesn't require one. Don't add `mirror.yml` to a repo that's +missing it; that gap is intentional and about to be moot everywhere. + +## Checklist for adding a repo to a channel + +- **Bakery**: write `bakery.toml`, add a `[[products]]` entry to + `bread-ecosystem/registry/bread-ecosystem.toml`, copy a sibling's + `release.yml` (prefer one with the same shape: single binary vs. binary + + systemd service — compare against `bread/release.yml` if there's a + service to install, `breadmon/release.yml` if not) and swap the repo + name / binary name / `PKG_DIR`. +- **Pacman**: write `packaging/PKGBUILD` (or `packaging/arch/PKGBUILD`), + copy a sibling's `package.yml` and swap the repo/package name and + `system_deps`→`pacman -Syu` package list. +- Never add either file type "just in case." An unused `bakery.toml` or + `PKGBUILD` is exactly the kind of drift this document exists to prevent + (see the breadlock/breadarr/bos-settings history in the audit that + produced this doc — two of those had a stray `bakery.toml` nothing + served, one was missing the registry entry + release.yml that would have + made an existing `bakery.toml` real). + +## Current state (as of this pass) + +| Repo | bakery | pacman | notes | +|---|---|---|---| +| bread-ecosystem (bakery product) | yes | yes | `release-bakery.yml` recovered from a dead `.github/workflows/release.yml` that referenced a `hestia` self-hosted runner GitHub never had registered | +| bread-ecosystem (bread-theme product) | yes | no | | +| bread, breadbar, breadbox, breadcrumbs, breadpad, breadpaper | yes | yes | complete, used as templates | +| breadclip, breadmon, breadsearch, breadshot | yes | no | complete | +| breadlock, breadhelp | no | yes | breadlock's `bakery.toml` was removed as orphaned; its README wrongly claimed it was a registry entry | +| bos-settings | yes | yes | was missing both the registry entry and `release.yml`; both added | +| bos | no | no | ISO-only via `release-iso.yml`; had an erroneous `bakery.toml` copy-pasted from bos-settings, removed | +| breadarr | no | no | 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 | diff --git a/scripts/doctor-channels.sh b/scripts/doctor-channels.sh new file mode 100755 index 0000000..9164565 --- /dev/null +++ b/scripts/doctor-channels.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# doctor-channels.sh — detect drift between a repo's declared distribution +# channel(s) and its actual .forgejo/workflows/ + packaging metadata. +# +# See docs/release-channels.md for the policy this checks against. +# +# Usage: +# scripts/doctor-channels.sh [BASE_DIR] +# +# BASE_DIR defaults to the parent of this repo checkout (i.e. run from a +# normal ~/Projects/bread-ecosystem checkout, it scans sibling ~/Projects/* +# repos). Point it at a directory of worktrees (e.g. ~/Projects, which is +# also where *-fix-worktree checkouts live) to check those instead: +# +# scripts/doctor-channels.sh ~/Projects +# +# Exits 0 if no drift found, 1 if any repo has drift (so it's CI-friendly). +# +# Requires: python3 (tomllib, stdlib since 3.11) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BASE_DIR="${1:-$(dirname "${SCRIPT_DIR}")}" +REGISTRY="${SCRIPT_DIR}/registry/bread-ecosystem.toml" + +if [[ ! -f "${REGISTRY}" ]]; then + echo "error: registry not found at ${REGISTRY}" >&2 + exit 2 +fi + +# repo (last path segment of registry `repo = "Breadway/x"`) -> 1 +mapfile -t registry_repos < <(python3 -c " +import tomllib +with open('${REGISTRY}', 'rb') as f: + d = tomllib.load(f) +for p in d['products']: + print(p['repo'].split('/')[-1]) +") + +is_in_registry() { + local name="$1" + for r in "${registry_repos[@]}"; do + [[ "${r}" == "${name}" ]] && return 0 + done + return 1 +} + +# Repos with a deliberately non-standard packaging shape that the +# single-PKGBUILD/single-package.yml heuristic below doesn't fit. Extend +# this if another repo grows a legitimately special-cased layout. +PACKAGE_CHECK_EXEMPT=("bos") # ships an ISO via release-iso.yml; its PKGBUILDs + # under packaging/*/ build bundled AUR deps + # (bibata, calamares, ...), each with its own + # dedicated workflow — not a pacman-channel package. + +is_package_check_exempt() { + local name="$1" + for r in "${PACKAGE_CHECK_EXEMPT[@]}"; do + [[ "${r}" == "${name}" ]] && return 0 + done + return 1 +} + +drift=0 +checked=0 + +for dir in "${BASE_DIR}"/*/; do + name="$(basename "${dir}")" + name="${name%-fix-worktree}" # normalize worktree checkouts back to the repo name + [[ -d "${dir}/.git" || -f "${dir}/.git" ]] || continue + # Skip bread-ecosystem itself — it's a multi-product repo the registry + # membership check above doesn't map 1:1, and it's already reviewed by + # hand above (bakery + bread-theme products). + [[ "${name}" == "bread-ecosystem" ]] && continue + + checked=$((checked + 1)) + has_bakery_toml=0 + [[ -f "${dir}/bakery.toml" ]] && has_bakery_toml=1 + + has_release_wf=0 + compgen -G "${dir}/.forgejo/workflows/release*.yml" >/dev/null 2>&1 && has_release_wf=1 + + in_registry=0 + is_in_registry "${name}" && in_registry=1 + + has_pkgbuild=0 + find "${dir}" -maxdepth 3 -iname 'PKGBUILD' -not -path '*/.git/*' 2>/dev/null \ + | grep -q . && has_pkgbuild=1 + + has_package_wf=0 + [[ -f "${dir}/.forgejo/workflows/package.yml" ]] && has_package_wf=1 + + issues=() + + if [[ "${has_bakery_toml}" == 1 && "${in_registry}" == 0 ]]; then + issues+=("has bakery.toml but no registry/bread-ecosystem.toml entry") + fi + if [[ "${in_registry}" == 1 && "${has_bakery_toml}" == 0 ]]; then + issues+=("registered in bread-ecosystem.toml but has no bakery.toml") + fi + if [[ "${in_registry}" == 1 && "${has_release_wf}" == 0 ]]; then + issues+=("registered + has bakery.toml but no release*.yml workflow") + fi + if [[ "${has_bakery_toml}" == 1 && "${in_registry}" == 0 && "${has_release_wf}" == 1 ]]; then + issues+=("has a release workflow for a product not in the registry (index.json will never include it)") + fi + if ! is_package_check_exempt "${name}"; then + if [[ "${has_pkgbuild}" == 1 && "${has_package_wf}" == 0 ]]; then + issues+=("has a PKGBUILD but no package.yml workflow") + fi + if [[ "${has_package_wf}" == 1 && "${has_pkgbuild}" == 0 ]]; then + issues+=("has package.yml but no PKGBUILD") + fi + fi + + if [[ ${#issues[@]} -gt 0 ]]; then + drift=1 + echo "${name}:" + for i in "${issues[@]}"; do + echo " - ${i}" + done + fi +done + +echo +echo "checked ${checked} repos under ${BASE_DIR}" +if [[ "${drift}" == 0 ]]; then + echo "no channel drift found" +else + echo "drift found — see docs/release-channels.md for the policy" +fi +exit "${drift}" From 98812af020c434a00240b9c37e9e5fca83bdb69c Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 17 Jul 2026 14:06:25 +0800 Subject: [PATCH 08/99] Register bos-settings as a bakery-channel product bos-settings has carried a bakery.toml since it was split out (per bos/DESIGN.md: "bos-settings gets a bakery.toml and is added to the bread-ecosystem registry"), but the registry-side half of that was never done, so gen-index.sh has never actually picked it up. Paired with the release.yml added in bos-settings-fix-worktree tonight. --- registry/bread-ecosystem.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/registry/bread-ecosystem.toml b/registry/bread-ecosystem.toml index 43abdc5..4620361 100644 --- a/registry/bread-ecosystem.toml +++ b/registry/bread-ecosystem.toml @@ -66,3 +66,8 @@ description = "Wayland clipboard history manager for Hyprland" name = "breadshot" repo = "Breadway/breadshot" description = "Screenshot utility for the bread ecosystem" + +[[products]] +name = "bos-settings" +repo = "Breadway/bos-settings" +description = "System settings app for Bread OS" From ab4e882baa6cd740da662468ac29d65164a102a8 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 17 Jul 2026 14:11:30 +0800 Subject: [PATCH 09/99] Add push-mirror provisioning + old mirror.yml cleanup scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the per-repo .forgejo/workflows/mirror.yml + MIRROR_TOKEN pattern with Forgejo's native Push Mirror feature, provisioned centrally instead of per-repo: - setup-push-mirrors.sh: reads the repo list live from the Forgejo API (GET /users/Breadway/repos — confirmed Breadway is a user account, not an org, so the /orgs/ endpoint 404s and this falls back correctly) instead of a hardcoded repo list, checks each repo's existing push_mirrors for idempotency, and POSTs a new one (sync_on_commit + 8h interval) for any repo missing one. Private repos are skipped by default (found novacana-engine on the live account) since mirroring one to a public GitHub repo is a disclosure decision this script should never make silently — pass --include-private to override per-run. --dry-run prints every request (GH token redacted) without POSTing. - cleanup-old-mirror-workflows.sh: deletes mirror.yml from each repo's default branch (live commit via the contents API, not a local change) and removes the MIRROR_TOKEN secret. Refuses to run at all — dry-run included — without an explicit --i-have-verified-push-mirrors-work flag, since it should only ever run after confirming the new push mirrors are actually syncing. Neither script has been run for real. setup-push-mirrors.sh has only been run with --dry-run against the live Forgejo API (read-only GETs); cleanup-old-mirror-workflows.sh has not been run at all beyond confirming its guardrail refuses to execute. --- scripts/cleanup-old-mirror-workflows.sh | 190 ++++++++++++++++++++++ scripts/setup-push-mirrors.sh | 201 ++++++++++++++++++++++++ 2 files changed, 391 insertions(+) create mode 100755 scripts/cleanup-old-mirror-workflows.sh create mode 100755 scripts/setup-push-mirrors.sh diff --git a/scripts/cleanup-old-mirror-workflows.sh b/scripts/cleanup-old-mirror-workflows.sh new file mode 100755 index 0000000..3a507a8 --- /dev/null +++ b/scripts/cleanup-old-mirror-workflows.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash +# cleanup-old-mirror-workflows.sh — retire the per-repo GitHub mirroring +# pattern now that Forgejo native Push Mirrors (see setup-push-mirrors.sh) +# do the same job centrally. +# +# THIS SCRIPT IS DESTRUCTIVE AND TOUCHES LIVE, RUNNING INFRASTRUCTURE: +# 1. Deletes .forgejo/workflows/mirror.yml from the DEFAULT BRANCH of every +# repo returned by the Forgejo API that has one (via the contents API — +# this is a real commit to each repo's default branch, not a local/ +# worktree change). +# 2. Deletes the MIRROR_TOKEN Actions secret from every repo that has one. +# +# Do not run this until you have confirmed, for real, that push mirrors +# created by setup-push-mirrors.sh are actually syncing to GitHub (check +# a repo's Settings > Push Mirrors in the Forgejo web UI, or GET +# /repos/{owner}/{repo}/push_mirrors and look at last_update / last_error, +# and confirm commits are actually landing on the GitHub side). Until then, +# removing mirror.yml would silently kill the only thing currently keeping +# GitHub in sync. +# +# As a guardrail, this script refuses to do anything unless invoked with +# --i-have-verified-push-mirrors-work. There is no way around that flag +# short of editing this script, which is the point. +# +# Requires: bash, curl, jq +# +# Reads the same token file as setup-push-mirrors.sh: +# FORGEJO_TOKEN_FILE default ~/.config/forgejo/token +# +# Env vars: +# FORGEJO_BASE https://git.breadway.dev +# FORGEJO_OWNER Breadway +# +# Flags: +# --i-have-verified-push-mirrors-work required, see above +# --dry-run print what would be deleted, make +# no changes (combine with the +# confirmation flag or this refuses +# to run at all — even dry-run mode +# is gated, so nobody can quietly +# drop the guardrail out of the +# invocation by force of habit) +# --only repo1,repo2 comma-separated allowlist +# +# Usage (once verified): +# scripts/cleanup-old-mirror-workflows.sh --i-have-verified-push-mirrors-work --dry-run +# scripts/cleanup-old-mirror-workflows.sh --i-have-verified-push-mirrors-work + +set -euo pipefail + +FORGEJO_BASE="${FORGEJO_BASE:-https://git.breadway.dev}" +FORGEJO_OWNER="${FORGEJO_OWNER:-Breadway}" +FORGEJO_TOKEN_FILE="${FORGEJO_TOKEN_FILE:-${HOME}/.config/forgejo/token}" + +CONFIRMED=0 +DRY_RUN=0 +ONLY_REPOS="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --i-have-verified-push-mirrors-work) CONFIRMED=1; shift ;; + --dry-run) DRY_RUN=1; shift ;; + --only) ONLY_REPOS="$2"; shift 2 ;; + -h|--help) + sed -n '2,42p' "$0" + exit 0 + ;; + *) + echo "error: unknown argument: $1" >&2 + exit 2 + ;; + esac +done + +if [[ "${CONFIRMED}" != 1 ]]; then + cat >&2 <<'EOF' +error: refusing to run. + +This script deletes mirror.yml from the default branch of every mirrored +repo and removes the MIRROR_TOKEN secret. That's a real, immediate change +to production CI on every one of those repos, and it also permanently +disables the *old* mirroring path. + +Before running this: + 1. Run setup-push-mirrors.sh for real (not --dry-run). + 2. Confirm, for at least one repo, that the push mirror actually synced + (Forgejo web UI: repo Settings > Push Mirrors > check "Last Update" + and that there's no "Last Error"; and check the GitHub side directly). + 3. Only then re-run this script with: + --i-have-verified-push-mirrors-work + +Add --dry-run (in addition to the flag above) to preview without changing +anything. +EOF + exit 1 +fi + +for bin in curl jq; do + command -v "${bin}" >/dev/null 2>&1 || { echo "error: ${bin} is required" >&2; exit 2; } +done + +[[ -f "${FORGEJO_TOKEN_FILE}" ]] || { echo "error: Forgejo token file not found at ${FORGEJO_TOKEN_FILE}" >&2; exit 2; } +FORGEJO_TOKEN="$(<"${FORGEJO_TOKEN_FILE}")" + +api() { + # api METHOD PATH [JSON_BODY] -> prints response body, exits nonzero on HTTP error + local method="$1" path="$2" body="${3:-}" + if [[ -n "${body}" ]]; then + curl -fsS -X "${method}" \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "${body}" \ + "${FORGEJO_BASE}/api/v1${path}" + else + curl -fsS -X "${method}" \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ + "${FORGEJO_BASE}/api/v1${path}" + fi +} + +owner_kind="org" +if ! curl -fsS -o /dev/null -H "Authorization: token ${FORGEJO_TOKEN}" \ + "${FORGEJO_BASE}/api/v1/orgs/${FORGEJO_OWNER}" 2>/dev/null; then + owner_kind="user" +fi + +if [[ "${owner_kind}" == "org" ]]; then + repos_json="$(api GET "/orgs/${FORGEJO_OWNER}/repos?limit=50")" +else + repos_json="$(api GET "/users/${FORGEJO_OWNER}/repos?limit=50")" +fi + +mapfile -t repo_names < <(echo "${repos_json}" | jq -r '.[].name') + +if [[ "${DRY_RUN}" == 1 ]]; then + echo "# --dry-run: no deletions will be made" +fi +echo + +for name in "${repo_names[@]}"; do + if [[ -n "${ONLY_REPOS}" ]]; then + IFS=',' read -ra allow <<< "${ONLY_REPOS}" + match=0 + for a in "${allow[@]}"; do [[ "${a}" == "${name}" ]] && match=1; done + [[ "${match}" == 1 ]] || continue + fi + + default_branch="$(echo "${repos_json}" | jq -r --arg n "${name}" '.[] | select(.name==$n) | .default_branch')" + + # Contents API: GET returns the file's sha, which the DELETE call needs. + file_info="$(curl -fsS -o /tmp/cleanup_probe.json -w '%{http_code}' \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ + "${FORGEJO_BASE}/api/v1/repos/${FORGEJO_OWNER}/${name}/contents/.forgejo/workflows/mirror.yml?ref=${default_branch}" || true)" + + if [[ "${file_info}" == "200" ]]; then + sha="$(jq -r '.sha' /tmp/cleanup_probe.json)" + if [[ "${DRY_RUN}" == 1 ]]; then + echo "WOULD-DELETE ${name}: .forgejo/workflows/mirror.yml (sha ${sha}) from ${default_branch}" + else + echo "DELETE ${name}: .forgejo/workflows/mirror.yml from ${default_branch}" + del_body="$(jq -n --arg msg "ci: remove mirror.yml, superseded by native push mirror" \ + --arg sha "${sha}" --arg branch "${default_branch}" \ + '{message: $msg, sha: $sha, branch: $branch}')" + api DELETE "/repos/${FORGEJO_OWNER}/${name}/contents/.forgejo/workflows/mirror.yml" "${del_body}" >/dev/null + fi + else + echo "SKIP ${name}: no .forgejo/workflows/mirror.yml on ${default_branch}" + fi + + secret_check="$(curl -fsS -o /dev/null -w '%{http_code}' \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ + "${FORGEJO_BASE}/api/v1/repos/${FORGEJO_OWNER}/${name}/actions/secrets" || true)" + has_mirror_token="$(curl -fsS -H "Authorization: token ${FORGEJO_TOKEN}" \ + "${FORGEJO_BASE}/api/v1/repos/${FORGEJO_OWNER}/${name}/actions/secrets" \ + | jq -r '[.[] | select(.name=="MIRROR_TOKEN")] | length')" + + if [[ "${has_mirror_token}" -gt 0 ]]; then + if [[ "${DRY_RUN}" == 1 ]]; then + echo "WOULD-DELETE ${name}: MIRROR_TOKEN secret" + else + echo "DELETE ${name}: MIRROR_TOKEN secret" + curl -fsS -X DELETE -H "Authorization: token ${FORGEJO_TOKEN}" \ + "${FORGEJO_BASE}/api/v1/repos/${FORGEJO_OWNER}/${name}/actions/secrets/MIRROR_TOKEN" >/dev/null + fi + else + echo "SKIP ${name}: no MIRROR_TOKEN secret" + fi +done + +rm -f /tmp/cleanup_probe.json diff --git a/scripts/setup-push-mirrors.sh b/scripts/setup-push-mirrors.sh new file mode 100755 index 0000000..6dd3081 --- /dev/null +++ b/scripts/setup-push-mirrors.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +# setup-push-mirrors.sh — provision Forgejo native Push Mirrors to GitHub for +# every repo under the Breadway account, replacing the old per-repo +# .forgejo/workflows/mirror.yml + MIRROR_TOKEN pattern. +# +# THIS SCRIPT MUTATES LIVE FORGEJO STATE WHEN RUN WITHOUT --dry-run. +# Always run with --dry-run first and review the output before running for +# real. Nothing in this script deletes anything — see the separate +# cleanup-old-mirror-workflows.sh for removing the old mirror.yml files, +# which should only be run after confirming push mirrors are syncing. +# +# What it does, per repo returned by the Forgejo API: +# 1. GET /repos/{owner}/{repo}/push_mirrors — list existing push mirrors +# 2. If one already targets https://github.com//.git, skip +# (idempotent — safe to re-run). +# 3. Otherwise POST /repos/{owner}/{repo}/push_mirrors to create one, with +# sync_on_commit=true and a periodic interval as belt-and-suspenders. +# +# Requires: bash, curl, jq +# +# Reads (never prints the contents of either): +# - A Forgejo API token from FORGEJO_TOKEN_FILE (default: +# ~/.config/forgejo/token). Needs at least write access to repository +# settings for every repo under the target account. +# - A GitHub PAT from a dotenv-style `GH_TOKEN=...` line in +# MIRROR_ENV_FILE (default: ~/.config/bread/mirror.env). The token needs +# `repo` scope (classic PAT) or Contents: Read & Write (fine-grained) on +# every target GitHub repo, since it's what actually pushes commits. +# +# Env vars (all optional, shown with defaults): +# FORGEJO_BASE https://git.breadway.dev +# FORGEJO_OWNER Breadway # Forgejo account that owns the repos +# GITHUB_OWNER same as FORGEJO_OWNER # GitHub account/org to mirror into +# FORGEJO_TOKEN_FILE ~/.config/forgejo/token +# MIRROR_ENV_FILE ~/.config/bread/mirror.env +# SYNC_INTERVAL 8h0m0s # Forgejo duration string; periodic resync +# # on top of sync_on_commit +# +# Flags: +# --dry-run Print every GET/POST this script would make +# (including full request bodies except the +# GitHub token, which is redacted) without +# actually issuing any POST. GETs (listing repos, +# listing existing push mirrors) always happen — +# they're read-only and needed to print accurate +# dry-run output. +# --include-private By default, private Forgejo repos are SKIPPED +# and reported, not mirrored — pushing a private +# repo's history to a public GitHub repo is a +# one-way disclosure decision this script should +# never make silently. Pass this flag to include +# them anyway, after you've confirmed the target +# GitHub repo is also private (this script does +# not create or check GitHub-side repos or their +# visibility). +# --only repo1,repo2 Comma-separated allowlist of repo names. +# Default: every repo the Forgejo API returns. +# +# Usage: +# scripts/setup-push-mirrors.sh --dry-run +# scripts/setup-push-mirrors.sh --dry-run --include-private +# scripts/setup-push-mirrors.sh # the real thing + +set -euo pipefail + +FORGEJO_BASE="${FORGEJO_BASE:-https://git.breadway.dev}" +FORGEJO_OWNER="${FORGEJO_OWNER:-Breadway}" +GITHUB_OWNER="${GITHUB_OWNER:-${FORGEJO_OWNER}}" +FORGEJO_TOKEN_FILE="${FORGEJO_TOKEN_FILE:-${HOME}/.config/forgejo/token}" +MIRROR_ENV_FILE="${MIRROR_ENV_FILE:-${HOME}/.config/bread/mirror.env}" +SYNC_INTERVAL="${SYNC_INTERVAL:-8h0m0s}" + +DRY_RUN=0 +INCLUDE_PRIVATE=0 +ONLY_REPOS="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) DRY_RUN=1; shift ;; + --include-private) INCLUDE_PRIVATE=1; shift ;; + --only) ONLY_REPOS="$2"; shift 2 ;; + -h|--help) + sed -n '2,55p' "$0" + exit 0 + ;; + *) + echo "error: unknown argument: $1" >&2 + exit 2 + ;; + esac +done + +for bin in curl jq; do + command -v "${bin}" >/dev/null 2>&1 || { echo "error: ${bin} is required" >&2; exit 2; } +done + +[[ -f "${FORGEJO_TOKEN_FILE}" ]] || { echo "error: Forgejo token file not found at ${FORGEJO_TOKEN_FILE}" >&2; exit 2; } +[[ -f "${MIRROR_ENV_FILE}" ]] || { echo "error: mirror env file not found at ${MIRROR_ENV_FILE}" >&2; exit 2; } + +FORGEJO_TOKEN="$(<"${FORGEJO_TOKEN_FILE}")" +GH_TOKEN="$(grep -m1 '^GH_TOKEN=' "${MIRROR_ENV_FILE}" | cut -d= -f2-)" +[[ -n "${GH_TOKEN}" ]] || { echo "error: no GH_TOKEN= line found in ${MIRROR_ENV_FILE}" >&2; exit 2; } + +api() { + # api METHOD PATH [JSON_BODY] + local method="$1" path="$2" body="${3:-}" + if [[ -n "${body}" ]]; then + curl -fsS -X "${method}" \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "${body}" \ + "${FORGEJO_BASE}/api/v1${path}" + else + curl -fsS -X "${method}" \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ + "${FORGEJO_BASE}/api/v1${path}" + fi +} + +# Determine whether FORGEJO_OWNER is an org or a user — orgs and users use +# different list-repos endpoints. +owner_kind="org" +if ! curl -fsS -o /dev/null -H "Authorization: token ${FORGEJO_TOKEN}" \ + "${FORGEJO_BASE}/api/v1/orgs/${FORGEJO_OWNER}" 2>/dev/null; then + owner_kind="user" +fi +echo "# ${FORGEJO_OWNER} is a Forgejo ${owner_kind} account" + +if [[ "${owner_kind}" == "org" ]]; then + repos_json="$(api GET "/orgs/${FORGEJO_OWNER}/repos?limit=50")" +else + repos_json="$(api GET "/users/${FORGEJO_OWNER}/repos?limit=50")" +fi + +mapfile -t repo_names < <(echo "${repos_json}" | jq -r '.[].name') +echo "# ${#repo_names[@]} repos found under ${FORGEJO_OWNER}" +echo + +if [[ "${DRY_RUN}" == 1 ]]; then + echo "# --dry-run: no POST requests will be made. GETs below are real, live reads." + echo +fi + +skipped_private=() +would_create=() +already_present=() + +for name in "${repo_names[@]}"; do + if [[ -n "${ONLY_REPOS}" ]]; then + IFS=',' read -ra allow <<< "${ONLY_REPOS}" + match=0 + for a in "${allow[@]}"; do [[ "${a}" == "${name}" ]] && match=1; done + [[ "${match}" == 1 ]] || continue + fi + + is_private="$(echo "${repos_json}" | jq -r --arg n "${name}" '.[] | select(.name==$n) | .private')" + if [[ "${is_private}" == "true" && "${INCLUDE_PRIVATE}" == 0 ]]; then + skipped_private+=("${name}") + echo "SKIP ${name}: private repo, pass --include-private to mirror it anyway" + continue + fi + + target_url="https://github.com/${GITHUB_OWNER}/${name}.git" + + existing="$(api GET "/repos/${FORGEJO_OWNER}/${name}/push_mirrors")" + already="$(echo "${existing}" | jq -r --arg u "${target_url}" '[.[] | select(.remote_address==$u)] | length')" + + if [[ "${already}" -gt 0 ]]; then + already_present+=("${name}") + echo "OK ${name}: push mirror to ${target_url} already exists, skipping" + continue + fi + + would_create+=("${name}") + body="$(jq -n \ + --arg addr "${target_url}" \ + --arg user "x-access-token" \ + --arg pass "${GH_TOKEN}" \ + --arg interval "${SYNC_INTERVAL}" \ + '{remote_address: $addr, remote_username: $user, remote_password: $pass, + sync_on_commit: true, interval: $interval, use_ssh: false}')" + + if [[ "${DRY_RUN}" == 1 ]]; then + redacted="$(echo "${body}" | jq '.remote_password = "***REDACTED***"')" + echo "WOULD-POST ${name}: /repos/${FORGEJO_OWNER}/${name}/push_mirrors" + echo "${redacted}" | sed 's/^/ /' + else + echo "CREATE ${name}: push mirror -> ${target_url}" + api POST "/repos/${FORGEJO_OWNER}/${name}/push_mirrors" "${body}" >/dev/null + fi +done + +echo +echo "# summary" +echo "# already had a matching push mirror: ${#already_present[@]}" +echo "# private, skipped (--include-private to override): ${#skipped_private[@]}" +if [[ "${DRY_RUN}" == 1 ]]; then + echo "# would create: ${#would_create[@]}" +else + echo "# created: ${#would_create[@]}" +fi From 8e82d2d833e992ce939a5b836f910ee109f2e939 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 19 Jul 2026 03:07:40 +0800 Subject: [PATCH 10/99] Add push-mirror provisioning, bread_client, README/identity fixes; bump to 0.3.0 --- Cargo.lock | 424 ++++++++++---------------------- Cargo.toml | 4 +- LICENSE | 21 ++ README.md | 39 +-- bakery/Cargo.toml | 2 +- bread-onnx/Cargo.toml | 2 +- bread-theme/Cargo.toml | 2 +- bread-utils/Cargo.toml | 8 +- bread-utils/src/bread_client.rs | 302 +++++++++++++++++++++++ bread-utils/src/lib.rs | 6 + packaging/arch/PKGBUILD | 4 +- 11 files changed, 490 insertions(+), 324 deletions(-) create mode 100644 LICENSE create mode 100644 bread-utils/src/bread_client.rs diff --git a/Cargo.lock b/Cargo.lock index 678ab4d..92d225b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -92,9 +92,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "autocfg" @@ -104,7 +104,7 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "bakery" -version = "0.2.3" +version = "0.3.0" dependencies = [ "anyhow", "chrono", @@ -134,9 +134,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "block-buffer" @@ -149,7 +149,7 @@ dependencies = [ [[package]] name = "bread-onnx" -version = "0.2.3" +version = "0.3.0" dependencies = [ "anyhow", "bread-utils", @@ -162,9 +162,20 @@ dependencies = [ "ureq", ] +[[package]] +name = "bread-shared" +version = "0.7.0" +source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0#22e34e2cf2202305d7960759dfccb54dc79f948b" +dependencies = [ + "dirs", + "serde", + "serde_json", + "toml 0.8.23", +] + [[package]] name = "bread-theme" -version = "0.2.3" +version = "0.3.0" dependencies = [ "dirs", "gtk4", @@ -174,8 +185,9 @@ dependencies = [ [[package]] name = "bread-utils" -version = "0.2.3" +version = "0.3.0" dependencies = [ + "bread-shared", "dirs", "gtk4", "gtk4-layer-shell", @@ -225,9 +237,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.63" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" dependencies = [ "find-msvc-tools", "shlex", @@ -264,9 +276,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" dependencies = [ "clap_builder", "clap_derive", @@ -274,9 +286,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" dependencies = [ "anstream", "anstyle", @@ -592,12 +604,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -609,24 +615,24 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" dependencies = [ "futures-core", "futures-task", @@ -635,15 +641,15 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", @@ -652,15 +658,15 @@ dependencies = [ [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-core", "futures-macro", @@ -696,9 +702,9 @@ dependencies = [ [[package]] name = "gdk4" -version = "0.11.2" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd42fdbbf48612c6e8f47c65fb92d2e8f39c25aecd6af047e83897c1a22d2a4e" +checksum = "d81e2a6c6ecba2aab60633a98df1868b03fa0bfdce8105edc27c1bccf71f0e39" dependencies = [ "cairo-rs", "gdk-pixbuf", @@ -712,9 +718,9 @@ dependencies = [ [[package]] name = "gdk4-sys" -version = "0.11.2" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d974ac4f15e67472c3a9728daf612590b4a5762a4b33f0edd298df0b80d043c" +checksum = "3d8f608d8d7d229975c4d0d026f5d3071598c4ddab3c5262b0a31840fec78d13" dependencies = [ "cairo-sys-rs", "gdk-pixbuf-sys", @@ -762,22 +768,20 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "wasip2", - "wasip3", ] [[package]] name = "gio" -version = "0.22.6" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3848bcba3a35cc0a71df8ba8ecfd799d6bfb862342a53a4a915fb62213aa4e6" +checksum = "8b3e1f669909c326b9413bde5a742097b8c90a7d78f45326db13668984769ded" dependencies = [ "futures-channel", "futures-core", @@ -792,9 +796,9 @@ dependencies = [ [[package]] name = "gio-sys" -version = "0.22.0" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64729ba2772c080448f9f966dba8f4456beeb100d8c28a865ef8a0f2ef4987e1" +checksum = "353fdc7da7cd16da916104b1e0e4e7de380ec9c8aaa20d4d742d66310ab4b0d5" dependencies = [ "glib-sys", "gobject-sys", @@ -825,9 +829,9 @@ dependencies = [ [[package]] name = "glib" -version = "0.22.7" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c207e04e51605dcf7b2924c41591b3a10e1438eaac5bcf448fb91f325381104a" +checksum = "ddbcf514bd1881fc1b960e4e52b4e82873f4da3bceddbd58d42827b508888100" dependencies = [ "bitflags", "futures-channel", @@ -858,9 +862,9 @@ dependencies = [ [[package]] name = "glib-sys" -version = "0.22.6" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f7fbac234ed5bc2a28359b7bde8e1b9cdf1441cc2d7f068e4824672d7db9445" +checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233" dependencies = [ "libc", "system-deps", @@ -879,32 +883,30 @@ dependencies = [ [[package]] name = "graphene-rs" -version = "0.22.0" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7d1b7881f96869f49808b6adfe906a93a57a34204952253444d68c3208d71f1" +checksum = "eb856b9c558971c3f13ab692358926da710b046932a4e087aedcc35b040d7dff" dependencies = [ "glib", "graphene-sys", - "libc", ] [[package]] name = "graphene-sys" -version = "0.22.0" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "517f062f3fd6b7fd3e57a3f038a74b3c23ca32f51199ff028aa704609943f79c" +checksum = "5c7ffdfde88f3570d3705e0d8a2433e036d387a1f2930bbf47eafcb5f569fd04" dependencies = [ "glib-sys", "libc", - "pkg-config", "system-deps", ] [[package]] name = "gsk4" -version = "0.11.1" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c912dfcbd28acace5fc99c40bb9f25e1dcb73efb1f2608327f66a99acdcb62" +checksum = "b867be1c5f14dcb8f552c0eff6e9a9b1da5f8b43943e8efc3a63c889d84952ff" dependencies = [ "cairo-rs", "gdk4", @@ -917,9 +919,9 @@ dependencies = [ [[package]] name = "gsk4-sys" -version = "0.11.1" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7d54bbc7a9d8b6ffe4f0c95eede15ccfb365c8bf521275abe6bcfb57b18fb8a" +checksum = "5b7c7eb2e681ee896646cfb8872b431f24d09f53ba9283289d9b10caa6707088" dependencies = [ "cairo-sys-rs", "gdk4-sys", @@ -933,9 +935,9 @@ dependencies = [ [[package]] name = "gtk4" -version = "0.11.3" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7181b837f04cbe93f79441475f7a00560a92cba7a72e38cc1a68b6f8b78eaae2" +checksum = "98a0a0466484f64b07b5b8184d43fa46be78eb0b8e04ae4e179af31d770b76d9" dependencies = [ "cairo-rs", "field-offset", @@ -982,9 +984,9 @@ dependencies = [ [[package]] name = "gtk4-macros" -version = "0.11.0" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3581b242ba62fdff122ebb626ea641582ec326031622bd19d60f85029c804a87" +checksum = "5ac7179400a36a04de039c24206bb841c5596992b907b43b23ee8d5bdc40d00e" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -994,9 +996,9 @@ dependencies = [ [[package]] name = "gtk4-sys" -version = "0.11.3" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20ba8e695e2640455561274e65e45f0a151619e450746007667f4b23ceae4e1b" +checksum = "82b8f954786af0b1984425c4446b77f5ff6594346181316be3f850caab1c6f01" dependencies = [ "cairo-sys-rs", "gdk-pixbuf-sys", @@ -1011,15 +1013,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - [[package]] name = "hashbrown" version = "0.17.1" @@ -1144,12 +1137,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -1184,9 +1171,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", + "hashbrown", ] [[package]] @@ -1225,13 +1210,12 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -1241,12 +1225,6 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" version = "0.2.186" @@ -1255,9 +1233,9 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ "libc", ] @@ -1276,9 +1254,9 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "log" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "macro_rules_attribute" @@ -1308,9 +1286,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memoffset" @@ -1477,13 +1455,12 @@ checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90" [[package]] name = "pango" -version = "0.22.6" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "251bdc6e6487b811be0e406a21e301e07e45c0aa8fa39e00c0c8e12a91752438" +checksum = "5d800d8d0de2ad5d0fb046f5344dbaba14a003cf3dd27cc21d85893d35ea316c" dependencies = [ "gio", "glib", - "libc", "pango-sys", ] @@ -1525,9 +1502,9 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" [[package]] name = "portable-atomic-util" @@ -1556,23 +1533,13 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - [[package]] name = "proc-macro-crate" version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.12+spec-1.1.0", + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] @@ -1586,9 +1553,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -1749,9 +1716,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ "log", "once_cell", @@ -1764,9 +1731,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "zeroize", ] @@ -1784,9 +1751,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -1880,9 +1847,9 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "slab" @@ -1892,9 +1859,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "spm_precompiled" @@ -1934,9 +1901,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -1963,7 +1930,7 @@ dependencies = [ "cfg-expr", "heck", "pkg-config", - "toml 1.1.2+spec-1.1.0", + "toml 1.1.3+spec-1.1.0", "version-compare", ] @@ -1980,7 +1947,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -2084,9 +2051,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" dependencies = [ "indexmap", "serde_core", @@ -2094,7 +2061,7 @@ dependencies = [ "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -2131,14 +2098,14 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -2147,7 +2114,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -2158,9 +2125,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tracing" @@ -2226,12 +2193,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "unicode_categories" version = "0.1.1" @@ -2312,27 +2273,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -2343,9 +2295,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2353,9 +2305,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", @@ -2366,47 +2318,13 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "web-time" version = "1.1.0" @@ -2423,14 +2341,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.7", + "webpki-roots 1.0.9", ] [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] @@ -2653,107 +2571,19 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "writeable" version = "0.6.3" @@ -2832,9 +2662,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" @@ -2871,6 +2701,6 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index b51552a..6675555 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,10 +3,10 @@ members = ["bakery", "bread-theme", "bread-utils", "bread-onnx"] resolver = "2" [workspace.package] -version = "0.2.3" +version = "0.3.0" edition = "2021" license = "MIT" -authors = ["Breadway "] +authors = ["Breadway "] [workspace.dependencies] anyhow = "1" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..373e4ee --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Breadway + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index c01995a..fff6340 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ bakery install breadbar | `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`) | +| `breadpaper` | Wallpaper manager for the bread desktop | ## Recommended keybinds @@ -35,19 +36,31 @@ to keys. ## Theming -All GUIs share one look via `bread-theme`. The `bread-theme` CLI renders the -component stylesheet from your pywal palette, layered on a fixed BOS dark -base (background/surface/overlay never come from pywal — only the accent -colors do, so a light wallpaper can't wash out the UI), to -`$XDG_RUNTIME_DIR/bread/theme.css`; every app loads that file and **live-reloads** -it, so changing your wallpaper recolours the whole ecosystem with no rebuilds: +All GUI products (breadbar, breadbox, breadpad) share one stylesheet via +`bread-theme`. Background, surface, overlay, and foreground are always BOS's +fixed dark values; only the accent colors are read from the pywal palette in +`~/.cache/wal/colors.json`. When that file is absent, the accents fall back +to BOS's curated bread-toned defaults (not Catppuccin Mocha). The stylesheet +is written to `$XDG_RUNTIME_DIR/bread/theme.css`; running apps watch that +file and recolour live when it changes. Per-app CSS overrides live at +`~/.config//style.css`. ```sh wal -i ~/Pictures/wall.png # regenerate pywal palette bread-theme generate # render the shared stylesheet (run from a wal hook) ``` -See [`BREAD_DESIGN_SYSTEM.md`](BREAD_DESIGN_SYSTEM.md) for the tokens (fonts, +`bread-theme` subcommands: + +| Subcommand | Description | +|------------|-------------| +| `generate` | Render the current palette and write the shared stylesheet (default) | +| `reload` | Same as `generate`; use after a palette change to trigger live recolour in running apps | +| `path` | Print the stylesheet path | +| `print` | Render the stylesheet to stdout without writing | + +The shared theming logic lives in the `bread-theme` crate in this repo. See +[`BREAD_DESIGN_SYSTEM.md`](BREAD_DESIGN_SYSTEM.md) for the design tokens (fonts, spacing, radii, colour roles) the stylesheet is built from. ## Installing bakery @@ -94,18 +107,6 @@ bakery remove # remove a package (data files are never deleted) Install all required deps with `sudo pacman -S `. Use `pacman -Q ` to check whether any are already present. -## Theming - -All GUI products (breadbar, breadbox, breadpad) read pywal colors from -`~/.cache/wal/colors.json` for accents only; background, surface, overlay, -and foreground are always BOS's fixed dark values (see -[`BREAD_DESIGN_SYSTEM.md`](BREAD_DESIGN_SYSTEM.md#color-system)) regardless -of what pywal extracted from the wallpaper. When `colors.json` is absent, -accents fall back to BOS's curated bread-toned defaults. Per-app CSS -overrides live at `~/.config//style.css`. - -The shared theming logic lives in the `bread-theme` crate in this repo. - ## Workspace This repo is a Cargo workspace: diff --git a/bakery/Cargo.toml b/bakery/Cargo.toml index 3664980..a344e87 100644 --- a/bakery/Cargo.toml +++ b/bakery/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true license.workspace = true authors.workspace = true description = "Package manager for the bread ecosystem" -repository = "https://github.com/Breadway/bread-ecosystem" +repository = "https://git.breadway.dev/Breadway/bread-ecosystem" [dependencies] anyhow = { workspace = true } diff --git a/bread-onnx/Cargo.toml b/bread-onnx/Cargo.toml index d8cb08a..170bdfc 100644 --- a/bread-onnx/Cargo.toml +++ b/bread-onnx/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true license.workspace = true authors.workspace = true description = "Shared ONNX Runtime plumbing for the bread ecosystem: session building, execution-provider fallback with loud diagnostics, embedding-pipeline tensor math, and verified model downloads" -repository = "https://github.com/Breadway/bread-ecosystem" +repository = "https://git.breadway.dev/Breadway/bread-ecosystem" keywords = ["onnx", "onnxruntime", "ml", "embeddings"] [dependencies] diff --git a/bread-theme/Cargo.toml b/bread-theme/Cargo.toml index 8547e24..43c3952 100644 --- a/bread-theme/Cargo.toml +++ b/bread-theme/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true license.workspace = true authors.workspace = true description = "Shared pywal-accented, fixed-dark-base theming crate for the bread ecosystem" -repository = "https://github.com/Breadway/bread-ecosystem" +repository = "https://git.breadway.dev/Breadway/bread-ecosystem" keywords = ["theming", "pywal", "gtk4", "wayland"] [dependencies] diff --git a/bread-utils/Cargo.toml b/bread-utils/Cargo.toml index 851c3e2..69e2172 100644 --- a/bread-utils/Cargo.toml +++ b/bread-utils/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true license.workspace = true authors.workspace = true description = "Shared plumbing for the bread ecosystem: Hyprland IPC, single-instance toggling, timeout-guarded subprocess execution, atomic file writes, XDG paths, and a GTK4 layer-shell popup scaffold" -repository = "https://github.com/Breadway/bread-ecosystem" +repository = "https://git.breadway.dev/Breadway/bread-ecosystem" keywords = ["hyprland", "wayland", "xdg", "gtk4"] [dependencies] @@ -15,6 +15,7 @@ dirs = { workspace = true } gtk4 = { version = "0.11", features = ["v4_12"], optional = true } gtk4-layer-shell = { version = "0.8", optional = true } toml_edit = { version = "0.22", optional = true } +bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.7.0", optional = true } [features] # Enable the layer-shell popup scaffold (breadbox, breadclip). Kept optional @@ -25,6 +26,11 @@ gtk = ["dep:gtk4", "dep:gtk4-layer-shell"] # breadhelp). Optional so consumers that don't edit TOML configs (breadbox, # breadclip, breadmon, ...) don't pull in toml_edit. toml = ["dep:toml_edit"] +# Enable BreadClient, a persistent-connection client for breadd's IPC +# socket (emit + subscribe), for sibling bread* app daemons that want to +# integrate with the bread automation fabric. Optional so consumers that +# don't talk to breadd at all aren't forced to pull in bread-shared. +bread-client = ["dep:bread-shared"] [dev-dependencies] tempfile = "3" diff --git a/bread-utils/src/bread_client.rs b/bread-utils/src/bread_client.rs new file mode 100644 index 0000000..3f4adcf --- /dev/null +++ b/bread-utils/src/bread_client.rs @@ -0,0 +1,302 @@ +//! A persistent-connection client for breadd's IPC socket, for sibling +//! `bread*` app daemons that run continuously. +//! +//! This is deliberately a *second* client alongside `bread-emit` (the +//! fire-and-forget CLI binary in the `bread` repo), not a replacement for +//! it: `bread-emit` skips holding a connection open at all, which is right +//! for occasional/hook-style callers (a git hook, a shell prompt) but wrong +//! for a long-running daemon like breadclipd that wants to publish an +//! event on every clipboard change and subscribe to a command stream — +//! reconnecting from scratch for every single emit would be wasteful, and +//! subscribing needs a held-open connection by nature. +//! +//! # Graceful degradation +//! +//! 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::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 +//! resumes automatically once breadd comes back. +//! +//! # Namespace enforcement +//! +//! `emit` refuses locally (no network round trip) to publish an event +//! 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. + +use std::io::{BufRead, BufReader, Write}; +use std::net::Shutdown; +use std::os::unix::net::UnixStream; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +use bread_shared::apps::validate_app_namespace; +use serde_json::{json, Value}; + +/// A normalized event as delivered by breadd's `events.subscribe` stream. +#[derive(Debug, Clone)] +pub struct BreadEvent { + /// Dotted event name, e.g. `bread.command.clip.clear`. + pub event: String, + /// Unix epoch milliseconds when the daemon observed the originating signal. + pub timestamp: u64, + /// Structured event data; shape depends on the event family. + pub data: Value, +} + +/// A client bound to one sibling app's identity, used to `emit` within that +/// app's namespace and `subscribe` to events (typically its own +/// `bread.command..**` verb namespace). +/// +/// Cheap to clone (just an `Arc`-free `String`); safe to share across +/// threads by cloning, or to construct fresh per call site. +#[derive(Clone)] +pub struct BreadClient { + app_id: String, +} + +impl BreadClient { + /// Bind a client to `app_id` (e.g. `"clip"`). Does not connect yet — + /// there is no persistent connection to "fail" at construction time; + /// `emit` and `subscribe` each connect (or reconnect) as needed. This + /// is itself part of the graceful-degradation story: constructing a + /// `BreadClient` can never fail just because breadd isn't running yet. + pub fn connect(app_id: impl Into) -> Self { + Self { + app_id: app_id.into(), + } + } + + /// The app id this client is bound to. + pub fn app_id(&self) -> &str { + &self.app_id + } + + /// Publish `event` (must be within `bread..*`) with `data`. + /// Fire-and-forget: a single short-lived connection is opened, the + /// request is written, and the reply is never read (mirroring + /// `bread-emit`). If breadd is unreachable or slow, this silently does + /// nothing — it never blocks or errors the caller. + pub fn emit(&self, event: &str, data: Value) { + if !validate_app_namespace(&self.app_id, event) { + eprintln!( + "bread-client: refusing to emit '{event}' outside the '{}' namespace", + self.app_id + ); + 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; + }; + + 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}"); + } + + /// Subscribe to events matching `pattern` (glob: `*`/`**`/`?`), invoking + /// `on_event` for each one on a dedicated background thread. Typically + /// called with `"bread.command..**"` to receive commands + /// addressed to this app. + /// + /// Returns a [`Subscription`] handle; drop or call [`Subscription::stop`] + /// to end it. The background thread reconnects with exponential backoff + /// (500ms, capped at ~32s) whenever the connection drops, so a restart + /// of breadd is transparent to the caller — `on_event` simply pauses + /// and resumes. + pub fn subscribe(&self, pattern: impl Into, on_event: F) -> Subscription + where + F: Fn(BreadEvent) + Send + 'static, + { + let pattern = pattern.into(); + let stop = Arc::new(AtomicBool::new(false)); + let current_stream: Arc>> = Arc::new(Mutex::new(None)); + + let stop_for_thread = stop.clone(); + let stream_for_thread = current_stream.clone(); + let handle = thread::spawn(move || { + let mut attempt: u32 = 0; + while !stop_for_thread.load(Ordering::Relaxed) { + match run_subscription_once(&pattern, &on_event, &stream_for_thread) { + Ok(()) => attempt = 0, // clean end (stop() closed the socket) + Err(_) => attempt = attempt.saturating_add(1), + } + + *stream_for_thread.lock().unwrap_or_else(|p| p.into_inner()) = None; + + if stop_for_thread.load(Ordering::Relaxed) { + break; + } + let backoff_ms = 500u64.saturating_mul(2u64.saturating_pow(attempt.min(6))); + thread::sleep(Duration::from_millis(backoff_ms)); + } + }); + + Subscription { + stop, + current_stream, + handle: Some(handle), + } + } +} + +/// 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 +/// shut it down from another thread to interrupt the blocking read promptly. +fn run_subscription_once( + pattern: &str, + on_event: &impl Fn(BreadEvent), + current_stream: &Mutex>, +) -> std::io::Result<()> { + let stream = UnixStream::connect(bread_shared::resolve_socket_path())?; + let read_stream = stream.try_clone()?; + *current_stream.lock().unwrap_or_else(|p| p.into_inner()) = Some(stream); + + // Re-borrow to write the subscribe request through the stored copy so + // there is exactly one owner performing I/O per direction. + { + let guard = current_stream.lock().unwrap_or_else(|p| p.into_inner()); + if let Some(stream) = guard.as_ref() { + let mut writer = stream; + let request = json!({ + "id": "sub", + "method": "events.subscribe", + "params": { "filter": pattern } + }); + let line = serde_json::to_string(&request).unwrap_or_default(); + writeln!(writer, "{line}")?; + } + } + + for line in BufReader::new(read_stream).lines() { + let line = line?; + if line.trim().is_empty() { + continue; + } + let Ok(value) = serde_json::from_str::(&line) else { + continue; + }; + // The first line is the subscribe ack ({"result": {"subscribed": true}}); + // only lines with an "event" field are actual BreadEvents. + if let Some(event_name) = value.get("event").and_then(Value::as_str) { + let timestamp = value.get("timestamp").and_then(Value::as_u64).unwrap_or(0); + let data = value.get("data").cloned().unwrap_or(Value::Null); + on_event(BreadEvent { + event: event_name.to_string(), + timestamp, + data, + }); + } + } + Ok(()) +} + +/// Handle to a running [`BreadClient::subscribe`] background thread. +pub struct Subscription { + stop: Arc, + current_stream: Arc>>, + handle: Option>, +} + +impl Subscription { + /// Stop the subscription and block until its background thread exits. + /// Shuts down the live socket (if connected) so a thread blocked in a + /// read wakes up immediately, rather than waiting for the next event or + /// a future reconnect attempt to notice the stop flag. + pub fn stop(mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(stream) = self + .current_stream + .lock() + .unwrap_or_else(|p| p.into_inner()) + .as_ref() + { + let _ = stream.shutdown(Shutdown::Both); + } + if let Some(h) = self.handle.take() { + let _ = h.join(); + } + } +} + +impl Drop for Subscription { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(stream) = self + .current_stream + .lock() + .unwrap_or_else(|p| p.into_inner()) + .as_ref() + { + let _ = stream.shutdown(Shutdown::Both); + } + // Best-effort on drop: don't block a caller who simply let the + // handle go out of scope. Explicit `stop()` is what actually waits. + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn connect_never_fails_even_with_no_daemon_present() { + // Constructing a client must not depend on breadd actually running — + // that's the whole point of the graceful-degradation design. + let _client = BreadClient::connect("clip"); + } + + #[test] + fn emit_is_a_silent_no_op_when_daemon_is_unreachable() { + // Point at a socket path that can't possibly exist by using an + // app id that still passes namespace validation; the daemon being + // absent must not panic or block this call. + let client = BreadClient::connect("clip"); + client.emit("bread.clip.copied", json!({ "len": 1 })); + } + + #[test] + fn emit_refuses_event_outside_own_namespace_without_connecting() { + // "pad" events are not this client's to publish — this must be + // caught locally (and cheaply) rather than round-tripped to a + // daemon that isn't even running in this test. + let client = BreadClient::connect("clip"); + client.emit("bread.pad.reminder.due", json!({})); + // No assertion beyond "did not panic" — there is no daemon to + // observe the (correctly suppressed) call against in a unit test; + // the cross-process behavior is covered by breadd's own + // integration tests for the IPC-side of namespace validation. + } + + #[test] + fn subscription_stop_joins_the_background_thread() { + let client = BreadClient::connect("clip"); + let sub = client.subscribe("bread.command.clip.**", |_event| {}); + // Even with no daemon present (so the thread is spinning on + // connect-refused + backoff), stop() must return promptly rather + // than hanging. + sub.stop(); + } +} diff --git a/bread-utils/src/lib.rs b/bread-utils/src/lib.rs index 12f6bf5..0817ad3 100644 --- a/bread-utils/src/lib.rs +++ b/bread-utils/src/lib.rs @@ -18,6 +18,9 @@ //! load/save discipline built on [`atomic`]. //! - [`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. pub mod atomic; pub mod hypr; @@ -42,3 +45,6 @@ pub mod tomlcfg; #[cfg(feature = "gtk")] pub mod gtk_popup; + +#[cfg(feature = "bread-client")] +pub mod bread_client; diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 793c187..6a3f8bf 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -1,11 +1,11 @@ -# Maintainer: Breadway +# Maintainer: Breadway pkgname=bakery pkgver=0.2.3 pkgrel=1 pkgdesc="Package manager for the bread ecosystem" arch=('x86_64') -url="https://github.com/Breadway/bread-ecosystem" +url="https://git.breadway.dev/Breadway/bread-ecosystem" license=('MIT') # Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's # default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read, From 157ed6e3782ac2facd8b517d7c247713f36854ed Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 21 Jul 2026 19:07:49 +0800 Subject: [PATCH 11/99] bakery: rotate signing key, fix broken index-signature verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old bakery-signing-key.minisign-sec on hestia was password-encrypted and the password was lost, so scripts/gen-index.sh never actually signed index.json (silent no-op warning). bakery/src/manifest.rs (0.3.0+) hard- requires that signature, so every bakery command has been failing with 'fetching index.json.minisig — the index must be signed before it can be trusted' since the signing enforcement shipped. Generated a new no-password minisign keypair on hestia (~/.secrets/bakery-signing-key-2.minisign-sec), updated the hardcoded PUBKEY in manifest.rs and get.sh to match, wired BAKERY_MINISIGN_SEC_KEY_PATH as a Forgejo Actions secret so future CI releases sign automatically, and manually signed+published the current index.json on hestia so bakery works immediately. --- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- bakery/src/manifest.rs | 2 +- scripts/get.sh | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 92d225b..51ffd62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -104,7 +104,7 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "bakery" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "chrono", @@ -149,7 +149,7 @@ dependencies = [ [[package]] name = "bread-onnx" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "bread-utils", @@ -175,7 +175,7 @@ dependencies = [ [[package]] name = "bread-theme" -version = "0.3.0" +version = "0.3.1" dependencies = [ "dirs", "gtk4", @@ -185,7 +185,7 @@ dependencies = [ [[package]] name = "bread-utils" -version = "0.3.0" +version = "0.3.1" dependencies = [ "bread-shared", "dirs", diff --git a/Cargo.toml b/Cargo.toml index 6675555..23f96e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["bakery", "bread-theme", "bread-utils", "bread-onnx"] resolver = "2" [workspace.package] -version = "0.3.0" +version = "0.3.1" edition = "2021" license = "MIT" authors = ["Breadway "] diff --git a/bakery/src/manifest.rs b/bakery/src/manifest.rs index 2a8e7ac..35a9bbc 100644 --- a/bakery/src/manifest.rs +++ b/bakery/src/manifest.rs @@ -17,7 +17,7 @@ const CACHE_MAX_AGE: Duration = Duration::from_secs(24 * 3600); /// bytes are trusted or parsed. This is the single control point: the /// per-artifact `sha256` fields and `post_install` hook strings all live /// inside `index.json` itself, so a valid signature transitively covers them. -const PUBKEY: &str = "RWRh2Zr5SUinvVFCtD7S7HwGjfrye6j31Xq2mYXRdkGFDWe3yHF7W11K"; +const PUBKEY: &str = "RWTBR8w/IJ+jaylOv80b52DzekKbSR2CvOVGvzB0ipGBaMhJPAOiEWq8"; /// Verify `bytes` against `sig_text` (the contents of an `index.json.minisig` /// file) using the pinned [`PUBKEY`]. Returns an error on any failure — diff --git a/scripts/get.sh b/scripts/get.sh index a2df2eb..6a1707e 100755 --- a/scripts/get.sh +++ b/scripts/get.sh @@ -9,7 +9,7 @@ set -eu # index.json and the bakery binary itself). Do not source this from the # network — it must be baked into this script so a compromised dl server # can't swap it out along with a malicious binary. -BAKERY_MINISIGN_PUBKEY="RWRh2Zr5SUinvVFCtD7S7HwGjfrye6j31Xq2mYXRdkGFDWe3yHF7W11K" +BAKERY_MINISIGN_PUBKEY="RWTBR8w/IJ+jaylOv80b52DzekKbSR2CvOVGvzB0ipGBaMhJPAOiEWq8" BAKERY_VERSION="${BAKERY_VERSION:-latest}" BIN_DIR="${BAKERY_BIN_DIR:-$HOME/.local/bin}" From 0b272838dfd5cd64ec85436202cec4d3e0554b59 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 21 Jul 2026 19:11:15 +0800 Subject: [PATCH 12/99] gen-index.sh: exclude .minisig sidecar files from the binaries list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed release binaries ship a bakery-x86_64.minisig alongside the binary. The binaries-collection loop already filtered out .sha256/ .toml/.service/.css/.txt sidecars but not .minisig, so the signature file itself got listed as an installable binary with no sha256 — bakery then refused to install it (checksum mismatch) whenever it tried to update a package with a signed binary. --- scripts/gen-index.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/gen-index.sh b/scripts/gen-index.sh index 9991adb..2b7a4d8 100755 --- a/scripts/gen-index.sh +++ b/scripts/gen-index.sh @@ -56,6 +56,7 @@ build_package_json() { [[ "${bin_path}" == *.service ]] && continue [[ "${bin_path}" == *.css ]] && continue [[ "${bin_path}" == *.txt ]] && continue + [[ "${bin_path}" == *.minisig ]] && continue [[ -f "${bin_path}" ]] || continue local bin_name bin_name="$(basename "${bin_path}")" From db2fa3c4b4c1e6933bc5cf62a236d05972fdc886 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 21 Jul 2026 19:19:56 +0800 Subject: [PATCH 13/99] ci: remove GitHub push-mirror workflow --- .forgejo/workflows/mirror.yml | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 .forgejo/workflows/mirror.yml diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml deleted file mode 100644 index 2128050..0000000 --- a/.forgejo/workflows/mirror.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Mirror to GitHub - -on: - push: - branches: ['**'] - tags: ['**'] - -jobs: - mirror: - runs-on: [self-hosted, hestia] - steps: - - name: Mirror to GitHub - run: | - set -euo pipefail - git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git - cd repo.git - # Mirror only branches and tags (not refs/pull/*, which GitHub rejects); - # --prune deletes GitHub refs that no longer exist on Forgejo. - git push --prune \ - "https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/bread-ecosystem.git" \ - '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' From 4ac54c610d6db7c5e5a8dd31542022a8f83b5209 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 09:19:31 +0800 Subject: [PATCH 14/99] bakery: add stable/beta/dev build tracks Adds a track concept to bakery (separate from the existing bakery/pacman distribution channel): stable (unchanged tag-triggered releases), beta (deliberate beta-v* tag promotion), and dev (published on every push to dev). Each track gets its own signed index + artifact tree under dl.breadway.dev so stable's paths and existing installs are untouched. - bakery: new Track type, a global track preference in installed.json (defaults to stable via serde, no migration needed), `bakery track show`/`set`, a BAKERY_INDEX_BASE_URL override for testing, and a real semver comparison in `update` (was a plain string-equality check before). ANSI-colored/aligned CLI output (TTY + NO_COLOR aware). - gen-index.sh: TRACK env var selects which subtree to read/write. - CI: dev-bakery.yml/beta-bakery.yml/dev-bread-theme.yml/beta-bread-theme.yml publish those two products on the new tracks; dev/beta skip the GitHub Release upload step (no per-commit release spam). - docs/release-channels.md documents the three-track policy. --- .forgejo/workflows/beta-bakery.yml | 61 ++++++++++ .forgejo/workflows/beta-bread-theme.yml | 56 +++++++++ .forgejo/workflows/dev-bakery.yml | 80 ++++++++++++ .forgejo/workflows/dev-bread-theme.yml | 69 +++++++++++ Cargo.lock | 1 + Cargo.toml | 1 + bakery/Cargo.toml | 1 + bakery/src/doctor.rs | 24 ++-- bakery/src/main.rs | 155 ++++++++++++++++++++---- bakery/src/manifest.rs | 106 +++++++++++++--- bakery/src/state.rs | 27 +++++ bakery/src/track.rs | 90 ++++++++++++++ bakery/src/ui.rs | 60 +++++++++ docs/release-channels.md | 59 +++++++-- scripts/gen-index.sh | 48 ++++++-- 15 files changed, 771 insertions(+), 67 deletions(-) create mode 100644 .forgejo/workflows/beta-bakery.yml create mode 100644 .forgejo/workflows/beta-bread-theme.yml create mode 100644 .forgejo/workflows/dev-bakery.yml create mode 100644 .forgejo/workflows/dev-bread-theme.yml create mode 100644 bakery/src/track.rs create mode 100644 bakery/src/ui.rs diff --git a/.forgejo/workflows/beta-bakery.yml b/.forgejo/workflows/beta-bakery.yml new file mode 100644 index 0000000..ca2f49c --- /dev/null +++ b/.forgejo/workflows/beta-bakery.yml @@ -0,0 +1,61 @@ +name: beta bakery + +# Publishes a beta-track build when a `beta-v*` tag is pushed — a deliberate +# promotion step (you pick the version string and the commit), distinct from +# dev-bakery.yml's automatic build-on-every-push. See docs/release-channels.md. +on: + push: + tags: ['beta-v*'] + +jobs: + build: + runs-on: [self-hosted, hestia] + steps: + - name: checkout + run: | + set -euo pipefail + rm -rf src && mkdir src + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src + + - name: build + run: cd src && cargo build --release --locked -p bakery + + - name: test + run: cd src && cargo test --release --locked -p bakery + + - name: prepare artifacts + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME#beta-v}" + PKG_DIR="/srv/breadway-dl/beta/bakery/${VERSION}" + mkdir -p "${PKG_DIR}" + cp "src/target/release/bakery" "${PKG_DIR}/bakery-x86_64" + strip "${PKG_DIR}/bakery-x86_64" + sha256sum "${PKG_DIR}/bakery-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/bakery-x86_64.sha256" + cp src/bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${VERSION}" "/srv/breadway-dl/beta/bakery/latest" + + - name: sign beta binary + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME#beta-v}" + PKG_DIR="/srv/breadway-dl/beta/bakery/${VERSION}" + if [ -n "${MINISIGN_SEC_KEY:-}" ]; then + minisign -W -S -s "${MINISIGN_SEC_KEY}" -m "${PKG_DIR}/bakery-x86_64" \ + -x "${PKG_DIR}/bakery-x86_64.minisig" "${PKG_DIR}/bread-theme-x86_64.sha256" + cp src/bread-theme/bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${VERSION}" "/srv/breadway-dl/beta/bread-theme/latest" + + - name: sign beta binary + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME#beta-v}" + PKG_DIR="/srv/breadway-dl/beta/bread-theme/${VERSION}" + if [ -n "${MINISIGN_SEC_KEY:-}" ]; then + minisign -W -S -s "${MINISIGN_SEC_KEY}" -m "${PKG_DIR}/bread-theme-x86_64" \ + -x "${PKG_DIR}/bread-theme-x86_64.minisig" > "$GITHUB_ENV" + + - name: prepare artifacts + run: | + set -euo pipefail + PKG_DIR="/srv/breadway-dl/dev/bakery/${VERSION}" + mkdir -p "${PKG_DIR}" + cp "src/target/release/bakery" "${PKG_DIR}/bakery-x86_64" + strip "${PKG_DIR}/bakery-x86_64" + sha256sum "${PKG_DIR}/bakery-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/bakery-x86_64.sha256" + cp src/bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${VERSION}" "/srv/breadway-dl/dev/bakery/latest" + + - name: sign dev binary + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} + run: | + set -euo pipefail + PKG_DIR="/srv/breadway-dl/dev/bakery/${VERSION}" + if [ -n "${MINISIGN_SEC_KEY:-}" ]; then + minisign -W -S -s "${MINISIGN_SEC_KEY}" -m "${PKG_DIR}/bakery-x86_64" \ + -x "${PKG_DIR}/bakery-x86_64.minisig" > "$GITHUB_ENV" + + - name: prepare artifacts + run: | + set -euo pipefail + PKG_DIR="/srv/breadway-dl/dev/bread-theme/${VERSION}" + mkdir -p "${PKG_DIR}" + cp "src/target/release/bread-theme" "${PKG_DIR}/bread-theme-x86_64" + strip "${PKG_DIR}/bread-theme-x86_64" + sha256sum "${PKG_DIR}/bread-theme-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/bread-theme-x86_64.sha256" + cp src/bread-theme/bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${VERSION}" "/srv/breadway-dl/dev/bread-theme/latest" + + - name: sign dev binary + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} + run: | + set -euo pipefail + PKG_DIR="/srv/breadway-dl/dev/bread-theme/${VERSION}" + if [ -n "${MINISIGN_SEC_KEY:-}" ]; then + minisign -W -S -s "${MINISIGN_SEC_KEY}" -m "${PKG_DIR}/bread-theme-x86_64" \ + -x "${PKG_DIR}/bread-theme-x86_64.minisig" bool { /// Returns true if all *required* deps are satisfied. pub fn report(package_name: &str, required: &[String], optional: &[String]) -> bool { if required.is_empty() && optional.is_empty() { - println!(" {package_name}: no system deps required"); + println!(" {}", ui::ok(&format!("{package_name}: no system deps required"))); return true; } match check_deps(required, optional) { Err(e) => { - eprintln!(" error running doctor for {package_name}: {e}"); + eprintln!(" {}", ui::fail(&format!("error running doctor for {package_name}: {e}"))); false } Ok(rep) => { for warn in &rep.warnings { eprintln!( - " {package_name}: optional dep not found: {warn} \ - (install for full functionality)" + " {}", + ui::style( + &format!( + "{package_name}: optional dep not found: {warn} \ + (install for full functionality)" + ), + ui::YELLOW + ) ); } if rep.missing.is_empty() { - println!(" {package_name}: all required system deps satisfied"); + println!(" {}", ui::ok(&format!("{package_name}: all required system deps satisfied"))); true } else { eprintln!( - " {package_name}: missing system deps: {}", - rep.missing.join(", ") + " {}", + ui::fail(&format!( + "{package_name}: missing system deps: {}", + rep.missing.join(", ") + )) ); eprintln!(" install with: sudo pacman -S {}", rep.missing.join(" ")); false diff --git a/bakery/src/main.rs b/bakery/src/main.rs index 821f55a..8a16b2c 100644 --- a/bakery/src/main.rs +++ b/bakery/src/main.rs @@ -3,11 +3,14 @@ mod download; mod install; mod manifest; mod state; +mod track; +mod ui; -use anyhow::{bail, Result}; +use anyhow::{bail, Context, Result}; use clap::{Parser, Subcommand}; use std::collections::HashSet; use std::path::PathBuf; +use track::Track; #[derive(Parser)] #[command(name = "bakery", about = "Package manager for the bread ecosystem", version)] @@ -54,6 +57,20 @@ enum Cmd { /// Package to check; omit to check all installed packages package: Option, }, + /// View or switch which build track bakery follows (stable/beta/dev) + Track { + #[command(subcommand)] + action: TrackCmd, + }, +} + +#[derive(Subcommand)] +enum TrackCmd { + /// Show the currently selected track + Show, + /// Switch tracks. Only changes the preference — run `bakery update --all` + /// afterwards to actually install builds from the new track. + Set { track: Track }, } fn default_bin_dir() -> PathBuf { @@ -65,23 +82,52 @@ fn default_bin_dir() -> PathBuf { fn main() -> Result<()> { let cli = Cli::parse(); let bin_dir = cli.bin_dir.unwrap_or_else(default_bin_dir); + let track = state::State::load()?.track; match cli.command { Cmd::Install { packages } => { - let index = manifest::load(true)?; + let index = manifest::load(true, track)?; for pkg in &packages { cmd_install(&index, pkg, &bin_dir)?; } Ok(()) } Cmd::Remove { package } => cmd_remove(&package, &bin_dir), - Cmd::Update { package, all } => cmd_update(package.as_deref(), all, &bin_dir), - Cmd::List { installed } => cmd_list(installed), - Cmd::Info { package } => cmd_info(&package), - Cmd::Doctor { package } => cmd_doctor(package.as_deref()), + Cmd::Update { package, all } => cmd_update(package.as_deref(), all, &bin_dir, track), + Cmd::List { installed } => cmd_list(installed, track), + Cmd::Info { package } => cmd_info(&package, track), + Cmd::Doctor { package } => cmd_doctor(package.as_deref(), track), + Cmd::Track { action } => cmd_track(action), } } +fn cmd_track(action: TrackCmd) -> Result<()> { + let mut state = state::State::load()?; + match action { + TrackCmd::Show => { + println!("current track: {}", ui::style(state.track.as_str(), ui::CYAN)); + } + TrackCmd::Set { track } => { + if state.track == track { + println!("already on track {track}"); + return Ok(()); + } + // Fail fast on a bad/unreachable track rather than silently + // recording a preference bakery can't actually serve. + manifest::load(true, track) + .with_context(|| format!("could not validate {track} track, not switching"))?; + state.set_track(track); + state.save()?; + println!( + "switched to {} — run 'bakery update --all' to install {} builds", + ui::style(track.as_str(), ui::CYAN), + track + ); + } + } + Ok(()) +} + fn cmd_install(index: &manifest::Index, name: &str, bin_dir: &std::path::Path) -> Result<()> { let mut visited = HashSet::new(); install_with_deps(index, name, bin_dir, &mut visited) @@ -130,8 +176,8 @@ fn cmd_remove(name: &str, bin_dir: &std::path::Path) -> Result<()> { install::remove_package(name, bin_dir) } -fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path) -> Result<()> { - let index = manifest::load(true)?; +fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path, track: Track) -> Result<()> { + let index = manifest::load(true, track)?; let state = state::State::load()?; let targets: Vec = if all || name.is_none() { @@ -162,14 +208,16 @@ fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path) -> Resul } }; - if installed.version == latest.version { - println!("{pkg_name} is already at {}", installed.version); + if !is_newer(&installed.version, &latest.version) { + println!("{}", ui::style(&format!("{pkg_name} is already at {}", installed.version), ui::GREEN)); continue; } println!( - "updating {pkg_name} {} → {}", - installed.version, latest.version + "updating {pkg_name} {} {} {}", + ui::style(&installed.version, ui::DIM), + ui::style("→", ui::CYAN), + ui::style(&latest.version, ui::BOLD) ); let rep = match doctor::check_deps(&latest.system_deps, &latest.optional_system_deps) { @@ -204,7 +252,28 @@ fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path) -> Resul Ok(()) } -fn cmd_list(installed_only: bool) -> Result<()> { +/// Is `latest` newer than `installed`? Real semver comparison — the +/// previous plain string-equality check couldn't tell "different" from +/// "actually newer", so it would happily "update" a package to a lexically +/// different but not-newer version. Falls back to a simple inequality check +/// (with a warning) for any version string that isn't valid semver, rather +/// than hard-erroring on packages built before this convention existed. +fn is_newer(installed: &str, latest: &str) -> bool { + match (semver::Version::parse(installed), semver::Version::parse(latest)) { + (Ok(i), Ok(l)) => l > i, + _ => { + if installed != latest { + eprintln!( + " warning: '{installed}' or '{latest}' is not valid semver, \ + falling back to a plain inequality check" + ); + } + installed != latest + } + } +} + +fn cmd_list(installed_only: bool, track: Track) -> Result<()> { let state = state::State::load()?; if installed_only { @@ -217,35 +286,39 @@ fn cmd_list(installed_only: bool) -> Result<()> { return Ok(()); } - let index = manifest::load(false)?; + if !matches!(track, Track::Stable) { + println!("tracking:{}\n", ui::track_badge(track)); + } + + let index = manifest::load(false, track)?; let mut names: Vec<&str> = index.packages.keys().map(|s| s.as_str()).collect(); names.sort(); for name in names { let pkg = &index.packages[name]; let tag = if state.is_installed(name) { - format!(" [installed {}]", state.packages[name].version) + ui::style(&format!(" [installed {}]", state.packages[name].version), ui::GREEN) } else { String::new() }; - println!(" {} {} — {}{}", pkg.name, pkg.version, pkg.description, tag); + println!(" {:<14} {:<10} — {}{}", pkg.name, pkg.version, pkg.description, tag); } Ok(()) } -fn cmd_info(name: &str) -> Result<()> { - let index = manifest::load(false)?; +fn cmd_info(name: &str, track: Track) -> Result<()> { + let index = manifest::load(false, track)?; let pkg = index .get(name) .ok_or_else(|| anyhow::anyhow!("unknown package: {name}"))?; let state = state::State::load()?; let status = if let Some(inst) = state.packages.get(name) { - format!("installed ({})", inst.version) + ui::style(&format!("installed ({})", inst.version), ui::GREEN) } else { - "not installed".to_string() + ui::style("not installed", ui::DIM) }; - println!("{} {}", pkg.name, pkg.version); + println!("{}{} {}", ui::style(&pkg.name, ui::BOLD), ui::track_badge(track), pkg.version); println!(" {}", pkg.description); println!(" status: {status}"); println!( @@ -278,8 +351,8 @@ fn cmd_info(name: &str) -> Result<()> { Ok(()) } -fn cmd_doctor(name: Option<&str>) -> Result<()> { - let index = manifest::load(false)?; +fn cmd_doctor(name: Option<&str>, track: Track) -> Result<()> { + let index = manifest::load(false, track)?; let state = state::State::load()?; let targets: Vec = match name { @@ -310,7 +383,41 @@ fn cmd_doctor(name: Option<&str>) -> Result<()> { } if all_ok { - println!("all checks passed"); + println!("{}", ui::ok("all checks passed")); } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_newer_detects_real_semver_increase() { + assert!(is_newer("0.3.1", "0.3.2")); + assert!(is_newer("0.3.1", "0.4.0")); + assert!(!is_newer("0.3.2", "0.3.1")); + } + + #[test] + fn is_newer_false_when_equal() { + assert!(!is_newer("0.3.1", "0.3.1")); + } + + #[test] + fn is_newer_orders_dev_prereleases_within_a_track() { + // Two dev builds of the same upcoming patch, ordered by their + // timestamp+sha build suffix. + assert!(is_newer( + "0.3.2-dev.20260722120000+aaa1111", + "0.3.2-dev.20260722130000+bbb2222" + )); + } + + #[test] + fn is_newer_falls_back_to_inequality_on_unparseable_versions() { + // Pre-semver version strings should never hard-fail an update check. + assert!(is_newer("weird-version-1", "weird-version-2")); + assert!(!is_newer("weird-version-1", "weird-version-1")); + } +} diff --git a/bakery/src/manifest.rs b/bakery/src/manifest.rs index 35a9bbc..248b715 100644 --- a/bakery/src/manifest.rs +++ b/bakery/src/manifest.rs @@ -1,13 +1,34 @@ +use crate::track::Track; use anyhow::{bail, Context, Result}; use minisign_verify::{PublicKey, Signature}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime}; -const PRIMARY_URL: &str = "https://dl.breadway.dev/index.json"; -const SIG_URL: &str = "https://dl.breadway.dev/index.json.minisig"; +const DEFAULT_BASE_URL: &str = "https://dl.breadway.dev"; const CACHE_MAX_AGE: Duration = Duration::from_secs(24 * 3600); +/// The `https://dl.breadway.dev` base can be overridden for local/staging +/// testing (e.g. serving a fake index from `python3 -m http.server`) without +/// rebuilding bakery — same pattern as `main.rs`'s `BAKERY_BIN_DIR` override. +fn base_url() -> String { + std::env::var("BAKERY_INDEX_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string()) +} + +/// Index URL for `track`. `Stable` keeps the exact pre-track path +/// (`{base}/index.json`) so existing infra and warm caches are unaffected; +/// `Beta`/`Dev` live under a track-prefixed subpath. +fn primary_url(track: Track) -> String { + match track { + Track::Stable => format!("{}/index.json", base_url()), + Track::Beta | Track::Dev => format!("{}/{}/index.json", base_url(), track.as_str()), + } +} + +fn sig_url(track: Track) -> String { + format!("{}.minisig", primary_url(track)) +} + /// The bakery index-signing public key. /// /// The matching secret key is used offline (never on this machine, never in @@ -120,8 +141,8 @@ impl Index { } } -/// Load the manifest, using the on-disk cache when it is fresh enough. -/// Always fetches if `force_refresh` is true. +/// Load the manifest for `track`, using the on-disk cache when it is fresh +/// enough. Always fetches if `force_refresh` is true. /// /// Every path — fresh fetch or cached read — verifies the minisign /// signature over the raw `index.json` bytes before the JSON is parsed or @@ -130,12 +151,12 @@ impl Index { /// (possibly tampered, possibly just stale-format) cache and triggers one /// re-fetch from the network rather than bricking the CLI outright; if the /// freshly fetched copy also fails to verify, that's a hard error. -pub fn load(force_refresh: bool) -> Result { - let cache_path = cache_path(); +pub fn load(force_refresh: bool, track: Track) -> Result { + let cache_path = cache_path(track); let sig_cache_path = sig_cache_path(&cache_path); if !force_refresh && cache_is_fresh(&cache_path) { - match read_and_verify_cache(&cache_path, &sig_cache_path) { + match read_and_verify_cache(&cache_path, &sig_cache_path, track) { Ok(index) => return Ok(index), Err(err) => { eprintln!( @@ -145,14 +166,20 @@ pub fn load(force_refresh: bool) -> Result { } } - fetch_and_cache(&cache_path, &sig_cache_path) + fetch_and_cache(&cache_path, &sig_cache_path, track) } -fn read_and_verify_cache(cache_path: &PathBuf, sig_cache_path: &PathBuf) -> Result { +fn read_and_verify_cache( + cache_path: &PathBuf, + sig_cache_path: &PathBuf, + track: Track, +) -> Result { let bytes = std::fs::read(cache_path).context("reading cached index")?; let sig_text = std::fs::read_to_string(sig_cache_path) .context("reading cached index.json.minisig (cache predates signing support)")?; - verify_index_signature(&bytes, &sig_text)?; + verify_index_signature(&bytes, &sig_text).with_context(|| { + format!("cached {track} index failed signature verification") + })?; serde_json::from_slice(&bytes).context("parsing cached index") } @@ -163,13 +190,18 @@ fn cache_is_fresh(path: &PathBuf) -> bool { .unwrap_or(false) } -fn fetch_and_cache(cache_path: &PathBuf, sig_cache_path: &PathBuf) -> Result { - let bytes = fetch_bytes(PRIMARY_URL)?; - let sig_text = fetch_text(SIG_URL).context( +fn fetch_and_cache(cache_path: &PathBuf, sig_cache_path: &PathBuf, track: Track) -> Result { + let bytes = fetch_bytes(&primary_url(track)).with_context(|| { + format!( + "fetching {track} index — has a {track} build been published yet? \ + run 'bakery track set stable' to switch back" + ) + })?; + let sig_text = fetch_text(&sig_url(track)).context( "fetching index.json.minisig — the index must be signed before it can be trusted", )?; verify_index_signature(&bytes, &sig_text) - .context("freshly fetched index.json failed signature verification")?; + .with_context(|| format!("freshly fetched {track} index failed signature verification"))?; if let Some(dir) = cache_path.parent() { std::fs::create_dir_all(dir)?; @@ -193,10 +225,19 @@ fn fetch_text(url: &str) -> Result { .context("reading response body") } -pub fn cache_path() -> PathBuf { +/// Cache filename for `track`. `Stable` keeps the pre-track filename +/// (`index.json`) so an existing warm cache survives an upgrade to a +/// track-aware bakery; `Beta`/`Dev` get their own sibling files so switching +/// tracks doesn't clobber each other's cache. +pub fn cache_path(track: Track) -> PathBuf { + let file_name = match track { + Track::Stable => "index.json".to_string(), + Track::Beta | Track::Dev => format!("index-{}.json", track.as_str()), + }; dirs::cache_dir() .unwrap_or_else(|| PathBuf::from("~/.cache")) - .join("bakery/index.json") + .join("bakery") + .join(file_name) } /// Download a binary blob from `primary_url`, falling back to `fallback_url` @@ -276,4 +317,37 @@ znmVfINB4jFDR2a4wuY8rOKlUBeSDOFjMkHYDXV3vxvAjK+r4V12ae9ZRQkfVtQ1YIEmFXbnJfbxywg+ // it must at least parse as a valid minisign public key. PublicKey::from_base64(PUBKEY).expect("PUBKEY must be a valid minisign public key"); } + + #[test] + fn stable_cache_path_matches_pre_track_filename() { + // Must stay exactly "index.json" so an existing warm cache from a + // pre-track bakery binary is still used after an upgrade. + assert_eq!( + cache_path(Track::Stable).file_name().unwrap(), + "index.json" + ); + } + + #[test] + fn beta_and_dev_cache_paths_are_distinct_siblings() { + let stable = cache_path(Track::Stable); + let beta = cache_path(Track::Beta); + let dev = cache_path(Track::Dev); + assert_ne!(stable, beta); + assert_ne!(stable, dev); + assert_ne!(beta, dev); + assert_eq!(beta.parent(), stable.parent()); + assert_eq!(dev.parent(), stable.parent()); + } + + #[test] + fn stable_url_has_no_track_prefix() { + assert_eq!(primary_url(Track::Stable), format!("{}/index.json", base_url())); + } + + #[test] + fn beta_and_dev_urls_are_track_prefixed() { + assert_eq!(primary_url(Track::Beta), format!("{}/beta/index.json", base_url())); + assert_eq!(primary_url(Track::Dev), format!("{}/dev/index.json", base_url())); + } } diff --git a/bakery/src/state.rs b/bakery/src/state.rs index 92b9aa9..7bf0ad8 100644 --- a/bakery/src/state.rs +++ b/bakery/src/state.rs @@ -1,3 +1,4 @@ +use crate::track::Track; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -14,6 +15,11 @@ pub struct InstalledPackage { #[derive(Debug, Default, Deserialize, Serialize)] pub struct State { + // `#[serde(default)]` lets an installed.json written by a pre-track + // bakery binary deserialize straight into Track::Stable with no + // migration step. + #[serde(default)] + pub track: Track, pub packages: HashMap, } @@ -52,6 +58,10 @@ impl State { pub fn remove(&mut self, name: &str) -> Option { self.packages.remove(name) } + + pub fn set_track(&mut self, track: Track) { + self.track = track; + } } fn state_path() -> PathBuf { @@ -101,6 +111,23 @@ mod tests { assert!(state.remove("nope").is_none()); } + #[test] + fn track_defaults_to_stable_on_old_shape_json() { + // Simulates installed.json written before the track field existed. + let old_shape = r#"{"packages":{}}"#; + let state: State = serde_json::from_str(old_shape).unwrap(); + assert_eq!(state.track, Track::Stable); + } + + #[test] + fn set_track_updates_and_roundtrips() { + let mut state = State::default(); + state.set_track(Track::Dev); + let json = serde_json::to_string(&state).unwrap(); + let restored: State = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.track, Track::Dev); + } + #[test] fn json_roundtrip() { let mut state = State::default(); diff --git a/bakery/src/track.rs b/bakery/src/track.rs new file mode 100644 index 0000000..1c16ce5 --- /dev/null +++ b/bakery/src/track.rs @@ -0,0 +1,90 @@ +use clap::ValueEnum; +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::str::FromStr; + +/// Which build of a package bakery follows: the tagged stable release, a +/// deliberately-promoted beta, or the continuously-published `dev` branch +/// build. Not to be confused with the *distribution* channel (bakery vs. +/// pacman) documented in `docs/release-channels.md` — that's an orthogonal, +/// pre-existing use of the word "channel", which is why this is called a +/// "track" instead. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, ValueEnum)] +#[serde(rename_all = "lowercase")] +pub enum Track { + Stable, + Beta, + Dev, +} + +impl Default for Track { + fn default() -> Self { + Track::Stable + } +} + +impl Track { + pub fn as_str(&self) -> &'static str { + match self { + Track::Stable => "stable", + Track::Beta => "beta", + Track::Dev => "dev", + } + } +} + +impl fmt::Display for Track { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for Track { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "stable" => Ok(Track::Stable), + "beta" => Ok(Track::Beta), + "dev" => Ok(Track::Dev), + other => Err(format!("unknown track '{other}' — expected stable, beta, or dev")), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_stable() { + assert_eq!(Track::default(), Track::Stable); + } + + #[test] + fn display_roundtrips_through_from_str() { + for track in [Track::Stable, Track::Beta, Track::Dev] { + let s = track.to_string(); + assert_eq!(s.parse::().unwrap(), track); + } + } + + #[test] + fn from_str_is_case_insensitive() { + assert_eq!("DEV".parse::().unwrap(), Track::Dev); + assert_eq!("Beta".parse::().unwrap(), Track::Beta); + } + + #[test] + fn from_str_rejects_unknown() { + assert!("nightly".parse::().is_err()); + } + + #[test] + fn json_roundtrip_uses_lowercase() { + let json = serde_json::to_string(&Track::Dev).unwrap(); + assert_eq!(json, "\"dev\""); + let back: Track = serde_json::from_str(&json).unwrap(); + assert_eq!(back, Track::Dev); + } +} diff --git a/bakery/src/ui.rs b/bakery/src/ui.rs new file mode 100644 index 0000000..8f99505 --- /dev/null +++ b/bakery/src/ui.rs @@ -0,0 +1,60 @@ +use crate::track::Track; +use std::io::IsTerminal; + +pub const RESET: &str = "\x1b[0m"; +pub const BOLD: &str = "\x1b[1m"; +pub const DIM: &str = "\x1b[2m"; +pub const RED: &str = "\x1b[31m"; +pub const GREEN: &str = "\x1b[32m"; +pub const YELLOW: &str = "\x1b[33m"; +pub const CYAN: &str = "\x1b[36m"; +pub const MAGENTA: &str = "\x1b[35m"; + +/// Colors are on only when stdout is a real terminal and `NO_COLOR` isn't +/// set — the ecosystem's existing CLI (breadcrumbs) hardcodes ANSI +/// unconditionally, which leaks escape codes into piped/logged output; this +/// is the hardening fix for that gap. +pub fn colors_enabled() -> bool { + std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal() +} + +pub fn style(s: &str, code: &str) -> String { + if colors_enabled() { + format!("{code}{s}{RESET}") + } else { + s.to_string() + } +} + +/// `" [beta]"` / `" [dev]"`, colored — empty string for `Stable` so the +/// common-case output is unchanged. +pub fn track_badge(track: Track) -> String { + match track { + Track::Stable => String::new(), + Track::Beta => format!(" {}", style("[beta]", YELLOW)), + Track::Dev => format!(" {}", style("[dev]", MAGENTA)), + } +} + +pub fn ok(s: &str) -> String { + style(&format!("✓ {s}"), GREEN) +} + +pub fn fail(s: &str) -> String { + style(&format!("✗ {s}"), RED) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stable_badge_is_empty() { + assert_eq!(track_badge(Track::Stable), ""); + } + + #[test] + fn dev_badge_is_nonempty() { + assert!(!track_badge(Track::Dev).is_empty()); + } +} diff --git a/docs/release-channels.md b/docs/release-channels.md index 952c9c2..d717ff4 100644 --- a/docs/release-channels.md +++ b/docs/release-channels.md @@ -50,6 +50,44 @@ release or package workflow — just the repo itself, e.g. breadarr today). binary, via its own `release-iso.yml`. It is never on either channel and should never carry a `bakery.toml` or `PKGBUILD`. +## Build tracks (stable/beta/dev) — orthogonal to channels + +Within the **bakery channel only**, a repo can additionally publish up to +three **tracks**: `stable` (the existing tag-triggered `v*` flow, unchanged), +`beta` (a deliberate promotion triggered by a `beta-v*` tag), and `dev` +(published automatically on every push to the `dev` branch). Don't confuse +"track" with "channel" above — channel is *how* a binary reaches a user +(bakery vs. pacman); track is *which build* of a bakery-channel package they +get. + +Each track lives in its own subtree so they never collide: + +| Track | Index URL | Artifact root | Trigger | +|---|---|---|---| +| stable | `dl.breadway.dev/index.json` | `/srv/breadway-dl///` | push tag `v*` | +| beta | `dl.breadway.dev/beta/index.json` | `/srv/breadway-dl/beta///` | push tag `beta-v*` | +| dev | `dl.breadway.dev/dev/index.json` | `/srv/breadway-dl/dev///` | push to branch `dev` | + +`scripts/gen-index.sh` takes a `TRACK` env var (default `stable`) to select +which subtree it reads/writes — every existing stable release workflow needs +zero changes. Dev/beta builds skip the GitHub Release upload step entirely +(no release-per-commit spam for dev, and beta doesn't need a GitHub mirror +either) — `dl.breadway.dev` is their only distribution point. + +Adding beta/dev to a bakery-channel repo: copy `dev-bakery.yml` / +`beta-bakery.yml` (or `bread`'s `dev-release.yml` / `beta-release.yml` if the +repo isn't part of this monorepo) from `bread-ecosystem`/`bread`, and swap +the repo/binary names the same way the checklist below describes for +`release.yml`. Not every bakery-channel repo needs beta/dev on day one — +`gen-index.sh` silently skips any product with no release dir under a given +track's tree, same as it already does for an unreleased product on stable. + +Client side: `bakery track show` / `bakery track set ` +remembers a global track preference (`~/.local/state/bakery/installed.json`) +and validates the target track's index is reachable and signed before +switching — it never auto-reinstalls on switch, run `bakery update --all` +afterwards. + ## mirror.yml is not part of this policy Every repo previously carried its own `.forgejo/workflows/mirror.yml` doing @@ -82,13 +120,14 @@ missing it; that gap is intentional and about to be moot everywhere. ## Current state (as of this pass) -| Repo | bakery | pacman | notes | -|---|---|---|---| -| bread-ecosystem (bakery product) | yes | yes | `release-bakery.yml` recovered from a dead `.github/workflows/release.yml` that referenced a `hestia` self-hosted runner GitHub never had registered | -| bread-ecosystem (bread-theme product) | yes | no | | -| bread, breadbar, breadbox, breadcrumbs, breadpad, breadpaper | yes | yes | complete, used as templates | -| breadclip, breadmon, breadsearch, breadshot | yes | no | complete | -| breadlock, breadhelp | no | yes | breadlock's `bakery.toml` was removed as orphaned; its README wrongly claimed it was a registry entry | -| bos-settings | yes | yes | was missing both the registry entry and `release.yml`; both added | -| bos | no | no | ISO-only via `release-iso.yml`; had an erroneous `bakery.toml` copy-pasted from bos-settings, removed | -| breadarr | no | no | 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 | +| Repo | bakery | pacman | tracks | notes | +|---|---|---|---|---| +| bread-ecosystem (bakery product) | yes | yes | stable, beta, dev | `release-bakery.yml` recovered from a dead `.github/workflows/release.yml` that referenced a `hestia` self-hosted runner GitHub never had registered | +| bread-ecosystem (bread-theme product) | yes | no | stable, beta, dev | | +| bread | yes | yes | stable, beta, dev | pilot repo for the beta/dev track rollout | +| breadbar, breadbox, breadcrumbs, breadpad, breadpaper | yes | yes | stable only | complete, used as templates; not yet rolled out to beta/dev | +| breadclip, breadmon, breadsearch, breadshot | yes | no | stable only | complete | +| breadlock, breadhelp | no | yes | n/a | breadlock's `bakery.toml` was removed as orphaned; its README wrongly claimed it was a registry entry | +| bos-settings | yes | yes | stable only | was missing both the registry entry and `release.yml`; both added | +| bos | no | no | n/a | ISO-only via `release-iso.yml`; had an erroneous `bakery.toml` copy-pasted from bos-settings, removed | +| 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 | diff --git a/scripts/gen-index.sh b/scripts/gen-index.sh index 2b7a4d8..05b0c20 100755 --- a/scripts/gen-index.sh +++ b/scripts/gen-index.sh @@ -1,19 +1,38 @@ #!/usr/bin/env bash -# Generate dl.breadway.dev/index.json from: -# - registry/bread-ecosystem.toml (product list) -# - //bakery.toml (per-product metadata, uploaded by release.yml) -# - / (built binaries + sha256 files) +# Generate dl.breadway.dev/index.json (or a track-prefixed sibling — see +# TRACK below) from: +# - registry/bread-ecosystem.toml (product list) +# - //bakery.toml (per-product metadata, uploaded by release.yml) +# - / (built binaries + sha256 files) # # Fallback for local dev: looks for ../name/bakery.toml (sibling repo checkout). # Run on hestia after each product build, before the dl server is refreshed. +# +# TRACK selects which build track to generate an index for: "stable" +# (default — reads/writes DL_DIR directly, byte-for-byte the same behavior +# as before tracks existed), "beta", or "dev" (both read/write a +# DL_DIR// subtree, so they never collide with stable's paths). A +# product with no release dir under the selected track's tree is skipped +# with a warning, same as an unreleased product is today — most products +# won't have a beta/dev build for a while after this lands. # Requires: jq, python3 (tomllib, stdlib since 3.11), sha256sum set -euo pipefail SCRIPT_DIR="${SCRIPT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" DL_DIR="${DL_DIR:-/srv/breadway-dl}" DL_BASE="${DL_BASE:-https://dl.breadway.dev}" +TRACK="${TRACK:-stable}" GH_BASE="https://github.com" -OUT="${DL_DIR}/index.json" + +if [[ "${TRACK}" == "stable" ]]; then + PKG_ROOT="${DL_DIR}" + URL_ROOT="${DL_BASE}" + OUT="${DL_DIR}/index.json" +else + PKG_ROOT="${DL_DIR}/${TRACK}" + URL_ROOT="${DL_BASE}/${TRACK}" + OUT="${DL_DIR}/${TRACK}/index.json" +fi # Read the product list from the registry TOML instead of a hardcoded array. mapfile -t products < <(python3 -c " @@ -30,8 +49,8 @@ build_package_json() { local name="$1" local repo="$2" - # Find the latest version dir under DL_DIR// - local pkg_dir="${DL_DIR}/${name}" + # Find the latest version dir under PKG_ROOT// + local pkg_dir="${PKG_ROOT}/${name}" if [[ ! -d "${pkg_dir}" ]]; then echo " warning: no release dir for ${name} at ${pkg_dir}" >&2 return 1 @@ -65,8 +84,17 @@ build_package_json() { if [[ -f "${sha256_path}" ]]; then sha256="$(awk '{print $1}' "${sha256_path}")" fi - local dl_url="${DL_BASE}/${name}/${version}/${bin_name}" - local gh_url="${GH_BASE}/${repo}/releases/download/v${version}/${bin_name}" + local dl_url="${URL_ROOT}/${name}/${version}/${bin_name}" + # dev/beta builds never get a real GitHub Release (see the dev/beta + # CI workflows — that step is intentionally skipped for those + # tracks), so github_url just mirrors dl_url rather than pointing at + # a release asset that doesn't exist. + local gh_url + if [[ "${TRACK}" == "stable" ]]; then + gh_url="${GH_BASE}/${repo}/releases/download/v${version}/${bin_name}" + else + gh_url="${dl_url}" + fi local entry entry="$(jq -n \ @@ -86,7 +114,7 @@ build_package_json() { bakery_toml="${SCRIPT_DIR}/../${name}/bakery.toml" fi if [[ ! -f "${bakery_toml}" ]]; then - echo "ERROR: bakery.toml not found for ${name} — release.yml must copy it to \${DL_DIR}/${name}/\${VERSION}/bakery.toml" >&2 + echo "ERROR: bakery.toml not found for ${name} — the release workflow must copy it to \${PKG_ROOT}/${name}/\${VERSION}/bakery.toml" >&2 return 1 fi From 86e712d726f0a54cfb180a4921d1843a5cefbd70 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 09:50:54 +0800 Subject: [PATCH 15/99] ci: fail fast on missing signing key in dev/beta index regeneration Matches the same guard just added to bread's dev/beta workflows: a missing BAKERY_MINISIGN_SEC_KEY_PATH secret should fail the job loudly rather than silently publish an unsigned index. --- .forgejo/workflows/beta-bakery.yml | 8 +++++++- .forgejo/workflows/beta-bread-theme.yml | 8 +++++++- .forgejo/workflows/dev-bakery.yml | 8 +++++++- .forgejo/workflows/dev-bread-theme.yml | 8 +++++++- 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/.forgejo/workflows/beta-bakery.yml b/.forgejo/workflows/beta-bakery.yml index ca2f49c..05c1774 100644 --- a/.forgejo/workflows/beta-bakery.yml +++ b/.forgejo/workflows/beta-bakery.yml @@ -58,4 +58,10 @@ jobs: env: MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} TRACK: beta - run: cd src && bash scripts/gen-index.sh + run: | + set -euo pipefail + if [ -z "${MINISIGN_SEC_KEY:-}" ]; then + echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate beta index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the beta track)" + exit 1 + fi + cd src && bash scripts/gen-index.sh diff --git a/.forgejo/workflows/beta-bread-theme.yml b/.forgejo/workflows/beta-bread-theme.yml index 89e3386..24bde62 100644 --- a/.forgejo/workflows/beta-bread-theme.yml +++ b/.forgejo/workflows/beta-bread-theme.yml @@ -53,4 +53,10 @@ jobs: env: MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} TRACK: beta - run: cd src && bash scripts/gen-index.sh + run: | + set -euo pipefail + if [ -z "${MINISIGN_SEC_KEY:-}" ]; then + echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate beta index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the beta track)" + exit 1 + fi + cd src && bash scripts/gen-index.sh diff --git a/.forgejo/workflows/dev-bakery.yml b/.forgejo/workflows/dev-bakery.yml index 3801ad0..3501c48 100644 --- a/.forgejo/workflows/dev-bakery.yml +++ b/.forgejo/workflows/dev-bakery.yml @@ -77,4 +77,10 @@ jobs: env: MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} TRACK: dev - run: cd src && bash scripts/gen-index.sh + run: | + set -euo pipefail + if [ -z "${MINISIGN_SEC_KEY:-}" ]; then + echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate dev index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the dev track)" + exit 1 + fi + cd src && bash scripts/gen-index.sh diff --git a/.forgejo/workflows/dev-bread-theme.yml b/.forgejo/workflows/dev-bread-theme.yml index 36694fe..75ef96e 100644 --- a/.forgejo/workflows/dev-bread-theme.yml +++ b/.forgejo/workflows/dev-bread-theme.yml @@ -66,4 +66,10 @@ jobs: env: MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} TRACK: dev - run: cd src && bash scripts/gen-index.sh + run: | + set -euo pipefail + if [ -z "${MINISIGN_SEC_KEY:-}" ]; then + echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate dev index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the dev track)" + exit 1 + fi + cd src && bash scripts/gen-index.sh From d6e20a082afbf7c0e0c74ee86fcd71836fa2e437 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 11:48:21 +0800 Subject: [PATCH 16/99] random commit message, read it yourself --- CLAUDE.md | 23 ++++++++ bread-theme/src/lib.rs | 130 ++++++++++++++++++++++++++++++++--------- 2 files changed, 126 insertions(+), 27 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..261735d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,23 @@ +# CLAUDE.md — Repo hygiene (local only, not committed) + +Scope: this file covers *repo hygiene* — branching, remotes, CI, cleanup. It is not project documentation. + +## Branch model +- `main` — release branch, always tag-ready. Don't commit directly to it. +- `dev` — integration branch. Land day-to-day work here first. +- Feature/fix work goes on short-lived branches off `dev` (`feature/x`, `fix/x`), merged back into `dev`, then `dev` → `main` when ready to release. + +## Remotes +- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative. +- `github` — GitHub mirror. Push both when publishing. + +## CI +- `.forgejo/workflows/package.yml`, `release-bakery.yml`, `release-bread-theme.yml` all trigger only on `push: tags: ['v*']` — pushing to `dev` or `main` runs nothing. Tag a release to trigger packaging. +- No build/lint/test CI runs on ordinary commits or PRs — test locally before merging to `dev`/`main`. + +## Cleanup +- Delete feature/fix branches (local + remote) once merged. Check with `git branch --merged dev` / `git branch --merged main`. +- A `fix/audit-findings` branch and a merged `copilot/create-readme-md` branch (both local and on `origin`/`github`) were found stale and fully merged here on 2026-07-21 and removed. + +## Don't +- Don't embed credentials in remote URLs — SSH or a credential helper only. diff --git a/bread-theme/src/lib.rs b/bread-theme/src/lib.rs index ab0156b..a97588b 100644 --- a/bread-theme/src/lib.rs +++ b/bread-theme/src/lib.rs @@ -65,41 +65,85 @@ pub fn ink_on(hex: &str) -> &'static str { if luminance(hex) > 0.179 { "#11111b" } else { "#f5f5f5" } } -/// Canonical `@define-color` block: the single naming all bread apps share. +/// Canonical (name, value) list: the single naming all bread apps share. /// `surface` = color0 (darkest surface), `overlay` = color7 (muted), and /// `accent` = color4. Apps must use these names, not raw palette slots, so the /// whole ecosystem recolours together. /// /// The `on-*` colours are computed ink (black/white) guaranteed to be legible on -/// the matching background — use `@on-surface` for text on a `@surface` panel, -/// `@on-accent` on an `@accent` button, etc. They exist because pywal can emit a +/// the matching background — use `on-surface` for text on a `surface` panel, +/// `on-accent` on an `accent` button, etc. They exist because pywal can emit a /// light value in any slot, and white text on a light surface disappears. +/// +/// [`define_colors`] (GTK `@define-color`) and [`css_custom_properties`] (web +/// `:root { --name: ... }`) both format this same list rather than each +/// hand-writing their own — see `css_vars_and_stylesheet_agree_on_color_block` +/// and `css_custom_properties_matches_define_colors_name_set` for the +/// regression tests this exists to satisfy. +fn color_pairs(p: &Palette) -> [(&'static str, String); 16] { + [ + ("bg", p.background.clone()), + ("fg", p.foreground.clone()), + ("surface", p.color0.clone()), + ("overlay", p.color7.clone()), + ("accent", p.color4.clone()), + ("red", p.color1.clone()), + ("green", p.color2.clone()), + ("yellow", p.color3.clone()), + ("blue", p.color4.clone()), + ("pink", p.color5.clone()), + ("teal", p.color6.clone()), + ("on-bg", ink_on(&p.background).to_string()), + ("on-surface", ink_on(&p.color0).to_string()), + ("on-accent", ink_on(&p.color4).to_string()), + ("on-red", ink_on(&p.color1).to_string()), + ("on-overlay", ink_on(&p.color7).to_string()), + ] +} + +/// GTK `@define-color` block built from [`color_pairs`]. fn define_colors(p: &Palette) -> String { + color_pairs(p) + .iter() + .map(|(name, value)| format!("@define-color {name} {value};\n")) + .collect() +} + +/// CSS custom-properties block (`:root { --bg: ...; --on-accent: ...; }`) for +/// web frontends (Tauri), using the exact same names as [`define_colors`] so +/// the GTK and web outputs cannot drift apart independently — both are +/// generated from [`color_pairs`], not two hand-written copies. +pub fn css_custom_properties(p: &Palette) -> String { + let vars: String = color_pairs(p) + .iter() + .map(|(name, value)| format!(" --{name}: {value};\n")) + .collect(); + format!(":root {{\n{vars}}}\n") +} + +/// CSS custom-properties for [`tokens`] (font, spacing, radii) — the web +/// counterpart to [`tokens`] being hand-read by GTK code, so a web frontend +/// isn't hand-copying the same numbers into a second source of truth. +pub fn css_tokens() -> String { + use tokens::*; format!( - "@define-color bg {bg};\n\ - @define-color fg {fg};\n\ - @define-color surface {c0};\n\ - @define-color overlay {c7};\n\ - @define-color accent {c4};\n\ - @define-color red {c1};\n\ - @define-color green {c2};\n\ - @define-color yellow {c3};\n\ - @define-color blue {c4};\n\ - @define-color pink {c5};\n\ - @define-color teal {c6};\n\ - @define-color on-bg {on_bg};\n\ - @define-color on-surface {on_surface};\n\ - @define-color on-accent {on_accent};\n\ - @define-color on-red {on_red};\n\ - @define-color on-overlay {on_overlay};\n", - bg = p.background, fg = p.foreground, - c0 = p.color0, c1 = p.color1, c2 = p.color2, c3 = p.color3, - c4 = p.color4, c5 = p.color5, c6 = p.color6, c7 = p.color7, - on_bg = ink_on(&p.background), - on_surface = ink_on(&p.color0), - on_accent = ink_on(&p.color4), - on_red = ink_on(&p.color1), - on_overlay = ink_on(&p.color7), + ":root {{\n\ + \x20\x20--font-family: '{font}';\n\ + \x20\x20--font-size-base: {base}px;\n\ + \x20\x20--font-size-secondary: {sec}px;\n\ + \x20\x20--space-xs: {xs}px;\n\ + \x20\x20--space-sm: {sm}px;\n\ + \x20\x20--space-md: {md}px;\n\ + \x20\x20--space-lg: {lg}px;\n\ + \x20\x20--space-xl: {xl}px;\n\ + \x20\x20--radius-primary: {r1}px;\n\ + \x20\x20--radius-secondary: {r2}px;\n\ + \x20\x20--radius-tertiary: {r3}px;\n\ + \x20\x20--radius-pill: {pill}px;\n\ + }}\n", + font = FONT_FAMILY, base = FONT_SIZE_BASE, sec = FONT_SIZE_SECONDARY, + xs = SPACE_XS, sm = SPACE_SM, md = SPACE_MD, lg = SPACE_LG, xl = SPACE_XL, + r1 = RADIUS_PRIMARY, r2 = RADIUS_SECONDARY, r3 = RADIUS_TERTIARY, pill = RADIUS_PILL, ) } @@ -273,6 +317,38 @@ mod tests { assert!(css.contains("Varela Round")); } + #[test] + fn css_custom_properties_matches_define_colors_name_set() { + // Both must derive from the same color_pairs() list, so the web + // output can't drift from the GTK one the way css_vars/stylesheet + // used to (see css_vars_and_stylesheet_agree_on_color_block above). + let p = Palette::default(); + let gtk = define_colors(&p); + let web = css_custom_properties(&p); + for (name, _) in color_pairs(&p) { + assert!(gtk.contains(&format!("@define-color {name} ")), "gtk missing {name}"); + assert!(web.contains(&format!("--{name}: ")), "web missing {name}"); + } + } + + #[test] + fn css_custom_properties_is_valid_root_block() { + let p = Palette::default(); + let css = css_custom_properties(&p); + assert!(css.starts_with(":root {\n")); + assert!(css.trim_end().ends_with('}')); + assert!(css.contains(&format!("--accent: {};", p.color4))); + } + + #[test] + fn css_tokens_contains_font_and_spacing_vars() { + let css = css_tokens(); + assert!(css.contains("--font-family: 'Varela Round, sans-serif';")); + assert!(css.contains("--font-size-base: 14px;")); + assert!(css.contains("--space-md: 12px;")); + assert!(css.contains("--radius-pill: 999px;")); + } + #[test] fn luminance_black_and_white_are_extremes() { assert!(luminance("#000000") < 0.01); From afa3686e9acf348bc86d62b64a7b9be16739ac40 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 13:51:23 +0800 Subject: [PATCH 17/99] ci: base dev version on the latest published tag, not Cargo.toml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cargo.toml can drift stale relative to the actual last release (observed on breadbox/breadpad/breadcrumbs/breadpaper), which made the auto-bumped dev version sort as OLDER than what's already installed — bakery's semver check correctly refused those "updates". Deriving the base version from git ls-remote --tags instead is self-healing regardless of Cargo.toml drift, with a Cargo.toml fallback only for a repo with no tags yet. --- .forgejo/workflows/dev-bakery.yml | 14 +++++++++++++- .forgejo/workflows/dev-bread-theme.yml | 14 +++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/dev-bakery.yml b/.forgejo/workflows/dev-bakery.yml index 3501c48..ae2e9ef 100644 --- a/.forgejo/workflows/dev-bakery.yml +++ b/.forgejo/workflows/dev-bakery.yml @@ -38,7 +38,19 @@ jobs: run: | set -euo pipefail cd src - CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + # Base the dev version off the latest published stable tag, + # not Cargo.toml — Cargo.toml can go stale relative to the last + # real release (seen in practice: breadbox/breadpad/breadcrumbs/ + # breadpaper), which would make a dev build sort as OLDER than + # what's already installed and bakery would correctly refuse it. + LATEST_TAG="$(git ls-remote --tags --refs \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \ + | awk -F/ '{print $NF}' | sed 's/^v//' | sort -V | tail -1)" + if [ -n "${LATEST_TAG}" ]; then + CUR="${LATEST_TAG}" + else + CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + fi IFS='.' read -r MA MI PA <<< "${CUR}" SHA="$(git rev-parse --short HEAD)" TS="$(date -u +%Y%m%d%H%M%S)" diff --git a/.forgejo/workflows/dev-bread-theme.yml b/.forgejo/workflows/dev-bread-theme.yml index 75ef96e..3d645d2 100644 --- a/.forgejo/workflows/dev-bread-theme.yml +++ b/.forgejo/workflows/dev-bread-theme.yml @@ -30,7 +30,19 @@ jobs: run: | set -euo pipefail cd src - CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + # Base the dev version off the latest published stable tag, + # not Cargo.toml — Cargo.toml can go stale relative to the last + # real release (seen in practice: breadbox/breadpad/breadcrumbs/ + # breadpaper), which would make a dev build sort as OLDER than + # what's already installed and bakery would correctly refuse it. + LATEST_TAG="$(git ls-remote --tags --refs \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \ + | awk -F/ '{print $NF}' | sed 's/^v//' | sort -V | tail -1)" + if [ -n "${LATEST_TAG}" ]; then + CUR="${LATEST_TAG}" + else + CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + fi IFS='.' read -r MA MI PA <<< "${CUR}" SHA="$(git rev-parse --short HEAD)" TS="$(date -u +%Y%m%d%H%M%S)" From 2b05a4f6c2c37a3b848eb429419f33cc5183597d Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 18:37:07 +0800 Subject: [PATCH 18/99] ci: make beta a branch-triggered freeze track, not a one-off tag Beta is now a real stabilization branch: publishes on every push to `beta` (mirroring dev's model, auto-versioned X.Y.Z-beta.+, base version from the latest published tag) instead of a manual beta-v* tag. Fixes made during the freeze land via fix/ branches merged into `beta` directly. The gen-index.sh clone for beta pulls bread-ecosystem's default branch (main) rather than pinning to dev, since beta is the more stable track and main now carries the TRACK-aware script. --- .forgejo/workflows/beta-bakery.yml | 50 ++++++++++++++++++++----- .forgejo/workflows/beta-bread-theme.yml | 40 ++++++++++++++++---- 2 files changed, 74 insertions(+), 16 deletions(-) diff --git a/.forgejo/workflows/beta-bakery.yml b/.forgejo/workflows/beta-bakery.yml index 05c1774..c20c069 100644 --- a/.forgejo/workflows/beta-bakery.yml +++ b/.forgejo/workflows/beta-bakery.yml @@ -1,11 +1,17 @@ name: beta bakery -# Publishes a beta-track build when a `beta-v*` tag is pushed — a deliberate -# promotion step (you pick the version string and the commit), distinct from -# dev-bakery.yml's automatic build-on-every-push. See docs/release-channels.md. +# Publishes a beta-track build on every push to `beta` — a frozen +# stabilization branch cut from `dev` when ready to stabilize; only +# fix/ branches merged into `beta` should land here afterward. +# See docs/release-channels.md for the three-track policy (stable/beta/dev). on: push: - tags: ['beta-v*'] + branches: ['beta'] + paths: + - 'bakery/**' + - 'Cargo.toml' + - 'Cargo.lock' + - '.forgejo/workflows/beta-bakery.yml' jobs: build: @@ -15,7 +21,7 @@ jobs: run: | set -euo pipefail rm -rf src && mkdir src - git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + git clone --branch beta --depth 1 \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build @@ -24,10 +30,36 @@ jobs: - name: test run: cd src && cargo test --release --locked -p bakery + # Auto-bumps the patch version from the latest tag and appends a + # timestamp+sha beta suffix — no developer discipline required, and the + # result is visibly "ahead of" the last stable patch release while + # staying valid semver (comparable within the beta track by bakery's + # `is_newer`). + - name: compute beta version + run: | + set -euo pipefail + cd src + # Base the beta version off the latest published stable tag, + # not Cargo.toml — Cargo.toml can go stale relative to the last + # real release (seen in practice: breadbox/breadpad/breadcrumbs/ + # breadpaper), which would make a beta build sort as OLDER than + # what's already installed and bakery would correctly refuse it. + LATEST_TAG="$(git ls-remote --tags --refs \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \ + | awk -F/ '{print $NF}' | sed 's/^v//' | sort -V | tail -1)" + if [ -n "${LATEST_TAG}" ]; then + CUR="${LATEST_TAG}" + else + CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + fi + IFS='.' read -r MA MI PA <<< "${CUR}" + SHA="$(git rev-parse --short HEAD)" + TS="$(date -u +%Y%m%d%H%M%S)" + echo "VERSION=${MA}.${MI}.$((PA + 1))-beta.${TS}+${SHA}" >> "$GITHUB_ENV" + - name: prepare artifacts run: | set -euo pipefail - VERSION="${GITHUB_REF_NAME#beta-v}" PKG_DIR="/srv/breadway-dl/beta/bakery/${VERSION}" mkdir -p "${PKG_DIR}" cp "src/target/release/bakery" "${PKG_DIR}/bakery-x86_64" @@ -42,7 +74,6 @@ jobs: MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} run: | set -euo pipefail - VERSION="${GITHUB_REF_NAME#beta-v}" PKG_DIR="/srv/breadway-dl/beta/bakery/${VERSION}" if [ -n "${MINISIGN_SEC_KEY:-}" ]; then minisign -W -S -s "${MINISIGN_SEC_KEY}" -m "${PKG_DIR}/bakery-x86_64" \ @@ -52,8 +83,9 @@ jobs: echo "::warning::BAKERY_MINISIGN_SEC_KEY_PATH not set — shipping bakery-x86_64 UNSIGNED" fi - # No GitHub Release upload — beta, like dev, is only distributed via - # dl.breadway.dev/beta/. + # No GitHub Release upload step here, unlike release-bakery.yml — beta + # builds happen on every push while the branch is frozen for testing, + # so dl.breadway.dev/beta/ is the only distribution point for this track. - name: regenerate beta index.json env: MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} diff --git a/.forgejo/workflows/beta-bread-theme.yml b/.forgejo/workflows/beta-bread-theme.yml index 24bde62..c1521b9 100644 --- a/.forgejo/workflows/beta-bread-theme.yml +++ b/.forgejo/workflows/beta-bread-theme.yml @@ -1,11 +1,17 @@ name: beta bread-theme -# Publishes a beta-track build when a `beta-v*` tag is pushed — a deliberate -# promotion step, distinct from dev-bread-theme.yml's build-on-every-push. -# See docs/release-channels.md. +# Publishes a beta-track build on every push to `beta` — a frozen +# stabilization branch cut from `dev` when ready to stabilize; only +# fix/ branches merged into `beta` should land here afterward. +# See docs/release-channels.md for the three-track policy this is part of. on: push: - tags: ['beta-v*'] + branches: ['beta'] + paths: + - 'bread-theme/**' + - 'Cargo.toml' + - 'Cargo.lock' + - '.forgejo/workflows/beta-bread-theme.yml' jobs: build: @@ -15,16 +21,37 @@ jobs: run: | set -euo pipefail rm -rf src && mkdir src - git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + git clone --branch beta --depth 1 \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build run: cd src && cargo build --release --locked -p bread-theme --bin bread-theme + - name: compute beta version + run: | + set -euo pipefail + cd src + # Base the beta version off the latest published stable tag, + # not Cargo.toml — Cargo.toml can go stale relative to the last + # real release (seen in practice: breadbox/breadpad/breadcrumbs/ + # breadpaper), which would make a beta build sort as OLDER than + # what's already installed and bakery would correctly refuse it. + LATEST_TAG="$(git ls-remote --tags --refs \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \ + | awk -F/ '{print $NF}' | sed 's/^v//' | sort -V | tail -1)" + if [ -n "${LATEST_TAG}" ]; then + CUR="${LATEST_TAG}" + else + CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + fi + IFS='.' read -r MA MI PA <<< "${CUR}" + SHA="$(git rev-parse --short HEAD)" + TS="$(date -u +%Y%m%d%H%M%S)" + echo "VERSION=${MA}.${MI}.$((PA + 1))-beta.${TS}+${SHA}" >> "$GITHUB_ENV" + - name: prepare artifacts run: | set -euo pipefail - VERSION="${GITHUB_REF_NAME#beta-v}" PKG_DIR="/srv/breadway-dl/beta/bread-theme/${VERSION}" mkdir -p "${PKG_DIR}" cp "src/target/release/bread-theme" "${PKG_DIR}/bread-theme-x86_64" @@ -39,7 +66,6 @@ jobs: MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} run: | set -euo pipefail - VERSION="${GITHUB_REF_NAME#beta-v}" PKG_DIR="/srv/breadway-dl/beta/bread-theme/${VERSION}" if [ -n "${MINISIGN_SEC_KEY:-}" ]; then minisign -W -S -s "${MINISIGN_SEC_KEY}" -m "${PKG_DIR}/bread-theme-x86_64" \ From 0425c642147acd68b1c633e3839680edb6274dba Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 18:53:16 +0800 Subject: [PATCH 19/99] docs: document the dev/beta/main release lifecycle CLAUDE.md and docs/release-channels.md now describe the full cycle: work lands on feature/fix branches merged into dev, dev auto-publishes on every push, beta is cut from dev as a frozen stabilization branch (also auto-publishing on every push, fixes forwarded from fix/ branches), and after a quiet freeze period beta merges to main and gets tagged for the actual stable release. Also fixes README's stale reference to .github/workflows (actual CI lives under .forgejo/workflows) and links out to the new CONTRIBUTING.md. --- CLAUDE.md | 20 +++++++++++++------ README.md | 10 ++++++++-- docs/release-channels.md | 43 ++++++++++++++++++++++++++++++---------- 3 files changed, 55 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 261735d..20d1900 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,19 +1,27 @@ -# CLAUDE.md — Repo hygiene (local only, not committed) +# CLAUDE.md — Repo hygiene Scope: this file covers *repo hygiene* — branching, remotes, CI, cleanup. It is not project documentation. ## Branch model -- `main` — release branch, always tag-ready. Don't commit directly to it. -- `dev` — integration branch. Land day-to-day work here first. -- Feature/fix work goes on short-lived branches off `dev` (`feature/x`, `fix/x`), merged back into `dev`, then `dev` → `main` when ready to release. +- `main` — release branch, always tag-ready. Don't commit directly to it; the only thing that lands there is a `beta` merge (see release lifecycle below). +- `dev` — integration branch. Land day-to-day work here first. Publishes a dev-track build automatically on every push (see CI below) — this is the "push, test, fix forward with another push" loop. +- `beta` — frozen stabilization branch, cut from `dev`. Publishes a beta-track build automatically on every push, same as `dev`. While frozen, only `fix/` branches merged directly into `beta` should land there — `dev` keeps moving independently for the next cycle. +- All new work — features and bug fixes alike — goes on short-lived branches: `feature/` or `fix/`. Normally these branch off `dev` and merge back into `dev`. During a beta freeze, a fix for a beta-reported issue branches off `beta` instead, merges into `beta` to unblock testers, and should also be cherry-picked/merged into `dev` so the bug doesn't quietly regress there. + +## Release lifecycle +1. Work lands on `dev` via `feature/x` / `fix/x` branches. Every push to `dev` auto-publishes a dev-track build (`bakery track set dev`) — test it, fix issues with another push to `dev`. +2. Once `dev` has gone roughly **a week** without new issues, cut `beta` fresh from `dev`'s current tip: `git branch -f beta dev` (from a clean checkout — don't `git checkout main`/`git merge` for this, use a plain branch-pointer move), then force-push `beta` to both remotes. This freezes it. +3. `beta` auto-publishes on every push, same as `dev`. Anyone can file issues against it on Forgejo. Fixes land via `fix/` → `beta` (and should be forwarded into `dev` too). +4. Once `beta` has gone roughly **a month** without new issues, merge `beta` → `main`, then push a `vX.Y.Z` tag from `main` to actually cut the stable release (the merge alone triggers no CI — only the tag does). Reset `beta` fresh from `dev` again to start the next cycle. ## Remotes - `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative. - `github` — GitHub mirror. Push both when publishing. ## CI -- `.forgejo/workflows/package.yml`, `release-bakery.yml`, `release-bread-theme.yml` all trigger only on `push: tags: ['v*']` — pushing to `dev` or `main` runs nothing. Tag a release to trigger packaging. -- No build/lint/test CI runs on ordinary commits or PRs — test locally before merging to `dev`/`main`. +- `.forgejo/workflows/package.yml`, `release-bakery.yml`, `release-bread-theme.yml` all trigger only on `push: tags: ['v*']` — pushing to `dev`, `beta`, or `main` doesn't run these. Tag a release to trigger packaging. +- `dev-bakery.yml` / `dev-bread-theme.yml` trigger on `push: branches: ['dev']`; `beta-bakery.yml` / `beta-bread-theme.yml` trigger on `push: branches: ['beta']` — both auto-publish a signed, auto-versioned build to `dl.breadway.dev/{dev,beta}/`. See `docs/release-channels.md` for the full three-track (stable/beta/dev) policy. +- No build/lint/test CI runs on ordinary commits or PRs to `dev`/`beta` beyond what those track workflows do — there's no separate lint/PR-check pipeline. ## Cleanup - Delete feature/fix branches (local + remote) once merged. Check with `git branch --merged dev` / `git branch --merged main`. diff --git a/README.md b/README.md index fff6340..22c8ceb 100644 --- a/README.md +++ b/README.md @@ -123,8 +123,8 @@ bread-ecosystem/ ## Release pipeline -Each product repo (`Breadway/bread`, `Breadway/breadbar`, …) has a -`.github/workflows/release.yml` that triggers on `v*` tags. The workflow +Each product repo (`Breadway/bread`, `Breadway/breadbar`, …) has +`.forgejo/workflows/release-*.yml` that triggers on `v*` tags. The workflow runs on a self-hosted runner on hestia, builds a stripped x86_64 binary, deposits it at `dl.breadway.dev///`, updates `index.json`, and mirrors the binary to GitHub Releases as a fallback. @@ -132,6 +132,12 @@ and mirrors the binary to GitHub Releases as a fallback. `bakery` always tries `dl.breadway.dev` first and transparently falls back 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. +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 `. + ### Release artifact contract Each product's `release.yml` **must** upload the following files alongside diff --git a/docs/release-channels.md b/docs/release-channels.md index d717ff4..aa71900 100644 --- a/docs/release-channels.md +++ b/docs/release-channels.md @@ -54,31 +54,54 @@ should never carry a `bakery.toml` or `PKGBUILD`. Within the **bakery channel only**, a repo can additionally publish up to three **tracks**: `stable` (the existing tag-triggered `v*` flow, unchanged), -`beta` (a deliberate promotion triggered by a `beta-v*` tag), and `dev` -(published automatically on every push to the `dev` branch). Don't confuse -"track" with "channel" above — channel is *how* a binary reaches a user -(bakery vs. pacman); track is *which build* of a bakery-channel package they -get. +`beta` (a frozen stabilization branch), and `dev` (published automatically on +every push to the `dev` branch). Don't confuse "track" with "channel" above — +channel is *how* a binary reaches a user (bakery vs. pacman); track is *which +build* of a bakery-channel package they get. Each track lives in its own subtree so they never collide: | Track | Index URL | Artifact root | Trigger | |---|---|---|---| -| stable | `dl.breadway.dev/index.json` | `/srv/breadway-dl///` | push tag `v*` | -| beta | `dl.breadway.dev/beta/index.json` | `/srv/breadway-dl/beta///` | push tag `beta-v*` | +| stable | `dl.breadway.dev/index.json` | `/srv/breadway-dl///` | push tag `v*` on `main` | +| beta | `dl.breadway.dev/beta/index.json` | `/srv/breadway-dl/beta///` | push to branch `beta` | | dev | `dl.breadway.dev/dev/index.json` | `/srv/breadway-dl/dev///` | push to branch `dev` | `scripts/gen-index.sh` takes a `TRACK` env var (default `stable`) to select which subtree it reads/writes — every existing stable release workflow needs zero changes. Dev/beta builds skip the GitHub Release upload step entirely -(no release-per-commit spam for dev, and beta doesn't need a GitHub mirror -either) — `dl.breadway.dev` is their only distribution point. +(no release-per-commit spam, and beta doesn't need a GitHub mirror either) — +`dl.breadway.dev` is their only distribution point. + +**The full branch lifecycle** (see also `CLAUDE.md`'s Branch model section): +day-to-day work lands on `feature/` or `fix/` branches, merged +into `dev`. `dev` publishes a fresh dev-track build on every push — this is +the "test for a while, fix forward with another push" loop. When `dev` has +gone roughly a week without new issues, cut `beta` fresh from `dev`'s current +tip (`git branch -f beta dev` from a clean checkout, then force-push) — this +freezes it as the stabilization target. `beta` publishes on every push the +same way `dev` does, but only `fix/` branches merged directly into +`beta` should land there afterward; `dev` keeps moving independently for the +next cycle. After roughly a month of `beta` going without new issues, merge +`beta` into `main` and push a `vX.Y.Z` tag from `main` to cut the actual +stable release (the merge itself triggers nothing — tag-push is what fires +`release.yml`). Reset `beta` fresh from `dev` again to start the next cycle. + +Auto-versioning: both `dev` and `beta` compute their build version from the +latest published `vX.Y.Z` tag (via `git ls-remote --tags`, not `Cargo.toml` — +`Cargo.toml` can drift stale relative to the actual last release) plus a +`-dev.+` / `-beta.+` suffix. This is +self-healing regardless of `Cargo.toml` drift and keeps `bakery`'s semver +check (`is_newer`) meaningful — it will correctly refuse to "update" to a +build that isn't actually newer than what's installed. Adding beta/dev to a bakery-channel repo: copy `dev-bakery.yml` / `beta-bakery.yml` (or `bread`'s `dev-release.yml` / `beta-release.yml` if the repo isn't part of this monorepo) from `bread-ecosystem`/`bread`, and swap the repo/binary names the same way the checklist below describes for -`release.yml`. Not every bakery-channel repo needs beta/dev on day one — +`release.yml`. Also create the repo's `dev` and `beta` branches if they don't +exist yet (`git checkout -b dev main` / `git checkout -b beta dev`, push +both). Not every bakery-channel repo needs beta/dev on day one — `gen-index.sh` silently skips any product with no release dir under a given track's tree, same as it already does for an unreleased product on stable. From 02aeb0406a952940a5d79308b0d1997c0aa32440 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 19:39:31 +0800 Subject: [PATCH 20/99] docs: add CONTRIBUTING.md, point CLAUDE.md at it, fix stale track table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md now points to CONTRIBUTING.md as the canonical workflow doc instead of duplicating the release lifecycle inline. docs/release-channels.md's current-state table was stale — it still said beta/dev wasn't rolled out to the sibling repos, which is now done. --- CLAUDE.md | 21 +++++---- CONTRIBUTING.md | 95 ++++++++++++++++++++++++++++++++++++++++ docs/release-channels.md | 5 +-- 3 files changed, 107 insertions(+), 14 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/CLAUDE.md b/CLAUDE.md index 20d1900..17810c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,17 +2,16 @@ Scope: this file covers *repo hygiene* — branching, remotes, CI, cleanup. It is not project documentation. -## Branch model -- `main` — release branch, always tag-ready. Don't commit directly to it; the only thing that lands there is a `beta` merge (see release lifecycle below). -- `dev` — integration branch. Land day-to-day work here first. Publishes a dev-track build automatically on every push (see CI below) — this is the "push, test, fix forward with another push" loop. -- `beta` — frozen stabilization branch, cut from `dev`. Publishes a beta-track build automatically on every push, same as `dev`. While frozen, only `fix/` branches merged directly into `beta` should land there — `dev` keeps moving independently for the next cycle. -- All new work — features and bug fixes alike — goes on short-lived branches: `feature/` or `fix/`. Normally these branch off `dev` and merge back into `dev`. During a beta freeze, a fix for a beta-reported issue branches off `beta` instead, merges into `beta` to unblock testers, and should also be cherry-picked/merged into `dev` so the bug doesn't quietly regress there. - -## Release lifecycle -1. Work lands on `dev` via `feature/x` / `fix/x` branches. Every push to `dev` auto-publishes a dev-track build (`bakery track set dev`) — test it, fix issues with another push to `dev`. -2. Once `dev` has gone roughly **a week** without new issues, cut `beta` fresh from `dev`'s current tip: `git branch -f beta dev` (from a clean checkout — don't `git checkout main`/`git merge` for this, use a plain branch-pointer move), then force-push `beta` to both remotes. This freezes it. -3. `beta` auto-publishes on every push, same as `dev`. Anyone can file issues against it on Forgejo. Fixes land via `fix/` → `beta` (and should be forwarded into `dev` too). -4. Once `beta` has gone roughly **a month** without new issues, merge `beta` → `main`, then push a `vX.Y.Z` tag from `main` to actually cut the stable release (the merge alone triggers no CI — only the tag does). Reset `beta` fresh from `dev` again to start the next cycle. +This repo follows the branch/release workflow documented in `CONTRIBUTING.md` +— read and follow it for any git, branch, or release work here (the +dev/beta/main lifecycle, `feature/x`/`fix/x` branch naming, when to cut or +reset `beta`, etc). Don't improvise a different workflow. The short version: +`main` is tag-ready and only moves via a `beta` merge; `dev` and `beta` both +auto-publish a build on every push (dev-track / beta-track respectively); +`beta` is a frozen stabilization branch cut from `dev` roughly weekly and +promoted to `main` roughly monthly. `git branch -f beta dev` (plain +branch-pointer move) is how `beta` gets reset — never `git checkout +main`/`git merge` for this. ## Remotes - `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6087898 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,95 @@ +# 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. + +## Branches + +- **`main`** — release branch, always tag-ready. Nothing is committed to it + directly; it only moves forward via a `beta` merge (see below). +- **`dev`** — integration branch. All day-to-day work lands here first. + Every push to `dev` automatically builds and publishes a **dev-track** + build (see Tracks below) — use this to test your change in a real install + before it goes any further. +- **`beta`** — a frozen stabilization branch, cut from `dev` periodically. + Every push to `beta` automatically builds and publishes a **beta-track** + build. While a freeze is active, only fixes for issues found *in that + freeze* should land on `beta`. + +New work — features and bug fixes alike — goes on a short-lived branch: + +``` +feature/ +fix/ +``` + +Branch off `dev`, open a PR/push back into `dev` when ready. If you're fixing +something reported against an active `beta` freeze, branch off `beta` +instead, merge the fix there to unblock testers, and also forward the same +fix into `dev` so it doesn't quietly reappear next cycle. + +## The release cycle + +1. Work accumulates on `dev` via `feature/x` / `fix/x` branches. Each push + auto-publishes a dev build — install it with `bakery track set dev` and + `bakery update --all`, then report or fix anything broken with another + push to `dev`. +2. Once `dev` has gone roughly **a week** without new issues, `beta` is cut + fresh from `dev`'s current tip. This freezes it as the stabilization + target — `dev` keeps moving independently starting the next cycle. +3. `beta` is open for anyone to test: `bakery track set beta` and + `bakery update --all`. **File issues against anything you find on this + repo's Forgejo issue tracker.** Fixes land via `fix/` branches + merged into `beta`. +4. Once `beta` has gone roughly **a month** without new issues, it's merged + into `main` and tagged `vX.Y.Z` — that tag is what actually triggers the + stable release build. `beta` is then reset from `dev` to start the next + cycle. + +## Tracks, from a user's perspective + +``` +bakery track show # what you're currently on (defaults to stable) +bakery track set dev # or beta, or stable +bakery update --all # pull the latest build on your current track +``` + +| Track | What it is | Published from | +|--------|-----------|-----------------| +| `stable` | The last tagged release | `main`, on a `vX.Y.Z` tag push | +| `beta` | Current stabilization freeze | `beta`, on every push | +| `dev` | Bleeding edge | `dev`, on every push | + +Dev/beta versions are auto-computed (`X.Y.Z-dev.+` / +`-beta.…`) from the latest published stable tag, so they always sort as +newer than what you have installed — no manual version bumping needed when +pushing to `dev` or `beta`. + +## Local development + +```sh +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. + +## CI + +- `dev-bakery.yml` / `dev-bread-theme.yml` — triggered on push to `dev`. +- `beta-bakery.yml` / `beta-bread-theme.yml` — triggered on push to `beta`. +- `release-bakery.yml` / `release-bread-theme.yml` — triggered on a `v*` tag + push, cuts the actual stable release. +- `package.yml` — publishes to the `[breadway]` pacman repo, also tag-triggered. + +All CI runs on a self-hosted runner; nothing runs automatically on plain +commits or PRs beyond the track builds above. See +[`docs/release-channels.md`](docs/release-channels.md) for the full policy, +including how a new product gets wired onto these tracks. + +## Questions + +Open an issue on this repo's Forgejo tracker. diff --git a/docs/release-channels.md b/docs/release-channels.md index aa71900..019ecf3 100644 --- a/docs/release-channels.md +++ b/docs/release-channels.md @@ -147,9 +147,8 @@ missing it; that gap is intentional and about to be moot everywhere. |---|---|---|---|---| | bread-ecosystem (bakery product) | yes | yes | stable, beta, dev | `release-bakery.yml` recovered from a dead `.github/workflows/release.yml` that referenced a `hestia` self-hosted runner GitHub never had registered | | bread-ecosystem (bread-theme product) | yes | no | stable, beta, dev | | -| bread | yes | yes | stable, beta, dev | pilot repo for the beta/dev track rollout | -| breadbar, breadbox, breadcrumbs, breadpad, breadpaper | yes | yes | stable only | complete, used as templates; not yet rolled out to beta/dev | -| breadclip, breadmon, breadsearch, breadshot | yes | no | stable only | complete | +| bread, breadbar, breadbox, breadcrumbs, breadpad, breadpaper | yes | yes | stable, beta, dev | complete on all three tracks | +| breadclip, breadmon, breadsearch, breadshot | yes | no | stable, beta, dev | complete on all three tracks | | breadlock, breadhelp | no | yes | n/a | breadlock's `bakery.toml` was removed as orphaned; its README wrongly claimed it was a registry entry | | bos-settings | yes | yes | stable only | was missing both the registry entry and `release.yml`; both added | | bos | no | no | n/a | ISO-only via `release-iso.yml`; had an erroneous `bakery.toml` copy-pasted from bos-settings, removed | From 5afe12d70f644845b311875995bae7c4d98fd5f7 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 19:52:56 +0800 Subject: [PATCH 21/99] Will change this commit message to mean something later --- bread-utils/src/bread_client.rs | 52 +++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/bread-utils/src/bread_client.rs b/bread-utils/src/bread_client.rs index 3f4adcf..9976fc6 100644 --- a/bread-utils/src/bread_client.rs +++ b/bread-utils/src/bread_client.rs @@ -115,6 +115,50 @@ impl BreadClient { let _ = writeln!(stream, "{line}"); } + /// Send a one-shot IPC request and return its `result`, or `None` on any + /// failure — breadd unreachable, a malformed response, or an `error` + /// field in the response. Mirrors `emit`'s graceful-degradation stance: + /// a caller checks for `None` the same way it'd handle "daemon not + /// installed," not via a `Result` that forces error-path plumbing for + /// what is, for most callers (a refresh-on-connect read), an expected + /// possibility rather than an exceptional one. + /// + /// Unlike `emit`, this is not restricted to the client's own namespace — + /// `method`/`params` map directly onto breadd's IPC method table (see + /// `Documentation.md`'s "Dictionary: IPC protocol"), most of which + /// (`state.get`, `widgets.list`, ...) are cross-namespace reads by + /// design. Only first-party compiled code links `bread-utils`, so this + /// carries the same trust level as `emit`'s own request construction. + pub fn request(&self, method: &str, params: Value) -> Option { + let request = json!({ + "id": "0", + "method": method, + "params": params, + }); + let line = serde_json::to_string(&request).ok()?; + + let mut stream = UnixStream::connect(bread_shared::resolve_socket_path()).ok()?; + stream + .set_write_timeout(Some(Duration::from_millis(200))) + .ok()?; + stream + .set_read_timeout(Some(Duration::from_millis(500))) + .ok()?; + writeln!(stream, "{line}").ok()?; + + let mut response_line = String::new(); + BufReader::new(stream).read_line(&mut response_line).ok()?; + if response_line.trim().is_empty() { + return None; + } + + let value: Value = serde_json::from_str(&response_line).ok()?; + if value.get("error").is_some() { + return None; + } + value.get("result").cloned() + } + /// Subscribe to events matching `pattern` (glob: `*`/`**`/`?`), invoking /// `on_event` for each one on a dedicated background thread. Typically /// called with `"bread.command..**"` to receive commands @@ -290,6 +334,14 @@ mod tests { // integration tests for the IPC-side of namespace validation. } + #[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. + let client = BreadClient::connect("clip"); + assert!(client.request("widgets.list", json!(null)).is_none()); + } + #[test] fn subscription_stop_joins_the_background_thread() { let client = BreadClient::connect("clip"); From c7abfae6307bc89712f17d36ddd97e1e81f388d3 Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 23 Jul 2026 10:15:13 +0800 Subject: [PATCH 22/99] bakery: add license_file/desktop_file/data_archive manifest fields Closes the packaging gap found while moving bread-ecosystem apps off pacman onto bakery-only distribution: pacman's package() typically installs a LICENSE file and, for GUI/onboarding apps, a .desktop entry and sometimes a data directory (e.g. breadhelp's guide content). All three follow the same download-verify-place pattern ConfigScaffold.example already established: - license_file -> ~/.local/share/licenses//LICENSE - desktop_file -> ~/.local/share/applications/.desktop - data_archive -> a .tar.gz extracted to ~/.local/share// (for arbitrary data too big/structured for a single file, via `tar`) gen-index.sh parses all three from bakery.toml, hashes the artifact, and now excludes them from the binaries-collection loop (previously undetected gap: they'd have been swept in as fake "binaries" with no checksum, same class of bug the existing .toml/.service/etc exclusions guard against). Also registers breadhelp as a bakery-channel product. --- bakery/src/install.rs | 304 +++++++++++++++++++++++++++++++++- bakery/src/manifest.rs | 61 +++++++ registry/bread-ecosystem.toml | 5 + scripts/gen-index.sh | 105 ++++++++++-- 4 files changed, 459 insertions(+), 16 deletions(-) diff --git a/bakery/src/install.rs b/bakery/src/install.rs index 3b4c925..e6ab26e 100644 --- a/bakery/src/install.rs +++ b/bakery/src/install.rs @@ -23,19 +23,34 @@ pub fn install_package(pkg: &Package, bin_dir: &Path) -> Result<()> { scaffold_config(cfg, pkg)?; } - // 3. Install systemd user units. + // 3. Install license file, if declared. + if let Some(license) = &pkg.license_file { + install_license(pkg, license)?; + } + + // 4. Install desktop entry, if declared. + if let Some(desktop) = &pkg.desktop_file { + install_desktop_file(pkg, desktop)?; + } + + // 5. Download + extract data archive, if declared. + if let Some(archive) = &pkg.data_archive { + install_data_archive(pkg, archive)?; + } + + // 6. Install systemd user units. let mut service_names = Vec::new(); for svc in &pkg.services { install_service(svc, bin_dir, pkg)?; service_names.push(svc.unit.clone()); } - // 4. Run post_install hooks. + // 7. Run post_install hooks. for cmd in &pkg.post_install { run_hook(cmd, &pkg.name)?; } - // 5. Record in state. + // 8. Record in state. let mut state = State::load()?; state.record(InstalledPackage { name: pkg.name.clone(), @@ -158,6 +173,110 @@ fn scaffold_config(cfg: &crate::manifest::ConfigScaffold, pkg: &Package) -> Resu Ok(()) } +/// Download `filename` from `pkg`'s release dir, verify it against `sha256` +/// (refusing an unverified download the same way `scaffold_config` does), +/// and write it to `dest`. Shared by `install_license`/`install_desktop_file` +/// since both are "fetch one small artifact, verify, place" — unlike a config +/// example, these aren't user-editable, so they're always refreshed rather +/// than skipped when already present. +fn fetch_verify_write( + pkg: &Package, + filename: &str, + sha256: &Option, + dest: &Path, + label: &str, +) -> Result<()> { + let Some((primary, fallback)) = pkg.artifact_urls(filename) else { + eprintln!(" warning: no artifact URL to download {label} ({filename})"); + return Ok(()); + }; + let bytes = match fetch_binary(&primary, &fallback) { + Ok(b) => b, + Err(e) => { + eprintln!(" warning: could not download {label} {filename}: {e}"); + return Ok(()); + } + }; + let Some(expected) = sha256 else { + eprintln!( + " warning: index.json has no sha256 for {label} {filename} — \ + refusing to install an unverified download" + ); + return Ok(()); + }; + if let Err(e) = verify_sha256(&bytes, expected) { + eprintln!(" warning: checksum mismatch for {label} {filename}: {e} — not installed"); + return Ok(()); + } + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(dest, &bytes).with_context(|| format!("writing {}", dest.display()))?; + println!(" installed {label} at {}", dest.display()); + Ok(()) +} + +fn install_license(pkg: &Package, filename: &str) -> Result<()> { + let dest = dirs::data_dir() + .unwrap_or_else(|| PathBuf::from("~/.local/share")) + .join("licenses") + .join(&pkg.name) + .join("LICENSE"); + fetch_verify_write(pkg, filename, &pkg.license_file_sha256, &dest, "license") +} + +fn install_desktop_file(pkg: &Package, filename: &str) -> Result<()> { + let dest = dirs::data_dir() + .unwrap_or_else(|| PathBuf::from("~/.local/share")) + .join("applications") + .join(format!("{}.desktop", pkg.name)); + fetch_verify_write(pkg, filename, &pkg.desktop_file_sha256, &dest, "desktop entry") +} + +fn install_data_archive(pkg: &Package, filename: &str) -> Result<()> { + let data_dir = dirs::data_dir() + .unwrap_or_else(|| PathBuf::from("~/.local/share")) + .join(&pkg.name); + fetch_extract_archive(pkg, filename, &pkg.data_archive_sha256, &data_dir) +} + +/// Downloads + verifies a `.tar.gz` artifact, then extracts it into +/// `dest_dir`. Shells out to `tar` rather than adding an archive-extraction +/// crate dependency — `tar` is universally present on Linux and this file +/// already shells out to `systemctl` for the same "trust the base system +/// has this" reason. Split from `install_data_archive` (which just supplies +/// the real `~/.local/share/` destination) so tests can extract into +/// a tempdir instead. +fn fetch_extract_archive( + pkg: &Package, + filename: &str, + sha256: &Option, + dest_dir: &Path, +) -> Result<()> { + let tmp_archive = std::env::temp_dir().join(format!("bakery-{}-{filename}", pkg.name)); + + fetch_verify_write(pkg, filename, sha256, &tmp_archive, "data archive")?; + if !tmp_archive.exists() { + // fetch_verify_write already warned (download/checksum failure). + return Ok(()); + } + + std::fs::create_dir_all(dest_dir)?; + let status = Command::new("tar") + .args(["xzf", &tmp_archive.to_string_lossy(), "-C"]) + .arg(dest_dir) + .status() + .with_context(|| format!("running tar to extract {filename}"))?; + let _ = std::fs::remove_file(&tmp_archive); + + if status.success() { + println!(" extracted {filename} to {}", dest_dir.display()); + } else { + eprintln!(" warning: tar exited with {status} extracting {filename}"); + } + Ok(()) +} + fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> { let service_dir = systemd_user_dir(); std::fs::create_dir_all(&service_dir)?; @@ -343,9 +462,188 @@ fn warn_path_if_needed(bin_dir: &Path) { #[cfg(test)] mod tests { use super::*; + use crate::manifest::{Binary, Package}; + use sha2::Digest; use std::fs; + use std::io::{Read, Write}; + use std::net::TcpListener; use tempfile::tempdir; + /// Serves `body` for exactly one HTTP/1.0 request on an ephemeral local + /// port, then stops. Real network I/O over loopback — exercises + /// `fetch_verify_write`'s actual `fetch_binary` call, not just its + /// surrounding logic, without any new test dependency. + fn serve_once(body: &'static [u8]) -> String { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let mut buf = [0u8; 1024]; + let _ = stream.read(&mut buf); + let response = format!( + "HTTP/1.0 200 OK\r\nContent-Length: {}\r\n\r\n", + body.len() + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.write_all(body); + } + }); + format!("http://{addr}") + } + + /// Same as `serve_once` but for a runtime-owned body (e.g. a tar.gz + /// built into a tempdir during the test), which can't satisfy `'static`. + fn serve_once_owned(body: Vec) -> String { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let mut buf = [0u8; 1024]; + let _ = stream.read(&mut buf); + let response = format!( + "HTTP/1.0 200 OK\r\nContent-Length: {}\r\n\r\n", + body.len() + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.write_all(&body); + } + }); + format!("http://{addr}") + } + + fn test_package(binary_url: &str) -> Package { + Package { + name: "breadhelp".to_string(), + description: "test".to_string(), + version: "1.0.0".to_string(), + binaries: vec![Binary { + name: "breadhelp-x86_64".to_string(), + dl_url: format!("{binary_url}/breadhelp-x86_64"), + github_url: format!("{binary_url}/breadhelp-x86_64"), + sha256: String::new(), + }], + system_deps: vec![], + optional_system_deps: vec![], + bread_deps: vec![], + services: vec![], + config: None, + post_install: vec![], + license_file: None, + license_file_sha256: None, + desktop_file: None, + desktop_file_sha256: None, + data_archive: None, + data_archive_sha256: None, + } + } + + #[test] + fn install_license_writes_verified_file() { + let license_bytes = b"MIT License\n"; + let sha256 = sha2::Sha256::digest(license_bytes); + let sha256_hex = hex::encode(sha256); + + let base_url = serve_once(license_bytes); + let mut pkg = test_package(&base_url); + pkg.license_file_sha256 = Some(sha256_hex); + + let dir = tempdir().unwrap(); + let dest = dir.path().join("LICENSE"); + fetch_verify_write(&pkg, "LICENSE", &pkg.license_file_sha256.clone(), &dest, "license") + .unwrap(); + + assert_eq!(fs::read(&dest).unwrap(), license_bytes); + } + + #[test] + fn install_desktop_file_writes_verified_file() { + let desktop_bytes = b"[Desktop Entry]\nName=BreadHelp\n"; + let sha256 = sha2::Sha256::digest(desktop_bytes); + let sha256_hex = hex::encode(sha256); + + let base_url = serve_once(desktop_bytes); + let mut pkg = test_package(&base_url); + pkg.desktop_file_sha256 = Some(sha256_hex); + + let dir = tempdir().unwrap(); + let dest = dir.path().join("breadhelp.desktop"); + fetch_verify_write( + &pkg, + "breadhelp.desktop", + &pkg.desktop_file_sha256.clone(), + &dest, + "desktop entry", + ) + .unwrap(); + + assert_eq!(fs::read(&dest).unwrap(), desktop_bytes); + } + + #[test] + fn fetch_verify_write_refuses_checksum_mismatch() { + let bytes = b"tampered content"; + let base_url = serve_once(bytes); + let mut pkg = test_package(&base_url); + pkg.license_file_sha256 = Some("0".repeat(64)); + + let dir = tempdir().unwrap(); + let dest = dir.path().join("LICENSE"); + fetch_verify_write(&pkg, "LICENSE", &pkg.license_file_sha256.clone(), &dest, "license") + .unwrap(); + + // Refused, not erred (matches scaffold_config's warn-and-continue + // posture) — the file must not have been written. + assert!(!dest.exists()); + } + + #[test] + fn fetch_verify_write_refuses_missing_sha256() { + let bytes = b"some content"; + let base_url = serve_once(bytes); + let pkg = test_package(&base_url); + + let dir = tempdir().unwrap(); + let dest = dir.path().join("LICENSE"); + fetch_verify_write(&pkg, "LICENSE", &None, &dest, "license").unwrap(); + + assert!(!dest.exists()); + } + + #[test] + fn fetch_extract_archive_extracts_tar_gz_contents() { + // Build a real tar.gz fixture via the actual `tar` binary — matches + // exactly what CI produces, rather than hand-rolling gzip framing. + let src = tempdir().unwrap(); + fs::create_dir_all(src.path().join("content/tours")).unwrap(); + fs::write( + src.path().join("content/tours/onboarding.toml"), + b"[[step]]\n", + ) + .unwrap(); + let archive_path = src.path().join("content.tar.gz"); + let status = Command::new("tar") + .args(["czf"]) + .arg(&archive_path) + .args(["-C"]) + .arg(src.path()) + .arg("content") + .status() + .unwrap(); + assert!(status.success()); + let archive_bytes = fs::read(&archive_path).unwrap(); + + let sha256_hex = hex::encode(sha2::Sha256::digest(&archive_bytes)); + let base_url = serve_once_owned(archive_bytes); + let pkg = test_package(&base_url); + + let dest_dir = tempdir().unwrap(); + fetch_extract_archive(&pkg, "content.tar.gz", &Some(sha256_hex), dest_dir.path()) + .unwrap(); + + let extracted = dest_dir.path().join("content/tours/onboarding.toml"); + assert_eq!(fs::read(&extracted).unwrap(), b"[[step]]\n"); + } + #[test] fn strip_known_suffixes() { assert_eq!(strip_arch_suffix("breadd-x86_64"), "breadd"); diff --git a/bakery/src/manifest.rs b/bakery/src/manifest.rs index 248b715..c8541d5 100644 --- a/bakery/src/manifest.rs +++ b/bakery/src/manifest.rs @@ -107,6 +107,30 @@ pub struct Package { pub config: Option, #[serde(default)] pub post_install: Vec, + /// License artifact filename (e.g. "LICENSE"), installed to + /// `~/.local/share/licenses//LICENSE` — the bakery equivalent of + /// what a PKGBUILD's `package()` does with `/usr/share/licenses`. + #[serde(default)] + pub license_file: Option, + #[serde(default)] + pub license_file_sha256: Option, + /// Desktop entry artifact filename (e.g. "breadhelp.desktop"), + /// installed to `~/.local/share/applications/.desktop` so the + /// app shows up in any XDG-compliant launcher without root. + #[serde(default)] + pub desktop_file: Option, + #[serde(default)] + pub desktop_file_sha256: Option, + /// Data archive artifact filename (e.g. "content.tar.gz") — a `.tar.gz` + /// in the release dir, extracted to `~/.local/share//` on + /// install. For arbitrary data a package needs at runtime beyond a + /// config example (e.g. breadhelp's guide content), where a single + /// downloadable file + `tar` extraction is simpler than teaching + /// bakery to mirror a whole directory tree file-by-file. + #[serde(default)] + pub data_archive: Option, + #[serde(default)] + pub data_archive_sha256: Option, } impl Package { @@ -350,4 +374,41 @@ znmVfINB4jFDR2a4wuY8rOKlUBeSDOFjMkHYDXV3vxvAjK+r4V12ae9ZRQkfVtQ1YIEmFXbnJfbxywg+ assert_eq!(primary_url(Track::Beta), format!("{}/beta/index.json", base_url())); assert_eq!(primary_url(Track::Dev), format!("{}/dev/index.json", base_url())); } + + fn minimal_package_json() -> &'static str { + r#"{ + "name": "breadhelp", + "description": "test", + "version": "1.0.0", + "binaries": [], + "config": null + }"# + } + + #[test] + fn license_and_desktop_fields_default_to_none_on_old_shape_json() { + // Simulates an index.json produced before license_file/desktop_file + // existed — must not fail to parse. + let pkg: Package = serde_json::from_str(minimal_package_json()).unwrap(); + assert!(pkg.license_file.is_none()); + assert!(pkg.license_file_sha256.is_none()); + assert!(pkg.desktop_file.is_none()); + assert!(pkg.desktop_file_sha256.is_none()); + } + + #[test] + fn license_and_desktop_fields_roundtrip() { + let mut pkg: Package = serde_json::from_str(minimal_package_json()).unwrap(); + pkg.license_file = Some("LICENSE".to_string()); + pkg.license_file_sha256 = Some("abc123".to_string()); + pkg.desktop_file = Some("breadhelp.desktop".to_string()); + pkg.desktop_file_sha256 = Some("def456".to_string()); + + let json = serde_json::to_string(&pkg).unwrap(); + let restored: Package = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.license_file.as_deref(), Some("LICENSE")); + assert_eq!(restored.license_file_sha256.as_deref(), Some("abc123")); + assert_eq!(restored.desktop_file.as_deref(), Some("breadhelp.desktop")); + assert_eq!(restored.desktop_file_sha256.as_deref(), Some("def456")); + } } diff --git a/registry/bread-ecosystem.toml b/registry/bread-ecosystem.toml index 4620361..b6d0287 100644 --- a/registry/bread-ecosystem.toml +++ b/registry/bread-ecosystem.toml @@ -71,3 +71,8 @@ description = "Screenshot utility for the bread ecosystem" name = "bos-settings" repo = "Breadway/bos-settings" description = "System settings app for Bread OS" + +[[products]] +name = "breadhelp" +repo = "Breadway/breadhelp" +description = "Onboarding and help center for Bread OS" diff --git a/scripts/gen-index.sh b/scripts/gen-index.sh index 05b0c20..29866df 100755 --- a/scripts/gen-index.sh +++ b/scripts/gen-index.sh @@ -67,6 +67,41 @@ build_package_json() { local version version="$(basename "${version_dir}")" + # Locate bakery.toml. The release workflow copies it into the version dir + # alongside the binaries (${version_dir}/bakery.toml). Fall back to a + # sibling repo checkout for local dev use. Done before the binaries loop + # below so license_file/desktop_file (if declared) can be excluded from + # it by name — otherwise they'd get swept up as "binaries" with no + # checksum, the same gotcha this loop's other exclusions guard against. + local bakery_toml="${version_dir}/bakery.toml" + if [[ ! -f "${bakery_toml}" ]]; then + bakery_toml="${SCRIPT_DIR}/../${name}/bakery.toml" + fi + if [[ ! -f "${bakery_toml}" ]]; then + echo "ERROR: bakery.toml not found for ${name} — the release workflow must copy it to \${PKG_ROOT}/${name}/\${VERSION}/bakery.toml" >&2 + return 1 + fi + + local license_file_name desktop_file_name data_archive_name + license_file_name="$(python3 -c " +import tomllib +with open('${bakery_toml}', 'rb') as f: + d = tomllib.load(f) +print(d.get('license_file', '')) +" 2>/dev/null || true)" + desktop_file_name="$(python3 -c " +import tomllib +with open('${bakery_toml}', 'rb') as f: + d = tomllib.load(f) +print(d.get('desktop_file', '')) +" 2>/dev/null || true)" + data_archive_name="$(python3 -c " +import tomllib +with open('${bakery_toml}', 'rb') as f: + d = tomllib.load(f) +print(d.get('data_archive', '')) +" 2>/dev/null || true)" + # Collect all binaries in the version dir (executables only; skip metadata files). local binaries_json="[]" for bin_path in "${version_dir}"/*; do @@ -76,6 +111,9 @@ build_package_json() { [[ "${bin_path}" == *.css ]] && continue [[ "${bin_path}" == *.txt ]] && continue [[ "${bin_path}" == *.minisig ]] && continue + [[ -n "${license_file_name}" && "${bin_path}" == "${version_dir}/${license_file_name}" ]] && continue + [[ -n "${desktop_file_name}" && "${bin_path}" == "${version_dir}/${desktop_file_name}" ]] && continue + [[ -n "${data_archive_name}" && "${bin_path}" == "${version_dir}/${data_archive_name}" ]] && continue [[ -f "${bin_path}" ]] || continue local bin_name bin_name="$(basename "${bin_path}")" @@ -106,18 +144,6 @@ build_package_json() { binaries_json="$(jq -n --argjson arr "${binaries_json}" --argjson e "${entry}" '$arr + [$e]')" done - # Locate bakery.toml. The release workflow copies it into the version dir - # alongside the binaries (${version_dir}/bakery.toml). Fall back to a - # sibling repo checkout for local dev use. - local bakery_toml="${version_dir}/bakery.toml" - if [[ ! -f "${bakery_toml}" ]]; then - bakery_toml="${SCRIPT_DIR}/../${name}/bakery.toml" - fi - if [[ ! -f "${bakery_toml}" ]]; then - echo "ERROR: bakery.toml not found for ${name} — the release workflow must copy it to \${PKG_ROOT}/${name}/\${VERSION}/bakery.toml" >&2 - return 1 - fi - local description system_deps optional_system_deps bread_deps services config post_install description="$(python3 -c " @@ -213,6 +239,47 @@ with open('${bakery_toml}', 'rb') as f: print(json.dumps(d.get('install', {}).get('post_install', []))) " 2>/dev/null || echo "[]")" + # license_file / desktop_file: plain filename fields in bakery.toml + # (names already read above, before the binaries loop), same "artifact + # in the version dir, sha256 computed here" pattern as config.example. + # Empty string (not null) when unset, matching how the rest of this + # script signals "field absent" to jq below. + license_file="${license_file_name}" + license_file_sha256="" + if [[ -n "${license_file}" ]]; then + license_path="${version_dir}/${license_file}" + if [[ -f "${license_path}" ]]; then + license_file_sha256="$(sha256sum "${license_path}" | awk '{print $1}')" + else + echo " warning: license_file '${license_file}' not found at ${license_path}" >&2 + license_file="" + fi + fi + + desktop_file="${desktop_file_name}" + desktop_file_sha256="" + if [[ -n "${desktop_file}" ]]; then + desktop_path="${version_dir}/${desktop_file}" + if [[ -f "${desktop_path}" ]]; then + desktop_file_sha256="$(sha256sum "${desktop_path}" | awk '{print $1}')" + else + echo " warning: desktop_file '${desktop_file}' not found at ${desktop_path}" >&2 + desktop_file="" + fi + fi + + data_archive="${data_archive_name}" + data_archive_sha256="" + if [[ -n "${data_archive}" ]]; then + data_archive_path="${version_dir}/${data_archive}" + if [[ -f "${data_archive_path}" ]]; then + data_archive_sha256="$(sha256sum "${data_archive_path}" | awk '{print $1}')" + else + echo " warning: data_archive '${data_archive}' not found at ${data_archive_path}" >&2 + data_archive="" + fi + fi + jq -n \ --arg name "${name}" \ --arg description "${description}" \ @@ -224,6 +291,12 @@ print(json.dumps(d.get('install', {}).get('post_install', []))) --argjson services "${services}" \ --argjson config "${config}" \ --argjson post_install "${post_install}" \ + --arg license_file "${license_file}" \ + --arg license_file_sha256 "${license_file_sha256}" \ + --arg desktop_file "${desktop_file}" \ + --arg desktop_file_sha256 "${desktop_file_sha256}" \ + --arg data_archive "${data_archive}" \ + --arg data_archive_sha256 "${data_archive_sha256}" \ '{ name: $name, description: $description, @@ -234,7 +307,13 @@ print(json.dumps(d.get('install', {}).get('post_install', []))) bread_deps: $bread_deps, services: $services, config: $config, - post_install: $post_install + post_install: $post_install, + license_file: (if $license_file == "" then null else $license_file end), + license_file_sha256: (if $license_file_sha256 == "" then null else $license_file_sha256 end), + desktop_file: (if $desktop_file == "" then null else $desktop_file end), + desktop_file_sha256: (if $desktop_file_sha256 == "" then null else $desktop_file_sha256 end), + data_archive: (if $data_archive == "" then null else $data_archive end), + data_archive_sha256: (if $data_archive_sha256 == "" then null else $data_archive_sha256 end) }' } From 77bca8a1cf90d7fd38745270d2deb291b8b22bfe Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 23 Jul 2026 11:12:55 +0800 Subject: [PATCH 23/99] Will change this commit message to mean something later --- upgrade.md | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 upgrade.md diff --git a/upgrade.md b/upgrade.md new file mode 100644 index 0000000..15b1824 --- /dev/null +++ b/upgrade.md @@ -0,0 +1,88 @@ +**Project Overview: Bread Screenshot System** + +### Goal +Add a maintainable, automated system to generate high-quality screenshots/renders of **all major UI views** across the Bread ecosystem. This will dramatically speed up UI development, visual regression testing, documentation, and marketing. + +### Scope + +**In Scope:** +- Automated screenshot generation for all major GUI components +- Support for different themes (pywal accents, light/dark if added later) +- Consistent naming and output structure +- Easy-to-run command (`bread capture --all` or similar) +- Integration with development workflow and CI (optional) + +**Out of Scope (Phase 1):** +- Video/GIF capture +- Full automated visual diffing (can be Phase 2) + +### Target Apps / Views + +1. **breadbar** + - Main bar (all placements) + - Control panel (full + sections) + - WiFi popover, media popover, etc. + - Notifications + +2. **breadman** + - All sidebar views (All, Upcoming, Todo, Reminder, etc.) + - Note cards in different states + - Editor / create flow + +3. **breadbox** + - Main launcher view + - Different contexts + +4. **bos-settings** + - All major panels + +5. **breadpad** (capture popup) + +6. **breadlock** (lock screen states) + +7. **Widgets** (test module that renders many widget examples) + +### Technical Approach (Most Idiomatic) + +**Core Components:** + +1. **Shared Library** (`bread-screenshots` crate in bread-ecosystem) + - Common screenshot utilities + - Window finding / targeting logic (using `gtk` or `grim`) + - Theme forcing + +2. **Per-App Screenshot Mode** + - Add `--screenshot ` flag to each GTK app + - Special runtime mode that opens the desired view and calls capture after render + +3. **Orchestrator** + - A small Rust binary (`bread-capture`) or bash + Rust hybrid + - Launches each app with proper flags, waits, captures, saves + +4. **Output Structure** + ``` + screenshots/ + ├── v0.8.0/ + │ ├── breadbar-main.png + │ ├── breadbar-control.png + │ ├── breadman-all.png + │ ├── breadman-todo.png + │ └── ... + └── latest/ (symlinks) + ``` + +### Recommended Implementation Steps + +1. Create `bread-screenshots` crate in bread-ecosystem +2. Add screenshot support to the most important apps first (breadman + breadbar) +3. Build the orchestrator tool +4. Add `bread capture` subcommand to the CLI +5. Document usage + add to CONTRIBUTING.md + +### Benefits + +- Much faster UI iteration +- Visual regression testing +- Always up-to-date marketing/docs screenshots +- Easier contributor onboarding for UI work +- Professional polish for the project From 007082374d335b277ad715a5f436a988b41d314e Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 23 Jul 2026 14:15:49 +0800 Subject: [PATCH 24/99] Add bread-screenshots + bread-capture: foundation for UI screenshot tooling New bread-screenshots crate captures a layer-shell surface (by namespace+pid, to disambiguate from an already-running instance) or the whole focused output via grim, using bread-utils::hypr/proc. bread-utils::Monitor gains a scale field and logical_size() so output geometry accounts for HiDPI/ transform, matching breadshot's proven math. bread-utils::hypr gains find_layer() over hyprctl layers -j. bread-capture is a small orchestrator that drives an app's --screenshot mode and collects the resulting PNGs; hardcoded to breadbar's two views for now. --- Cargo.lock | 18 +++++ Cargo.toml | 2 +- bread-capture/Cargo.toml | 18 +++++ bread-capture/src/main.rs | 58 ++++++++++++++ bread-screenshots/Cargo.toml | 14 ++++ bread-screenshots/src/lib.rs | 50 ++++++++++++ bread-utils/src/hypr.rs | 142 +++++++++++++++++++++++++++++++++++ 7 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 bread-capture/Cargo.toml create mode 100644 bread-capture/src/main.rs create mode 100644 bread-screenshots/Cargo.toml create mode 100644 bread-screenshots/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index b1812e2..e32afd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -148,6 +148,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bread-capture" +version = "0.3.1" +dependencies = [ + "anyhow", + "bread-utils", + "clap", +] + [[package]] name = "bread-onnx" version = "0.3.1" @@ -163,6 +172,15 @@ dependencies = [ "ureq", ] +[[package]] +name = "bread-screenshots" +version = "0.3.1" +dependencies = [ + "anyhow", + "bread-utils", + "tracing", +] + [[package]] name = "bread-shared" version = "0.7.0" diff --git a/Cargo.toml b/Cargo.toml index 4957702..968c4d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["bakery", "bread-theme", "bread-utils", "bread-onnx"] +members = ["bakery", "bread-theme", "bread-utils", "bread-onnx", "bread-screenshots", "bread-capture"] resolver = "2" [workspace.package] diff --git a/bread-capture/Cargo.toml b/bread-capture/Cargo.toml new file mode 100644 index 0000000..b87cbda --- /dev/null +++ b/bread-capture/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "bread-capture" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "Orchestrator for the bread ecosystem's UI screenshot tooling: drives each app's --screenshot mode and collects the resulting PNGs" +repository = "https://git.breadway.dev/Breadway/bread-ecosystem" +keywords = ["screenshot", "ci", "tooling"] + +[[bin]] +name = "bread-capture" +path = "src/main.rs" + +[dependencies] +bread-utils = { path = "../bread-utils" } +clap = { workspace = true } +anyhow = { workspace = true } diff --git a/bread-capture/src/main.rs b/bread-capture/src/main.rs new file mode 100644 index 0000000..63231f9 --- /dev/null +++ b/bread-capture/src/main.rs @@ -0,0 +1,58 @@ +//! Orchestrator for the bread ecosystem's UI screenshot tooling. +//! +//! Drives each target app's `--screenshot --output ` mode (see +//! `bread-screenshots` for what that mode does inside the app) and reports +//! pass/fail per view. Foundation-phase scope: one target (breadbar), a +//! hardcoded view list, and a flat output directory — no versioned +//! `screenshots/vX.Y.Z/latest` structure or manifest file yet, since those +//! only earn their complexity once more apps are wired up. + +use anyhow::Result; +use clap::Parser; +use std::path::PathBuf; +use std::time::Duration; + +const CAPTURE_TIMEOUT: Duration = Duration::from_secs(10); + +/// (view name, output filename) +const BREADBAR_TARGETS: &[(&str, &str)] = &[ + ("bar", "breadbar-bar.png"), + ("control-panel", "breadbar-control-panel.png"), +]; + +#[derive(Parser)] +struct Cli { + /// Path to the breadbar binary (resolved via $PATH if not a path). + #[arg(long, default_value = "breadbar")] + app_path: String, + + /// Directory to write captured PNGs into. + #[arg(long, default_value = "./screenshots")] + out_dir: PathBuf, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + + let mut failed = false; + for (view, filename) in BREADBAR_TARGETS { + let out_path = cli.out_dir.join(filename); + let out_str = out_path.to_string_lossy(); + let result = bread_utils::proc::run( + &cli.app_path, + &["--screenshot", view, "--output", &out_str], + CAPTURE_TIMEOUT, + ); + if result.success { + println!("ok breadbar/{view} -> {}", out_path.display()); + } else { + failed = true; + println!("FAIL breadbar/{view}: {}", result.stderr.trim()); + } + } + + if failed { + std::process::exit(1); + } + Ok(()) +} diff --git a/bread-screenshots/Cargo.toml b/bread-screenshots/Cargo.toml new file mode 100644 index 0000000..0d5670c --- /dev/null +++ b/bread-screenshots/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "bread-screenshots" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "Shared capture plumbing for the bread ecosystem's UI screenshot tooling: layer-surface and output geometry via Hyprland IPC, capture via grim" +repository = "https://git.breadway.dev/Breadway/bread-ecosystem" +keywords = ["hyprland", "wayland", "screenshot", "grim"] + +[dependencies] +bread-utils = { path = "../bread-utils" } +anyhow = { workspace = true } +tracing = { workspace = true } diff --git a/bread-screenshots/src/lib.rs b/bread-screenshots/src/lib.rs new file mode 100644 index 0000000..b6bb610 --- /dev/null +++ b/bread-screenshots/src/lib.rs @@ -0,0 +1,50 @@ +//! Capture primitives for the bread ecosystem's UI screenshot tooling (see +//! `bread-capture`, the orchestrator that drives this crate's consumers). +//! +//! A "view" being screenshotted is either: +//! - its own layer-shell surface (a bar, launcher, ...) — captured tightly via +//! [`capture_layer`], geometry from `hyprctl layers`. +//! - a transient popover/popup — not a separate layer surface under +//! gtk4-layer-shell (it's an xdg_popup Hyprland doesn't list individually), +//! so the only reliable capture is [`capture_output`]: whatever's currently +//! on the focused monitor. + +use anyhow::{bail, Context, Result}; +use std::path::Path; +use std::time::Duration; + +const GRIM_TIMEOUT: Duration = Duration::from_secs(5); + +/// Capture a named layer-shell surface belonging to *this* process +/// (`std::process::id()`), identified by its `namespace` (e.g. `"breadbar"`). +/// Namespace alone can't identify "our own" surface when another instance +/// under the same namespace is already running (breadbar commonly is), so +/// this matches by pid too — see `bread_utils::hypr::find_layer`. +pub fn capture_layer(namespace: &str, out: &Path) -> Result<()> { + let layer = bread_utils::hypr::find_layer(namespace, std::process::id()) + .with_context(|| format!("no layer surface found for namespace={namespace}"))?; + let geometry = format!("{},{} {}x{}", layer.x, layer.y, layer.w, layer.h); + run_grim(&geometry, out) +} + +/// Capture the entire focused output (monitor). Used for views whose +/// interesting content isn't its own layer surface — see the module doc. +pub fn capture_output(out: &Path) -> Result<()> { + let monitor = bread_utils::hypr::focused_monitor().context("no focused monitor found")?; + let (w, h) = monitor.logical_size(); + let geometry = format!("{},{} {}x{}", monitor.x, monitor.y, w, h); + run_grim(&geometry, out) +} + +fn run_grim(geometry: &str, out: &Path) -> Result<()> { + if let Some(parent) = out.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("creating {}", parent.display()))?; + } + let out_str = out.to_str().context("output path is not valid UTF-8")?; + let result = bread_utils::proc::run("grim", &["-g", geometry, out_str], GRIM_TIMEOUT); + if !result.success { + bail!("grim failed for geometry {geometry}: {}", result.stderr); + } + Ok(()) +} diff --git a/bread-utils/src/hypr.rs b/bread-utils/src/hypr.rs index def3e98..228c53c 100644 --- a/bread-utils/src/hypr.rs +++ b/bread-utils/src/hypr.rs @@ -152,10 +152,81 @@ pub struct Monitor { pub y: i32, pub width: i32, pub height: i32, + #[serde(default = "one")] + pub scale: f64, + #[serde(default)] + pub transform: i64, #[serde(default)] pub focused: bool, } +fn one() -> f64 { + 1.0 +} + +impl Monitor { + /// Logical (scaled, transform-aware) output size, matching what `grim -g` + /// expects — `width`/`height` above are physical pixels. A 90/270-degree + /// transform swaps the axes before the scale divide, same as breadshot's + /// `monitor_geometry`, which this mirrors. + pub fn logical_size(&self) -> (i32, i32) { + if self.transform % 2 == 0 { + ( + (self.width as f64 / self.scale).round() as i32, + (self.height as f64 / self.scale).round() as i32, + ) + } else { + ( + (self.height as f64 / self.scale).round() as i32, + (self.width as f64 / self.scale).round() as i32, + ) + } + } +} + +/// One entry from `hyprctl layers -j` — a layer-shell surface (bar, launcher, +/// lock screen, ...), keyed by the `namespace` its client registered. +#[derive(Debug, Clone, Deserialize)] +pub struct Layer { + pub namespace: String, + pub pid: u32, + pub x: i32, + pub y: i32, + pub w: i32, + pub h: i32, +} + +/// Find a layer-shell surface by namespace *and* pid. Namespace alone isn't +/// enough to identify "our own" surface — several bread apps (breadbar +/// notably) are typically already running under the same namespace when a +/// second instance starts up for some other purpose (e.g. screenshot mode), +/// so callers pass their own `std::process::id()` to disambiguate. +pub fn find_layer(namespace: &str, pid: u32) -> Option { + find_layer_in(&request_json("j/layers")?, namespace, pid) +} + +/// Parsing half of [`find_layer`], split out so it's testable without a live +/// Hyprland socket. +fn find_layer_in(root: &serde_json::Value, namespace: &str, pid: u32) -> Option { + let monitors = root.as_object()?; + for monitor in monitors.values() { + let Some(levels) = monitor.get("levels").and_then(|v| v.as_object()) else { + continue; + }; + for layers in levels.values() { + for layer in layers.as_array().into_iter().flatten() { + let Ok(l) = serde_json::from_value::(layer.clone()) else { + continue; + }; + if l.namespace == namespace && l.pid == pid { + return Some(l); + } + } + } + } + None +} + /// Query the currently active (focused) window. Returns `None` if the /// window is fullscreen or no window is focused — same "centre the popup /// instead" contract `breadclip`'s original `get_active_window` had. @@ -190,6 +261,77 @@ pub fn active_workspace_name() -> Option { mod tests { use super::*; + #[test] + fn logical_size_divides_by_scale() { + let m = Monitor { + name: "eDP-1".into(), + x: 0, + y: 0, + width: 3840, + height: 2400, + scale: 2.0, + transform: 0, + focused: true, + }; + assert_eq!(m.logical_size(), (1920, 1200)); + } + + #[test] + fn logical_size_swaps_axes_on_rotated_transform() { + let m = Monitor { + name: "eDP-1".into(), + x: 0, + y: 0, + width: 1920, + height: 1200, + scale: 1.0, + transform: 1, + focused: true, + }; + assert_eq!(m.logical_size(), (1200, 1920)); + } + + // Real shape confirmed live via `hyprctl layers -j`: per-monitor map -> + // "levels" map (layer index "0".."3") -> array of layer objects. + const LAYERS_JSON: &str = r#"{ + "eDP-1": { + "levels": { + "0": [ + {"address":"0x1","x":0,"y":0,"w":1920,"h":1200,"namespace":"awww-daemon","pid":2481} + ], + "1": [], + "2": [ + {"address":"0x2","x":0,"y":0,"w":1920,"h":32,"namespace":"breadbar","pid":1601316}, + {"address":"0x3","x":0,"y":0,"w":1920,"h":32,"namespace":"breadbar","pid":9999} + ], + "3": [] + } + } + }"#; + + #[test] + fn find_layer_in_matches_namespace_and_pid() { + let root: serde_json::Value = serde_json::from_str(LAYERS_JSON).unwrap(); + let layer = find_layer_in(&root, "breadbar", 9999).unwrap(); + assert_eq!(layer.pid, 9999); + assert_eq!(layer.w, 1920); + assert_eq!(layer.h, 32); + } + + #[test] + fn find_layer_in_ignores_same_namespace_wrong_pid() { + let root: serde_json::Value = serde_json::from_str(LAYERS_JSON).unwrap(); + // pid 1601316 is a real breadbar layer in the fixture, but not the one + // we're asking for — must not fall back to it. + assert!(find_layer_in(&root, "breadbar", 42).is_none()); + } + + #[test] + fn find_layer_in_returns_none_for_missing_namespace() { + let root: serde_json::Value = serde_json::from_str(LAYERS_JSON).unwrap(); + assert!(find_layer_in(&root, "breadbox", 1601316).is_none()); + } + #[test] fn fullscreen_state_deserializes_from_bool() { let s: FullscreenState = serde_json::from_str("true").unwrap(); From bcd57b7b54460ba646af2f755174f43a93bbd306 Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 23 Jul 2026 16:22:24 +0800 Subject: [PATCH 25/99] bread-capture: isolate captures in a throwaway nested Hyprland instance Captures now run inside a dedicated nested Hyprland session by default (--no-isolate to opt out), so nothing on the operator's live desktop can leak into a screenshot and the capture never flashes across their screen either. The nested instance nests as a Wayland client of the outer session (true headless was ruled out empirically: this machine's real GPU/output is already claimed by the live session, and only one process can hold logind's seat at a time), gets floated/exact-resized/focused via one-shot outer-session hyprctl dispatches targeted by pid, and has Hyprland's default background/ logo and startup warning overlays disabled via config so captures come out clean. Focusing turned out to be load-bearing, not cosmetic: an occluded nested window never gets frame callbacks from the outer compositor, so grim run inside it hangs forever waiting on a ready event that never comes. --- bread-capture/src/isolation.rs | 243 +++++++++++++++++++++++++++++++++ bread-capture/src/main.rs | 29 ++++ 2 files changed, 272 insertions(+) create mode 100644 bread-capture/src/isolation.rs diff --git a/bread-capture/src/isolation.rs b/bread-capture/src/isolation.rs new file mode 100644 index 0000000..1220b02 --- /dev/null +++ b/bread-capture/src/isolation.rs @@ -0,0 +1,243 @@ +//! Runs each capture target inside a throwaway nested Hyprland instance +//! instead of the operator's live desktop, so nothing on their screen (other +//! windows, a differently-themed real bar, whatever's behind a popover) can +//! leak into a capture, and the capture in turn never flashes across their +//! desktop either. +//! +//! Mechanics, established empirically against Hyprland 0.55 (Lua config) — +//! there's no single documented "run headless" switch that fits this case: +//! - A genuinely headless (zero real output) backend needs +//! `AQ_NO_KMS_REQUIREMENT`, but that's for a vGPU with no display output at +//! all. This machine's GPU already has a real output claimed by the live +//! session's `Hyprland`, and only one process can hold that session's seat +//! (logind) at a time — a second instance trying DRM directly fails with +//! "Device or resource busy", not a headless fallback. +//! - The working path is nesting: keep `WAYLAND_DISPLAY` pointed at the +//! outer session so the new `Hyprland` connects to it as an ordinary +//! Wayland *client* (Aquamarine's Wayland backend) — from the outer +//! session's point of view it's just a window. It auto-picks its own new +//! server socket name (`wayland-N`, skipping ones already taken) and its +//! own `HYPRLAND_INSTANCE_SIGNATURE`; neither is knowable in advance, so +//! both are discovered by diffing directory listings before/after spawn. +//! - That nested window's pixel size is decided by the *outer* compositor +//! (it's just a regular window there), not by any monitor rule inside the +//! nested config — so getting a fixed, consistent capture canvas means +//! floating + exact-resizing it via one-shot `hyprctl --instance +//! dispatch` calls against the outer session, targeted at the new +//! window's `address` (found by matching the outer client list's `pid` +//! against the spawned process's own pid — unambiguous, no reliance on +//! window class/title/timing). +//! - The outer compositor throttles frame callbacks for occluded surfaces: +//! with the nested window unfocused/covered, `grim` (run *inside* the +//! nested session) hangs forever waiting on `ext_image_copy_capture`'s +//! `.ready()` event, because Aquamarine's Wayland backend never gets a +//! frame tick to render one. Focusing the nested window (raising it, so +//! it's actually presented) is what makes captures complete instead of +//! hanging — confirmed by reproducing the hang and then clearing it with +//! nothing else changed. +//! - Vanilla Hyprland draws its own branded default background/logo when no +//! client owns the background layer (not blank), plus a red on-screen +//! watchdog/XDG-desktop/gui-utils warning overlay when started directly +//! like this rather than via `start-hyprland`. Both are disabled via +//! `misc:*` config, not by launch flags. + +use anyhow::{bail, Context, Result}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(5); +const HYPRCTL_TIMEOUT: Duration = Duration::from_secs(3); +/// Settle time after focusing the nested window — gives the outer +/// compositor a moment to actually start presenting it (see module docs on +/// occlusion throttling) before anything tries to capture through it. +const FOCUS_SETTLE: Duration = Duration::from_millis(400); + +pub struct Isolation { + child: Child, + pub wayland_display: String, + pub instance_signature: String, + config_path: PathBuf, + runtime_dir: PathBuf, +} + +impl Isolation { + /// Spawn the nested instance, size its outer window to `width`x`height`, + /// and set `WAYLAND_DISPLAY`/`HYPRLAND_INSTANCE_SIGNATURE` on *this* + /// process's own environment so every subsequent + /// `bread_utils::proc::run` spawn (the target app, and in turn its own + /// `grim` calls) inherits them and lands inside the nested session. + pub fn start(width: u32, height: u32) -> Result { + let outer_wayland_display = + std::env::var("WAYLAND_DISPLAY").context("WAYLAND_DISPLAY not set — isolation requires running inside a live Hyprland/Wayland session to nest inside")?; + let outer_signature = std::env::var("HYPRLAND_INSTANCE_SIGNATURE") + .context("HYPRLAND_INSTANCE_SIGNATURE not set — isolation requires running inside a live Hyprland session")?; + let runtime_dir = PathBuf::from(std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".to_string())); + + let config_path = write_nested_config()?; + let hypr_dir = runtime_dir.join("hypr"); + let before_instances = dir_names(&hypr_dir); + let before_sockets: HashSet = dir_names(&runtime_dir).into_iter().filter(|n| is_wayland_socket_name(n)).collect(); + + let child = Command::new("Hyprland") + .arg("--config") + .arg(&config_path) + .env("WAYLAND_DISPLAY", &outer_wayland_display) + .env("XDG_RUNTIME_DIR", &runtime_dir) + .env_remove("HYPRLAND_INSTANCE_SIGNATURE") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .context("spawning nested Hyprland")?; + let pid = child.id(); + + let mut isolation = Isolation { + child, + wayland_display: String::new(), + instance_signature: String::new(), + config_path, + runtime_dir: runtime_dir.clone(), + }; + + let result = (|| -> Result<()> { + isolation.instance_signature = poll_for_new(&hypr_dir, &before_instances, DISCOVERY_TIMEOUT, |_| true) + .context("waiting for nested Hyprland's instance signature to appear")?; + isolation.wayland_display = poll_for_new(&runtime_dir, &before_sockets, DISCOVERY_TIMEOUT, is_wayland_socket_name) + .context("waiting for nested Hyprland's Wayland socket to appear")?; + + let addr = poll_for_client_address(&outer_signature, pid, DISCOVERY_TIMEOUT) + .context("waiting for the nested Hyprland window to appear in the outer session")?; + outer_dispatch(&outer_signature, &format!("hl.dsp.window.float({{ window = 'address:{addr}' }})"))?; + outer_dispatch( + &outer_signature, + &format!("hl.dsp.window.resize({{ x = {width}, y = {height}, window = 'address:{addr}' }})"), + )?; + // Must be focused/raised, not just resized — an occluded nested + // window never gets frame callbacks from the outer compositor, + // and grim run inside it hangs forever waiting on one. See + // module docs. + outer_dispatch(&outer_signature, &format!("hl.dsp.focus({{ window = 'address:{addr}' }})"))?; + std::thread::sleep(FOCUS_SETTLE); + Ok(()) + })(); + + if let Err(e) = result { + // Best-effort teardown of the half-started instance before + // propagating — the normal Drop impl still runs too, but doing + // it here as well means a failure this early doesn't depend on + // isolation ever being bound to a variable that outlives this + // function. + let _ = isolation.child.kill(); + let _ = isolation.child.wait(); + return Err(e); + } + + std::env::set_var("WAYLAND_DISPLAY", &isolation.wayland_display); + std::env::set_var("HYPRLAND_INSTANCE_SIGNATURE", &isolation.instance_signature); + + Ok(isolation) + } +} + +impl Drop for Isolation { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + let _ = std::fs::remove_file(&self.config_path); + if !self.instance_signature.is_empty() { + let _ = std::fs::remove_dir_all(self.runtime_dir.join("hypr").join(&self.instance_signature)); + } + } +} + +fn write_nested_config() -> Result { + let path = std::env::temp_dir().join(format!("bread-capture-hypr-{}.lua", std::process::id())); + // No exec-once/wallpaper daemon at all — that's the entire "no + // background" mechanism (nothing ever claims the background layer). + // misc.background_color matches bread-theme's own FIXED_BACKGROUND + // (#0c0c0c, see bread-theme/src/palette.rs) so the empty canvas behind + // a capture reads as "the app's own dark theme", not an arbitrary color. + // disable_hyprland_logo/disable_splash_rendering turn off Hyprland's own + // branded default background (drawn even with zero clients); the three + // disable_* checks below turn off the on-screen red warning banner + // Hyprland draws when started outside `start-hyprland`/without a + // matching XDG_CURRENT_DESKTOP/without hyprland-dialog installed — all + // expected and harmless here, but they'd otherwise show up in captures. + let contents = r#"hl.monitor({ + output = "WAYLAND-1", + scale = "1", +}) +hl.config({ + misc = { + disable_hyprland_logo = true, + disable_splash_rendering = true, + force_default_wallpaper = 0, + background_color = "rgba(0c0c0cff)", + disable_xdg_env_checks = true, + disable_hyprland_guiutils_check = true, + disable_watchdog_warning = true, + }, +}) +"#; + std::fs::write(&path, contents).with_context(|| format!("writing {}", path.display()))?; + Ok(path) +} + +fn dir_names(path: &Path) -> HashSet { + std::fs::read_dir(path) + .into_iter() + .flatten() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect() +} + +fn is_wayland_socket_name(name: &str) -> bool { + name.strip_prefix("wayland-") + .is_some_and(|rest| !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit())) +} + +fn poll_for_new(dir: &Path, before: &HashSet, timeout: Duration, relevant: impl Fn(&str) -> bool) -> Result { + let start = Instant::now(); + loop { + let after = dir_names(dir); + if let Some(name) = after.iter().find(|n| relevant(n) && !before.contains(*n)) { + return Ok(name.clone()); + } + if start.elapsed() > timeout { + bail!("timed out after {timeout:?} waiting for a new entry in {}", dir.display()); + } + std::thread::sleep(Duration::from_millis(100)); + } +} + +fn poll_for_client_address(outer_signature: &str, pid: u32, timeout: Duration) -> Result { + let start = Instant::now(); + loop { + if let Some(clients) = bread_utils::proc::run_json("hyprctl", &["--instance", outer_signature, "clients", "-j"], HYPRCTL_TIMEOUT) { + if let Some(arr) = clients.as_array() { + let found = arr + .iter() + .find(|c| c.get("pid").and_then(|v| v.as_u64()) == Some(pid as u64)) + .and_then(|c| c.get("address")) + .and_then(|v| v.as_str()); + if let Some(addr) = found { + return Ok(addr.to_string()); + } + } + } + if start.elapsed() > timeout { + bail!("timed out after {timeout:?} waiting for pid {pid}'s window in the outer session's client list"); + } + std::thread::sleep(Duration::from_millis(100)); + } +} + +fn outer_dispatch(outer_signature: &str, lua_expr: &str) -> Result<()> { + let result = bread_utils::proc::run("hyprctl", &["--instance", outer_signature, "dispatch", lua_expr], HYPRCTL_TIMEOUT); + if !result.success { + bail!("hyprctl dispatch failed ({lua_expr}): {}", result.stderr.trim()); + } + Ok(()) +} diff --git a/bread-capture/src/main.rs b/bread-capture/src/main.rs index 63231f9..7d71a4b 100644 --- a/bread-capture/src/main.rs +++ b/bread-capture/src/main.rs @@ -6,6 +6,15 @@ //! hardcoded view list, and a flat output directory — no versioned //! `screenshots/vX.Y.Z/latest` structure or manifest file yet, since those //! only earn their complexity once more apps are wired up. +//! +//! By default every capture runs inside a throwaway nested Hyprland instance +//! (see [`isolation`]) rather than the operator's live desktop, so another +//! window (or their own differently-themed real bar) can't leak into a +//! capture. `--no-isolate` skips that and captures directly against whatever +//! session bread-capture itself is running in — useful for debugging the +//! capture sequence itself, since you can then actually watch it happen. + +mod isolation; use anyhow::Result; use clap::Parser; @@ -29,11 +38,31 @@ struct Cli { /// Directory to write captured PNGs into. #[arg(long, default_value = "./screenshots")] out_dir: PathBuf, + + /// Capture directly against the current session instead of a nested, + /// throwaway Hyprland instance. Off by default so captures can't pick up + /// whatever else is on the operator's desktop. + #[arg(long)] + no_isolate: bool, + + /// Width of the isolated session's capture canvas. + #[arg(long, default_value_t = 1920)] + isolate_width: u32, + + /// Height of the isolated session's capture canvas. + #[arg(long, default_value_t = 1080)] + isolate_height: u32, } fn main() -> Result<()> { let cli = Cli::parse(); + let _isolation = if cli.no_isolate { + None + } else { + Some(isolation::Isolation::start(cli.isolate_width, cli.isolate_height)?) + }; + let mut failed = false; for (view, filename) in BREADBAR_TARGETS { let out_path = cli.out_dir.join(filename); From d059d9943710e43c21e37e9bec17d331251508dd Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 29 Jul 2026 11:15:41 +0800 Subject: [PATCH 26/99] bread-capture: switch capture isolation to headless Sway Nested Hyprland worked but had real limits: the outer compositor decided the nested window's pixel size (needing an outer-session float+resize dispatch per capture), occluded surfaces got no frame callbacks (so grim hung unless the nested window was also focused/raised), and there was no way to fully suppress a brief real, visible flash of that window on the operator's desktop. wlroots' WLR_BACKENDS=headless (Sway, not Hyprland, is built on wlroots directly) has a genuine headless backend: no seat/DRM-master claim, no window anywhere, ever. Confirmed empirically: zero visible footprint, both zwlr_layer_shell_v1 and zwlr_screencopy_manager_v1 present, grim completes instantly with no focus dance needed. This drops the Hyprland-specific plumbing that no longer applies: - bread-screenshots now exposes one compositor-agnostic capture_region primitive instead of capture_layer/capture_output, since the isolated canvas size is always known up front rather than queried via hyprctl. - bread-utils::hypr loses the Monitor scale/transform/logical_size and Layer/find_layer additions that only existed to support that querying. - bread-capture's isolation module spawns headless Sway instead of a nested Hyprland instance, and passes --width/--height through to the target app so it knows the canvas size without asking anyone. Also fixes a socket leak in isolation teardown: killing the compositor (Hyprland or Sway) doesn't unlink the wayland-N/.lock files it created, so every capture run was orphaning a socket pair in the runtime dir. Drop now removes them explicitly. --- bread-capture/src/isolation.rs | 263 +++++++++++++-------------------- bread-capture/src/main.rs | 16 +- bread-screenshots/src/lib.rs | 44 ++---- bread-utils/src/hypr.rs | 142 ------------------ 4 files changed, 124 insertions(+), 341 deletions(-) diff --git a/bread-capture/src/isolation.rs b/bread-capture/src/isolation.rs index 1220b02..ef43da2 100644 --- a/bread-capture/src/isolation.rs +++ b/bread-capture/src/isolation.rs @@ -1,45 +1,44 @@ -//! Runs each capture target inside a throwaway nested Hyprland instance -//! instead of the operator's live desktop, so nothing on their screen (other -//! windows, a differently-themed real bar, whatever's behind a popover) can -//! leak into a capture, and the capture in turn never flashes across their -//! desktop either. +//! Runs each capture target inside a headless Sway instance instead of the +//! operator's live desktop, so nothing on their screen (other windows, a +//! differently-themed real bar, whatever's behind a popover) can leak into a +//! capture, and the capture never flashes across their desktop either. //! -//! Mechanics, established empirically against Hyprland 0.55 (Lua config) — -//! there's no single documented "run headless" switch that fits this case: -//! - A genuinely headless (zero real output) backend needs -//! `AQ_NO_KMS_REQUIREMENT`, but that's for a vGPU with no display output at -//! all. This machine's GPU already has a real output claimed by the live -//! session's `Hyprland`, and only one process can hold that session's seat -//! (logind) at a time — a second instance trying DRM directly fails with -//! "Device or resource busy", not a headless fallback. -//! - The working path is nesting: keep `WAYLAND_DISPLAY` pointed at the -//! outer session so the new `Hyprland` connects to it as an ordinary -//! Wayland *client* (Aquamarine's Wayland backend) — from the outer -//! session's point of view it's just a window. It auto-picks its own new -//! server socket name (`wayland-N`, skipping ones already taken) and its -//! own `HYPRLAND_INSTANCE_SIGNATURE`; neither is knowable in advance, so -//! both are discovered by diffing directory listings before/after spawn. -//! - That nested window's pixel size is decided by the *outer* compositor -//! (it's just a regular window there), not by any monitor rule inside the -//! nested config — so getting a fixed, consistent capture canvas means -//! floating + exact-resizing it via one-shot `hyprctl --instance -//! dispatch` calls against the outer session, targeted at the new -//! window's `address` (found by matching the outer client list's `pid` -//! against the spawned process's own pid — unambiguous, no reliance on -//! window class/title/timing). -//! - The outer compositor throttles frame callbacks for occluded surfaces: -//! with the nested window unfocused/covered, `grim` (run *inside* the -//! nested session) hangs forever waiting on `ext_image_copy_capture`'s -//! `.ready()` event, because Aquamarine's Wayland backend never gets a -//! frame tick to render one. Focusing the nested window (raising it, so -//! it's actually presented) is what makes captures complete instead of -//! hanging — confirmed by reproducing the hang and then clearing it with -//! nothing else changed. -//! - Vanilla Hyprland draws its own branded default background/logo when no -//! client owns the background layer (not blank), plus a red on-screen -//! watchdog/XDG-desktop/gui-utils warning overlay when started directly -//! like this rather than via `start-hyprland`. Both are disabled via -//! `misc:*` config, not by launch flags. +//! This replaced an earlier nested-Hyprland approach (see git history for +//! `feature/capture-isolation` if you want the gory details). That worked, +//! but Hyprland's own backend library (Aquamarine) has no genuinely headless +//! mode when a live session already holds the seat — the only path was +//! nesting a full second Hyprland as an ordinary Wayland *client* of the +//! outer session, which meant: the outer compositor deciding the nested +//! window's pixel size (so every capture needed an outer-session +//! float+resize dispatch), the outer compositor throttling frame callbacks +//! for occluded surfaces (so the nested window also had to be *focused*, or +//! `grim` run inside it hung forever waiting on a frame that never came), +//! and — the thing that ultimately motivated dropping this approach — no way +//! to fully suppress the brief real, visible flash of that window on the +//! operator's actual screen (Lua-config Hyprland has no `keyword`-based +//! pre-emptive windowrule injection, and parking it on an untoggled special +//! workspace produced broken, half-rendered captures instead). +//! +//! wlroots (which Sway, not Hyprland, is built directly on) has a real +//! headless backend: `WLR_BACKENDS=headless` skips DRM and Wayland-client +//! backends entirely and synthesizes a virtual output with no seat/DRM-master +//! claim at all — no fight with logind over the live session's seat, and no +//! window anywhere, nested or otherwise, for the operator to ever see. Empirically +//! confirmed on this machine: zero visible footprint, `zwlr_layer_shell_v1` +//! and `zwlr_screencopy_manager_v1` both present (so a layer-shell bar and +//! `grim` both work), and a manual `grim` capture against it completes +//! instantly with no focus/occlusion dance required. +//! +//! One consequence of not nesting inside Hyprland at all: breadbar's +//! workspace list (`src/bar/workspaces.rs`, via the `hyprland` crate) talks +//! to whatever `HYPRLAND_INSTANCE_SIGNATURE` points at. Left alone, that +//! still points at the operator's real, live Hyprland instance — a data leak +//! into an otherwise-isolated capture (real workspace names/count showing up +//! in a bar screenshot that's supposed to be clean). Sway has no equivalent +//! IPC this needs to keep working, so [`Isolation::start`] unsets it; +//! breadbar already has to tolerate a missing/dead Hyprland connection +//! gracefully (it survives Hyprland restarting), so this just exercises that +//! same fallback path instead of a real error case. use anyhow::{bail, Context, Result}; use std::collections::HashSet; @@ -48,93 +47,71 @@ use std::process::{Child, Command, Stdio}; use std::time::{Duration, Instant}; const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(5); -const HYPRCTL_TIMEOUT: Duration = Duration::from_secs(3); -/// Settle time after focusing the nested window — gives the outer -/// compositor a moment to actually start presenting it (see module docs on -/// occlusion throttling) before anything tries to capture through it. -const FOCUS_SETTLE: Duration = Duration::from_millis(400); + +/// Matches bread-theme's `FIXED_BACKGROUND` (`bread-theme/src/palette.rs`) — +/// so the empty canvas behind a capture reads as "the app's own dark theme", +/// not an arbitrary compositor default. +const BACKGROUND_COLOR: &str = "#0c0c0c"; pub struct Isolation { child: Child, pub wayland_display: String, - pub instance_signature: String, config_path: PathBuf, runtime_dir: PathBuf, } impl Isolation { - /// Spawn the nested instance, size its outer window to `width`x`height`, - /// and set `WAYLAND_DISPLAY`/`HYPRLAND_INSTANCE_SIGNATURE` on *this* - /// process's own environment so every subsequent + /// Spawn the headless instance sized to `width`x`height`, and set + /// `WAYLAND_DISPLAY` on *this* process's own environment (and unset + /// `HYPRLAND_INSTANCE_SIGNATURE`) so every subsequent /// `bread_utils::proc::run` spawn (the target app, and in turn its own - /// `grim` calls) inherits them and lands inside the nested session. + /// `grim` calls) inherits them and lands inside the isolated instance. pub fn start(width: u32, height: u32) -> Result { - let outer_wayland_display = - std::env::var("WAYLAND_DISPLAY").context("WAYLAND_DISPLAY not set — isolation requires running inside a live Hyprland/Wayland session to nest inside")?; - let outer_signature = std::env::var("HYPRLAND_INSTANCE_SIGNATURE") - .context("HYPRLAND_INSTANCE_SIGNATURE not set — isolation requires running inside a live Hyprland session")?; - let runtime_dir = PathBuf::from(std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".to_string())); + let runtime_dir = PathBuf::from( + std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".to_string()), + ); + let config_path = write_headless_config(width, height)?; + let before_sockets: HashSet = dir_names(&runtime_dir) + .into_iter() + .filter(|n| is_wayland_socket_name(n)) + .collect(); - let config_path = write_nested_config()?; - let hypr_dir = runtime_dir.join("hypr"); - let before_instances = dir_names(&hypr_dir); - let before_sockets: HashSet = dir_names(&runtime_dir).into_iter().filter(|n| is_wayland_socket_name(n)).collect(); - - let child = Command::new("Hyprland") - .arg("--config") + let child = Command::new("sway") + .arg("-c") .arg(&config_path) - .env("WAYLAND_DISPLAY", &outer_wayland_display) + .env("WLR_BACKENDS", "headless") .env("XDG_RUNTIME_DIR", &runtime_dir) - .env_remove("HYPRLAND_INSTANCE_SIGNATURE") + .env_remove("WAYLAND_DISPLAY") .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() - .context("spawning nested Hyprland")?; - let pid = child.id(); + .context("spawning headless sway")?; let mut isolation = Isolation { child, wayland_display: String::new(), - instance_signature: String::new(), config_path, runtime_dir: runtime_dir.clone(), }; - let result = (|| -> Result<()> { - isolation.instance_signature = poll_for_new(&hypr_dir, &before_instances, DISCOVERY_TIMEOUT, |_| true) - .context("waiting for nested Hyprland's instance signature to appear")?; - isolation.wayland_display = poll_for_new(&runtime_dir, &before_sockets, DISCOVERY_TIMEOUT, is_wayland_socket_name) - .context("waiting for nested Hyprland's Wayland socket to appear")?; - - let addr = poll_for_client_address(&outer_signature, pid, DISCOVERY_TIMEOUT) - .context("waiting for the nested Hyprland window to appear in the outer session")?; - outer_dispatch(&outer_signature, &format!("hl.dsp.window.float({{ window = 'address:{addr}' }})"))?; - outer_dispatch( - &outer_signature, - &format!("hl.dsp.window.resize({{ x = {width}, y = {height}, window = 'address:{addr}' }})"), - )?; - // Must be focused/raised, not just resized — an occluded nested - // window never gets frame callbacks from the outer compositor, - // and grim run inside it hangs forever waiting on one. See - // module docs. - outer_dispatch(&outer_signature, &format!("hl.dsp.focus({{ window = 'address:{addr}' }})"))?; - std::thread::sleep(FOCUS_SETTLE); - Ok(()) - })(); - - if let Err(e) = result { - // Best-effort teardown of the half-started instance before - // propagating — the normal Drop impl still runs too, but doing - // it here as well means a failure this early doesn't depend on - // isolation ever being bound to a variable that outlives this - // function. - let _ = isolation.child.kill(); - let _ = isolation.child.wait(); - return Err(e); + match poll_for_new(&runtime_dir, &before_sockets, DISCOVERY_TIMEOUT, is_wayland_socket_name) + .context("waiting for headless sway's Wayland socket to appear") + { + Ok(name) => isolation.wayland_display = name, + Err(e) => { + // Best-effort teardown of the half-started instance before + // propagating — the normal Drop impl still runs too, but + // doing it here as well means a failure this early doesn't + // depend on isolation ever being bound to a variable that + // outlives this function. + let _ = isolation.child.kill(); + let _ = isolation.child.wait(); + return Err(e); + } } std::env::set_var("WAYLAND_DISPLAY", &isolation.wayland_display); - std::env::set_var("HYPRLAND_INSTANCE_SIGNATURE", &isolation.instance_signature); + std::env::remove_var("HYPRLAND_INSTANCE_SIGNATURE"); Ok(isolation) } @@ -145,41 +122,23 @@ impl Drop for Isolation { let _ = self.child.kill(); let _ = self.child.wait(); let _ = std::fs::remove_file(&self.config_path); - if !self.instance_signature.is_empty() { - let _ = std::fs::remove_dir_all(self.runtime_dir.join("hypr").join(&self.instance_signature)); + // Killing sway doesn't unlink the socket it bound — confirmed + // empirically, a killed instance leaves both files behind — so + // without this, every capture run permanently orphans a + // `wayland-N`/`wayland-N.lock` pair in the runtime dir. + if !self.wayland_display.is_empty() { + let _ = std::fs::remove_file(self.runtime_dir.join(&self.wayland_display)); + let _ = std::fs::remove_file(self.runtime_dir.join(format!("{}.lock", self.wayland_display))); } } } -fn write_nested_config() -> Result { - let path = std::env::temp_dir().join(format!("bread-capture-hypr-{}.lua", std::process::id())); - // No exec-once/wallpaper daemon at all — that's the entire "no - // background" mechanism (nothing ever claims the background layer). - // misc.background_color matches bread-theme's own FIXED_BACKGROUND - // (#0c0c0c, see bread-theme/src/palette.rs) so the empty canvas behind - // a capture reads as "the app's own dark theme", not an arbitrary color. - // disable_hyprland_logo/disable_splash_rendering turn off Hyprland's own - // branded default background (drawn even with zero clients); the three - // disable_* checks below turn off the on-screen red warning banner - // Hyprland draws when started outside `start-hyprland`/without a - // matching XDG_CURRENT_DESKTOP/without hyprland-dialog installed — all - // expected and harmless here, but they'd otherwise show up in captures. - let contents = r#"hl.monitor({ - output = "WAYLAND-1", - scale = "1", -}) -hl.config({ - misc = { - disable_hyprland_logo = true, - disable_splash_rendering = true, - force_default_wallpaper = 0, - background_color = "rgba(0c0c0cff)", - disable_xdg_env_checks = true, - disable_hyprland_guiutils_check = true, - disable_watchdog_warning = true, - }, -}) -"#; +fn write_headless_config(width: u32, height: u32) -> Result { + let path = std::env::temp_dir().join(format!("bread-capture-sway-{}.conf", std::process::id())); + let contents = format!( + "output HEADLESS-1 resolution {width}x{height}\n\ + output HEADLESS-1 bg {BACKGROUND_COLOR} solid_color\n" + ); std::fs::write(&path, contents).with_context(|| format!("writing {}", path.display()))?; Ok(path) } @@ -198,7 +157,12 @@ fn is_wayland_socket_name(name: &str) -> bool { .is_some_and(|rest| !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit())) } -fn poll_for_new(dir: &Path, before: &HashSet, timeout: Duration, relevant: impl Fn(&str) -> bool) -> Result { +fn poll_for_new( + dir: &Path, + before: &HashSet, + timeout: Duration, + relevant: impl Fn(&str) -> bool, +) -> Result { let start = Instant::now(); loop { let after = dir_names(dir); @@ -206,38 +170,11 @@ fn poll_for_new(dir: &Path, before: &HashSet, timeout: Duration, relevan return Ok(name.clone()); } if start.elapsed() > timeout { - bail!("timed out after {timeout:?} waiting for a new entry in {}", dir.display()); + bail!( + "timed out after {timeout:?} waiting for a new entry in {}", + dir.display() + ); } std::thread::sleep(Duration::from_millis(100)); } } - -fn poll_for_client_address(outer_signature: &str, pid: u32, timeout: Duration) -> Result { - let start = Instant::now(); - loop { - if let Some(clients) = bread_utils::proc::run_json("hyprctl", &["--instance", outer_signature, "clients", "-j"], HYPRCTL_TIMEOUT) { - if let Some(arr) = clients.as_array() { - let found = arr - .iter() - .find(|c| c.get("pid").and_then(|v| v.as_u64()) == Some(pid as u64)) - .and_then(|c| c.get("address")) - .and_then(|v| v.as_str()); - if let Some(addr) = found { - return Ok(addr.to_string()); - } - } - } - if start.elapsed() > timeout { - bail!("timed out after {timeout:?} waiting for pid {pid}'s window in the outer session's client list"); - } - std::thread::sleep(Duration::from_millis(100)); - } -} - -fn outer_dispatch(outer_signature: &str, lua_expr: &str) -> Result<()> { - let result = bread_utils::proc::run("hyprctl", &["--instance", outer_signature, "dispatch", lua_expr], HYPRCTL_TIMEOUT); - if !result.success { - bail!("hyprctl dispatch failed ({lua_expr}): {}", result.stderr.trim()); - } - Ok(()) -} diff --git a/bread-capture/src/main.rs b/bread-capture/src/main.rs index 7d71a4b..3309bcc 100644 --- a/bread-capture/src/main.rs +++ b/bread-capture/src/main.rs @@ -7,7 +7,7 @@ //! `screenshots/vX.Y.Z/latest` structure or manifest file yet, since those //! only earn their complexity once more apps are wired up. //! -//! By default every capture runs inside a throwaway nested Hyprland instance +//! By default every capture runs inside a throwaway headless Sway instance //! (see [`isolation`]) rather than the operator's live desktop, so another //! window (or their own differently-themed real bar) can't leak into a //! capture. `--no-isolate` skips that and captures directly against whatever @@ -39,8 +39,8 @@ struct Cli { #[arg(long, default_value = "./screenshots")] out_dir: PathBuf, - /// Capture directly against the current session instead of a nested, - /// throwaway Hyprland instance. Off by default so captures can't pick up + /// Capture directly against the current session instead of a headless, + /// throwaway Sway instance. Off by default so captures can't pick up /// whatever else is on the operator's desktop. #[arg(long)] no_isolate: bool, @@ -63,13 +63,21 @@ fn main() -> Result<()> { Some(isolation::Isolation::start(cli.isolate_width, cli.isolate_height)?) }; + let width_str = cli.isolate_width.to_string(); + let height_str = cli.isolate_height.to_string(); + let mut failed = false; for (view, filename) in BREADBAR_TARGETS { let out_path = cli.out_dir.join(filename); let out_str = out_path.to_string_lossy(); let result = bread_utils::proc::run( &cli.app_path, - &["--screenshot", view, "--output", &out_str], + &[ + "--screenshot", view, + "--output", &out_str, + "--width", &width_str, + "--height", &height_str, + ], CAPTURE_TIMEOUT, ); if result.success { diff --git a/bread-screenshots/src/lib.rs b/bread-screenshots/src/lib.rs index b6bb610..d8e4eb8 100644 --- a/bread-screenshots/src/lib.rs +++ b/bread-screenshots/src/lib.rs @@ -1,13 +1,11 @@ -//! Capture primitives for the bread ecosystem's UI screenshot tooling (see +//! Capture primitive for the bread ecosystem's UI screenshot tooling (see //! `bread-capture`, the orchestrator that drives this crate's consumers). //! -//! A "view" being screenshotted is either: -//! - its own layer-shell surface (a bar, launcher, ...) — captured tightly via -//! [`capture_layer`], geometry from `hyprctl layers`. -//! - a transient popover/popup — not a separate layer surface under -//! gtk4-layer-shell (it's an xdg_popup Hyprland doesn't list individually), -//! so the only reliable capture is [`capture_output`]: whatever's currently -//! on the focused monitor. +//! Deliberately compositor-agnostic: no Hyprland IPC, no layer/output +//! lookup. `bread-capture` runs every target app inside an isolated, +//! headless compositor instance of a known, fixed size (see its +//! `isolation` module), so the caller already knows exactly what region to +//! grab — there's nothing to query. use anyhow::{bail, Context, Result}; use std::path::Path; @@ -15,36 +13,18 @@ use std::time::Duration; const GRIM_TIMEOUT: Duration = Duration::from_secs(5); -/// Capture a named layer-shell surface belonging to *this* process -/// (`std::process::id()`), identified by its `namespace` (e.g. `"breadbar"`). -/// Namespace alone can't identify "our own" surface when another instance -/// under the same namespace is already running (breadbar commonly is), so -/// this matches by pid too — see `bread_utils::hypr::find_layer`. -pub fn capture_layer(namespace: &str, out: &Path) -> Result<()> { - let layer = bread_utils::hypr::find_layer(namespace, std::process::id()) - .with_context(|| format!("no layer surface found for namespace={namespace}"))?; - let geometry = format!("{},{} {}x{}", layer.x, layer.y, layer.w, layer.h); - run_grim(&geometry, out) -} - -/// Capture the entire focused output (monitor). Used for views whose -/// interesting content isn't its own layer surface — see the module doc. -pub fn capture_output(out: &Path) -> Result<()> { - let monitor = bread_utils::hypr::focused_monitor().context("no focused monitor found")?; - let (w, h) = monitor.logical_size(); - let geometry = format!("{},{} {}x{}", monitor.x, monitor.y, w, h); - run_grim(&geometry, out) -} - -fn run_grim(geometry: &str, out: &Path) -> Result<()> { +/// Capture a `w`x`h` region at `(x, y)` (compositor-global coordinates) to +/// `out` via `grim -g`. +pub fn capture_region(x: i32, y: i32, w: i32, h: i32, out: &Path) -> Result<()> { if let Some(parent) = out.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("creating {}", parent.display()))?; } let out_str = out.to_str().context("output path is not valid UTF-8")?; - let result = bread_utils::proc::run("grim", &["-g", geometry, out_str], GRIM_TIMEOUT); + let geometry = format!("{x},{y} {w}x{h}"); + let result = bread_utils::proc::run("grim", &["-g", &geometry, out_str], GRIM_TIMEOUT); if !result.success { - bail!("grim failed for geometry {geometry}: {}", result.stderr); + bail!("grim failed for geometry {geometry}: {}", result.stderr.trim()); } Ok(()) } diff --git a/bread-utils/src/hypr.rs b/bread-utils/src/hypr.rs index 228c53c..def3e98 100644 --- a/bread-utils/src/hypr.rs +++ b/bread-utils/src/hypr.rs @@ -152,81 +152,10 @@ pub struct Monitor { pub y: i32, pub width: i32, pub height: i32, - #[serde(default = "one")] - pub scale: f64, - #[serde(default)] - pub transform: i64, #[serde(default)] pub focused: bool, } -fn one() -> f64 { - 1.0 -} - -impl Monitor { - /// Logical (scaled, transform-aware) output size, matching what `grim -g` - /// expects — `width`/`height` above are physical pixels. A 90/270-degree - /// transform swaps the axes before the scale divide, same as breadshot's - /// `monitor_geometry`, which this mirrors. - pub fn logical_size(&self) -> (i32, i32) { - if self.transform % 2 == 0 { - ( - (self.width as f64 / self.scale).round() as i32, - (self.height as f64 / self.scale).round() as i32, - ) - } else { - ( - (self.height as f64 / self.scale).round() as i32, - (self.width as f64 / self.scale).round() as i32, - ) - } - } -} - -/// One entry from `hyprctl layers -j` — a layer-shell surface (bar, launcher, -/// lock screen, ...), keyed by the `namespace` its client registered. -#[derive(Debug, Clone, Deserialize)] -pub struct Layer { - pub namespace: String, - pub pid: u32, - pub x: i32, - pub y: i32, - pub w: i32, - pub h: i32, -} - -/// Find a layer-shell surface by namespace *and* pid. Namespace alone isn't -/// enough to identify "our own" surface — several bread apps (breadbar -/// notably) are typically already running under the same namespace when a -/// second instance starts up for some other purpose (e.g. screenshot mode), -/// so callers pass their own `std::process::id()` to disambiguate. -pub fn find_layer(namespace: &str, pid: u32) -> Option { - find_layer_in(&request_json("j/layers")?, namespace, pid) -} - -/// Parsing half of [`find_layer`], split out so it's testable without a live -/// Hyprland socket. -fn find_layer_in(root: &serde_json::Value, namespace: &str, pid: u32) -> Option { - let monitors = root.as_object()?; - for monitor in monitors.values() { - let Some(levels) = monitor.get("levels").and_then(|v| v.as_object()) else { - continue; - }; - for layers in levels.values() { - for layer in layers.as_array().into_iter().flatten() { - let Ok(l) = serde_json::from_value::(layer.clone()) else { - continue; - }; - if l.namespace == namespace && l.pid == pid { - return Some(l); - } - } - } - } - None -} - /// Query the currently active (focused) window. Returns `None` if the /// window is fullscreen or no window is focused — same "centre the popup /// instead" contract `breadclip`'s original `get_active_window` had. @@ -261,77 +190,6 @@ pub fn active_workspace_name() -> Option { mod tests { use super::*; - #[test] - fn logical_size_divides_by_scale() { - let m = Monitor { - name: "eDP-1".into(), - x: 0, - y: 0, - width: 3840, - height: 2400, - scale: 2.0, - transform: 0, - focused: true, - }; - assert_eq!(m.logical_size(), (1920, 1200)); - } - - #[test] - fn logical_size_swaps_axes_on_rotated_transform() { - let m = Monitor { - name: "eDP-1".into(), - x: 0, - y: 0, - width: 1920, - height: 1200, - scale: 1.0, - transform: 1, - focused: true, - }; - assert_eq!(m.logical_size(), (1200, 1920)); - } - - // Real shape confirmed live via `hyprctl layers -j`: per-monitor map -> - // "levels" map (layer index "0".."3") -> array of layer objects. - const LAYERS_JSON: &str = r#"{ - "eDP-1": { - "levels": { - "0": [ - {"address":"0x1","x":0,"y":0,"w":1920,"h":1200,"namespace":"awww-daemon","pid":2481} - ], - "1": [], - "2": [ - {"address":"0x2","x":0,"y":0,"w":1920,"h":32,"namespace":"breadbar","pid":1601316}, - {"address":"0x3","x":0,"y":0,"w":1920,"h":32,"namespace":"breadbar","pid":9999} - ], - "3": [] - } - } - }"#; - - #[test] - fn find_layer_in_matches_namespace_and_pid() { - let root: serde_json::Value = serde_json::from_str(LAYERS_JSON).unwrap(); - let layer = find_layer_in(&root, "breadbar", 9999).unwrap(); - assert_eq!(layer.pid, 9999); - assert_eq!(layer.w, 1920); - assert_eq!(layer.h, 32); - } - - #[test] - fn find_layer_in_ignores_same_namespace_wrong_pid() { - let root: serde_json::Value = serde_json::from_str(LAYERS_JSON).unwrap(); - // pid 1601316 is a real breadbar layer in the fixture, but not the one - // we're asking for — must not fall back to it. - assert!(find_layer_in(&root, "breadbar", 42).is_none()); - } - - #[test] - fn find_layer_in_returns_none_for_missing_namespace() { - let root: serde_json::Value = serde_json::from_str(LAYERS_JSON).unwrap(); - assert!(find_layer_in(&root, "breadbox", 1601316).is_none()); - } - #[test] fn fullscreen_state_deserializes_from_bool() { let s: FullscreenState = serde_json::from_str("true").unwrap(); From 21099065c49c4de13b470e7b7412c34092f3ec77 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 29 Jul 2026 11:33:06 +0800 Subject: [PATCH 27/99] bread-capture: generalize target registry, fix Drop-skipped-on-exit leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the breadbar-only hardcoded view list with a registry keyed by app name (--app-name, defaulting to --app-path's file stem) so wiring up each new app just means adding one entry, not touching the CLI shape. Also fixes a real leak found while testing breadbox against this: std::process::exit() on the failure path skipped every destructor, including Isolation's Drop — so a failed capture run (or, more subtly, *any* run against an app whose view list didn't match yet, which is exactly what happened testing this) permanently orphaned the headless Sway process and its wayland-N/.lock socket pair. main() now returns ExitCode instead of calling process::exit directly, so Drop always runs. --- bread-capture/src/main.rs | 66 ++++++++++++++++++++++++++++----------- 1 file changed, 47 insertions(+), 19 deletions(-) diff --git a/bread-capture/src/main.rs b/bread-capture/src/main.rs index 3309bcc..e72d785 100644 --- a/bread-capture/src/main.rs +++ b/bread-capture/src/main.rs @@ -2,10 +2,13 @@ //! //! Drives each target app's `--screenshot --output ` mode (see //! `bread-screenshots` for what that mode does inside the app) and reports -//! pass/fail per view. Foundation-phase scope: one target (breadbar), a -//! hardcoded view list, and a flat output directory — no versioned -//! `screenshots/vX.Y.Z/latest` structure or manifest file yet, since those -//! only earn their complexity once more apps are wired up. +//! pass/fail per view. One app per invocation, selected by `--app-name` +//! (defaults to `--app-path`'s file stem, so `--app-path +//! ./target/release/breadbox` needs no separate `--app-name`) — the view +//! list for each app is looked up from [`TARGETS`] below. Flat output +//! directory for now — no versioned `screenshots/vX.Y.Z/latest` structure +//! or manifest file yet, since that's still not earning its complexity over +//! a handful of apps. //! //! By default every capture runs inside a throwaway headless Sway instance //! (see [`isolation`]) rather than the operator's live desktop, so another @@ -16,25 +19,35 @@ mod isolation; -use anyhow::Result; +use anyhow::{bail, Result}; use clap::Parser; use std::path::PathBuf; +use std::process::ExitCode; use std::time::Duration; const CAPTURE_TIMEOUT: Duration = Duration::from_secs(10); -/// (view name, output filename) -const BREADBAR_TARGETS: &[(&str, &str)] = &[ - ("bar", "breadbar-bar.png"), - ("control-panel", "breadbar-control-panel.png"), +/// Per-app (view name, output filename) lists. Keyed by the app's binary +/// name — see `--app-name`. +const TARGETS: &[(&str, &[(&str, &str)])] = &[ + ( + "breadbar", + &[("bar", "breadbar-bar.png"), ("control-panel", "breadbar-control-panel.png")], + ), + ("breadbox", &[("launcher", "breadbox-launcher.png")]), ]; #[derive(Parser)] struct Cli { - /// Path to the breadbar binary (resolved via $PATH if not a path). - #[arg(long, default_value = "breadbar")] + /// Path to the target app's binary (resolved via $PATH if not a path). + #[arg(long)] app_path: String, + /// Which app's view list to use (see `TARGETS`). Defaults to + /// `--app-path`'s file stem, e.g. `./target/release/breadbox` -> `breadbox`. + #[arg(long)] + app_name: Option, + /// Directory to write captured PNGs into. #[arg(long, default_value = "./screenshots")] out_dir: PathBuf, @@ -54,9 +67,27 @@ struct Cli { isolate_height: u32, } -fn main() -> Result<()> { +fn main() -> Result { let cli = Cli::parse(); + let app_name = cli.app_name.clone().unwrap_or_else(|| { + PathBuf::from(&cli.app_path) + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| cli.app_path.clone()) + }); + let Some((_, views)) = TARGETS.iter().find(|(name, _)| *name == app_name) else { + bail!( + "no known view list for app '{app_name}' (known: {})", + TARGETS.iter().map(|(n, _)| *n).collect::>().join(", ") + ); + }; + + // Bound, not dropped-and-discarded: `_isolation`'s teardown (kill the + // compositor, remove its socket/config) must run via Drop regardless of + // how this function returns below — returning an ExitCode rather than + // calling `std::process::exit` (which skips destructors entirely) is + // what makes that true on the failure path too. let _isolation = if cli.no_isolate { None } else { @@ -67,7 +98,7 @@ fn main() -> Result<()> { let height_str = cli.isolate_height.to_string(); let mut failed = false; - for (view, filename) in BREADBAR_TARGETS { + for (view, filename) in *views { let out_path = cli.out_dir.join(filename); let out_str = out_path.to_string_lossy(); let result = bread_utils::proc::run( @@ -81,15 +112,12 @@ fn main() -> Result<()> { CAPTURE_TIMEOUT, ); if result.success { - println!("ok breadbar/{view} -> {}", out_path.display()); + println!("ok {app_name}/{view} -> {}", out_path.display()); } else { failed = true; - println!("FAIL breadbar/{view}: {}", result.stderr.trim()); + println!("FAIL {app_name}/{view}: {}", result.stderr.trim()); } } - if failed { - std::process::exit(1); - } - Ok(()) + Ok(if failed { ExitCode::FAILURE } else { ExitCode::SUCCESS }) } From 231f71e586b13837379e2938d8651aca4b7257e8 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 29 Jul 2026 11:37:17 +0800 Subject: [PATCH 28/99] bread-capture: add breadclip to target registry --- bread-capture/src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/bread-capture/src/main.rs b/bread-capture/src/main.rs index e72d785..def943b 100644 --- a/bread-capture/src/main.rs +++ b/bread-capture/src/main.rs @@ -35,6 +35,7 @@ const TARGETS: &[(&str, &[(&str, &str)])] = &[ &[("bar", "breadbar-bar.png"), ("control-panel", "breadbar-control-panel.png")], ), ("breadbox", &[("launcher", "breadbox-launcher.png")]), + ("breadclip", &[("history", "breadclip-history.png")]), ]; #[derive(Parser)] From f06ba904b79bbd2303118b8ef684ec556fde7793 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 29 Jul 2026 11:40:28 +0800 Subject: [PATCH 29/99] bread-capture: add breadsearch to target registry --- bread-capture/src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/bread-capture/src/main.rs b/bread-capture/src/main.rs index def943b..f359b58 100644 --- a/bread-capture/src/main.rs +++ b/bread-capture/src/main.rs @@ -36,6 +36,7 @@ const TARGETS: &[(&str, &[(&str, &str)])] = &[ ), ("breadbox", &[("launcher", "breadbox-launcher.png")]), ("breadclip", &[("history", "breadclip-history.png")]), + ("breadsearch", &[("search", "breadsearch-search.png")]), ]; #[derive(Parser)] From 03749787c5a2e1c4497ca949ce8e4d342e450408 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 29 Jul 2026 11:44:00 +0800 Subject: [PATCH 30/99] bread-capture: add breadpad to target registry --- bread-capture/src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/bread-capture/src/main.rs b/bread-capture/src/main.rs index f359b58..c365a1b 100644 --- a/bread-capture/src/main.rs +++ b/bread-capture/src/main.rs @@ -37,6 +37,7 @@ const TARGETS: &[(&str, &[(&str, &str)])] = &[ ("breadbox", &[("launcher", "breadbox-launcher.png")]), ("breadclip", &[("history", "breadclip-history.png")]), ("breadsearch", &[("search", "breadsearch-search.png")]), + ("breadpad", &[("popup", "breadpad-popup.png")]), ]; #[derive(Parser)] From 1ce55149399ec675cadd41104d61911eded90c8e Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 29 Jul 2026 11:49:33 +0800 Subject: [PATCH 31/99] bread-capture: add breadhelp to target registry --- bread-capture/src/main.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bread-capture/src/main.rs b/bread-capture/src/main.rs index c365a1b..d7c4416 100644 --- a/bread-capture/src/main.rs +++ b/bread-capture/src/main.rs @@ -38,6 +38,14 @@ const TARGETS: &[(&str, &[(&str, &str)])] = &[ ("breadclip", &[("history", "breadclip-history.png")]), ("breadsearch", &[("search", "breadsearch-search.png")]), ("breadpad", &[("popup", "breadpad-popup.png")]), + ( + "breadhelp", + &[ + ("home", "breadhelp-home.png"), + ("learn", "breadhelp-learn.png"), + ("ask", "breadhelp-ask.png"), + ], + ), ]; #[derive(Parser)] From af796253e99abdf06976607a92fbfa459042f1cf Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 29 Jul 2026 16:37:14 +0800 Subject: [PATCH 32/99] bread-capture: add breadman to target registry --- bread-capture/src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/bread-capture/src/main.rs b/bread-capture/src/main.rs index d7c4416..175974e 100644 --- a/bread-capture/src/main.rs +++ b/bread-capture/src/main.rs @@ -46,6 +46,7 @@ const TARGETS: &[(&str, &[(&str, &str)])] = &[ ("ask", "breadhelp-ask.png"), ], ), + ("breadman", &[("all", "breadman-all.png")]), ]; #[derive(Parser)] From cdcb931f37b74207571a832e04428a2bdf326e20 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 29 Jul 2026 16:48:01 +0800 Subject: [PATCH 33/99] bread-capture: add bos-settings to target registry --- bread-capture/src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/bread-capture/src/main.rs b/bread-capture/src/main.rs index 175974e..022b9ef 100644 --- a/bread-capture/src/main.rs +++ b/bread-capture/src/main.rs @@ -47,6 +47,7 @@ const TARGETS: &[(&str, &[(&str, &str)])] = &[ ], ), ("breadman", &[("all", "breadman-all.png")]), + ("bos-settings", &[("default", "bos-settings-default.png")]), ]; #[derive(Parser)] From e3df426996ab67c188e55fddedf36e4fbcadd9f4 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 29 Jul 2026 17:02:09 +0800 Subject: [PATCH 34/99] bread-capture: rainbow-gradient isolation background instead of flat color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A solid background can't reveal whether a surface that's supposed to be translucent (breadbox/breadclip/breadsearch's full-screen overlay windows, breadbar's alpha-blended notification/OSD surfaces) is actually compositing as translucent — a flat color showing through a flat color still just looks flat. Generates a diagonal rainbow gradient (full hue sweep, via the `image` crate) sized exactly to the capture canvas and sets it as Sway's output background instead. Needed installing swaybg (the external helper Sway's `output ... bg` config directive shells out to) — without it the bg command silently no-ops and the canvas stays black, which is why the first attempt at this looked identical to the old flat-color version. --- Cargo.lock | 64 +++++++++++++++++++++++++++++++ bread-capture/Cargo.toml | 3 ++ bread-capture/src/isolation.rs | 69 ++++++++++++++++++++++++++++++---- 3 files changed, 128 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e32afd2..b0ebcac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -155,6 +155,7 @@ dependencies = [ "anyhow", "bread-utils", "clap", + "image", ] [[package]] @@ -222,6 +223,18 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "cairo-rs" version = "0.22.0" @@ -591,6 +604,15 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "field-offset" version = "0.3.6" @@ -1183,6 +1205,19 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1362,6 +1397,16 @@ dependencies = [ "syn", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "ndarray" version = "0.17.2" @@ -1519,6 +1564,19 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "portable-atomic" version = "1.14.0" @@ -1570,6 +1628,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + [[package]] name = "quote" version = "1.0.46" diff --git a/bread-capture/Cargo.toml b/bread-capture/Cargo.toml index b87cbda..763f7fa 100644 --- a/bread-capture/Cargo.toml +++ b/bread-capture/Cargo.toml @@ -16,3 +16,6 @@ path = "src/main.rs" bread-utils = { path = "../bread-utils" } clap = { workspace = true } anyhow = { workspace = true } +# Generates the isolated capture canvas's rainbow-gradient background — see +# isolation.rs. png-only: no decoding, no other format support needed. +image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/bread-capture/src/isolation.rs b/bread-capture/src/isolation.rs index ef43da2..f1506b0 100644 --- a/bread-capture/src/isolation.rs +++ b/bread-capture/src/isolation.rs @@ -39,6 +39,17 @@ //! breadbar already has to tolerate a missing/dead Hyprland connection //! gracefully (it survives Hyprland restarting), so this just exercises that //! same fallback path instead of a real error case. +//! +//! The background is a generated rainbow gradient, not a flat colour — +//! deliberately: a solid fill can't tell you whether a window that's +//! *supposed* to be translucent (breadbox/breadclip/breadsearch's +//! full-screen overlay windows, breadbar's notification/OSD surfaces) is +//! actually compositing as translucent, since a flat colour showing through +//! a flat colour still just looks flat. A continuously-varying gradient +//! makes any real transparency immediately obvious (multiple hues bleed +//! through) and any accidentally-opaque surface just as obvious (it blocks +//! the gradient out entirely, a flat rectangle where there should be colour +//! variation). use anyhow::{bail, Context, Result}; use std::collections::HashSet; @@ -48,15 +59,11 @@ use std::time::{Duration, Instant}; const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(5); -/// Matches bread-theme's `FIXED_BACKGROUND` (`bread-theme/src/palette.rs`) — -/// so the empty canvas behind a capture reads as "the app's own dark theme", -/// not an arbitrary compositor default. -const BACKGROUND_COLOR: &str = "#0c0c0c"; - pub struct Isolation { child: Child, pub wayland_display: String, config_path: PathBuf, + background_path: PathBuf, runtime_dir: PathBuf, } @@ -70,7 +77,8 @@ impl Isolation { let runtime_dir = PathBuf::from( std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".to_string()), ); - let config_path = write_headless_config(width, height)?; + let background_path = write_rainbow_background(width, height)?; + let config_path = write_headless_config(width, height, &background_path)?; let before_sockets: HashSet = dir_names(&runtime_dir) .into_iter() .filter(|n| is_wayland_socket_name(n)) @@ -91,6 +99,7 @@ impl Isolation { child, wayland_display: String::new(), config_path, + background_path, runtime_dir: runtime_dir.clone(), }; @@ -122,6 +131,7 @@ impl Drop for Isolation { let _ = self.child.kill(); let _ = self.child.wait(); let _ = std::fs::remove_file(&self.config_path); + let _ = std::fs::remove_file(&self.background_path); // Killing sway doesn't unlink the socket it bound — confirmed // empirically, a killed instance leaves both files behind — so // without this, every capture run permanently orphans a @@ -133,16 +143,59 @@ impl Drop for Isolation { } } -fn write_headless_config(width: u32, height: u32) -> Result { +fn write_headless_config(width: u32, height: u32, background_path: &Path) -> Result { let path = std::env::temp_dir().join(format!("bread-capture-sway-{}.conf", std::process::id())); + let bg = background_path.display(); let contents = format!( "output HEADLESS-1 resolution {width}x{height}\n\ - output HEADLESS-1 bg {BACKGROUND_COLOR} solid_color\n" + output HEADLESS-1 bg {bg} stretch\n" ); std::fs::write(&path, contents).with_context(|| format!("writing {}", path.display()))?; Ok(path) } +/// Renders a diagonal rainbow (full hue sweep, both x and y contribute) to a +/// PNG at exactly `width`x`height`, for use as the isolated canvas's +/// background — see the module doc for why a gradient instead of a flat +/// colour. Diagonal rather than a simple left-to-right sweep so a capture +/// showing color variation isn't just luck-of-the-x-position: a purely +/// horizontal gradient would still make a tall, narrow surface look like a +/// near-flat single hue. +fn write_rainbow_background(width: u32, height: u32) -> Result { + let path = std::env::temp_dir().join(format!("bread-capture-bg-{}.png", std::process::id())); + let mut img = image::RgbImage::new(width.max(1), height.max(1)); + let denom = (width + height).max(1) as f32; + for y in 0..img.height() { + for x in 0..img.width() { + let hue = ((x + y) as f32 / denom) * 360.0; + img.put_pixel(x, y, image::Rgb(hsv_to_rgb(hue, 0.85, 0.95))); + } + } + img.save(&path).with_context(|| format!("writing {}", path.display()))?; + Ok(path) +} + +/// Standard HSV -> RGB conversion. `h` in degrees [0, 360), `s`/`v` in [0, 1]. +fn hsv_to_rgb(h: f32, s: f32, v: f32) -> [u8; 3] { + let c = v * s; + let h_prime = (h / 60.0) % 6.0; + let x = c * (1.0 - (h_prime % 2.0 - 1.0).abs()); + let m = v - c; + let (r1, g1, b1) = match h_prime as u32 { + 0 => (c, x, 0.0), + 1 => (x, c, 0.0), + 2 => (0.0, c, x), + 3 => (0.0, x, c), + 4 => (x, 0.0, c), + _ => (c, 0.0, x), + }; + [ + ((r1 + m) * 255.0).round() as u8, + ((g1 + m) * 255.0).round() as u8, + ((b1 + m) * 255.0).round() as u8, + ] +} + fn dir_names(path: &Path) -> HashSet { std::fs::read_dir(path) .into_iter() From 64cc17905de1c1afd65ac5b81305935da7b8d939 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 29 Jul 2026 17:17:35 +0800 Subject: [PATCH 35/99] bread-capture: add breadbar's 8 new views to target registry --- bread-capture/src/main.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/bread-capture/src/main.rs b/bread-capture/src/main.rs index 022b9ef..b4e2558 100644 --- a/bread-capture/src/main.rs +++ b/bread-capture/src/main.rs @@ -32,7 +32,18 @@ const CAPTURE_TIMEOUT: Duration = Duration::from_secs(10); const TARGETS: &[(&str, &[(&str, &str)])] = &[ ( "breadbar", - &[("bar", "breadbar-bar.png"), ("control-panel", "breadbar-control-panel.png")], + &[ + ("bar", "breadbar-bar.png"), + ("control-panel", "breadbar-control-panel.png"), + ("connectivity-wifi", "breadbar-connectivity-wifi.png"), + ("connectivity-bluetooth", "breadbar-connectivity-bluetooth.png"), + ("media-popover", "breadbar-media-popover.png"), + ("notification", "breadbar-notification.png"), + ("notification-critical", "breadbar-notification-critical.png"), + ("osd-volume", "breadbar-osd-volume.png"), + ("osd-brightness", "breadbar-osd-brightness.png"), + ("wifi-add-dialog", "breadbar-wifi-add-dialog.png"), + ], ), ("breadbox", &[("launcher", "breadbox-launcher.png")]), ("breadclip", &[("history", "breadclip-history.png")]), From c0b489fa67db30257c2f12fe7b971bd238a87fdd Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 29 Jul 2026 17:27:12 +0800 Subject: [PATCH 36/99] bread-capture: add breadman's 11 additional views to target registry --- bread-capture/src/main.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/bread-capture/src/main.rs b/bread-capture/src/main.rs index b4e2558..a58dc49 100644 --- a/bread-capture/src/main.rs +++ b/bread-capture/src/main.rs @@ -57,7 +57,23 @@ const TARGETS: &[(&str, &[(&str, &str)])] = &[ ("ask", "breadhelp-ask.png"), ], ), - ("breadman", &[("all", "breadman-all.png")]), + ( + "breadman", + &[ + ("all", "breadman-all.png"), + ("upcoming", "breadman-upcoming.png"), + ("todo", "breadman-todo.png"), + ("reminder", "breadman-reminder.png"), + ("idea", "breadman-idea.png"), + ("note", "breadman-note.png"), + ("question", "breadman-question.png"), + ("archive", "breadman-archive.png"), + ("settings", "breadman-settings.png"), + ("errors", "breadman-errors.png"), + ("editor", "breadman-editor.png"), + ("new-note", "breadman-new-note.png"), + ], + ), ("bos-settings", &[("default", "bos-settings-default.png")]), ]; From 271555fb80c867e5473a55eb57ab5678c9b5c2d1 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 29 Jul 2026 21:32:44 +0800 Subject: [PATCH 37/99] bread-capture: add breadpad's reminder + reminder-snooze views to target registry --- bread-capture/src/main.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/bread-capture/src/main.rs b/bread-capture/src/main.rs index a58dc49..99f5b7e 100644 --- a/bread-capture/src/main.rs +++ b/bread-capture/src/main.rs @@ -48,7 +48,14 @@ const TARGETS: &[(&str, &[(&str, &str)])] = &[ ("breadbox", &[("launcher", "breadbox-launcher.png")]), ("breadclip", &[("history", "breadclip-history.png")]), ("breadsearch", &[("search", "breadsearch-search.png")]), - ("breadpad", &[("popup", "breadpad-popup.png")]), + ( + "breadpad", + &[ + ("popup", "breadpad-popup.png"), + ("reminder", "breadpad-reminder.png"), + ("reminder-snooze", "breadpad-reminder-snooze.png"), + ], + ), ( "breadhelp", &[ From 669ca642847f7897cf952c69315a7997a2c00c5b Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 29 Jul 2026 21:37:33 +0800 Subject: [PATCH 38/99] bread-capture: add breadhelp's troubleshoot-wizard view to target registry --- bread-capture/src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/bread-capture/src/main.rs b/bread-capture/src/main.rs index 99f5b7e..f6d5427 100644 --- a/bread-capture/src/main.rs +++ b/bread-capture/src/main.rs @@ -62,6 +62,7 @@ const TARGETS: &[(&str, &[(&str, &str)])] = &[ ("home", "breadhelp-home.png"), ("learn", "breadhelp-learn.png"), ("ask", "breadhelp-ask.png"), + ("troubleshoot-wizard", "breadhelp-troubleshoot-wizard.png"), ], ), ( From 94feaa6f9be7feb6b1f0c4c75cff2f2994f4e0d7 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 29 Jul 2026 21:45:18 +0800 Subject: [PATCH 39/99] bread-capture: add bos-settings' 24 sidebar-section views to target registry --- bread-capture/src/main.rs | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/bread-capture/src/main.rs b/bread-capture/src/main.rs index f6d5427..2f26756 100644 --- a/bread-capture/src/main.rs +++ b/bread-capture/src/main.rs @@ -82,7 +82,35 @@ const TARGETS: &[(&str, &[(&str, &str)])] = &[ ("new-note", "breadman-new-note.png"), ], ), - ("bos-settings", &[("default", "bos-settings-default.png")]), + ( + "bos-settings", + &[ + ("network", "bos-settings-network.png"), + ("breadcrumbs", "bos-settings-breadcrumbs.png"), + ("bluetooth", "bos-settings-bluetooth.png"), + ("firewall", "bos-settings-firewall.png"), + ("sound", "bos-settings-sound.png"), + ("power", "bos-settings-power.png"), + ("datetime", "bos-settings-datetime.png"), + ("hyprland", "bos-settings-hyprland.png"), + ("keybinds", "bos-settings-keybinds.png"), + ("autostart", "bos-settings-autostart.png"), + ("users", "bos-settings-users.png"), + ("appearance", "bos-settings-appearance.png"), + ("breadpaper", "bos-settings-breadpaper.png"), + ("breadbar", "bos-settings-breadbar.png"), + ("breadbox", "bos-settings-breadbox.png"), + ("breadclip", "bos-settings-breadclip.png"), + ("breadpad", "bos-settings-breadpad.png"), + ("breadsearch", "bos-settings-breadsearch.png"), + ("bread", "bos-settings-bread.png"), + ("packages", "bos-settings-packages.png"), + ("aur", "bos-settings-aur.png"), + ("firmware", "bos-settings-firmware.png"), + ("snapshots", "bos-settings-snapshots.png"), + ("about", "bos-settings-about.png"), + ], + ), ]; #[derive(Parser)] From 7ec232b86d8e1dd3e9bb993314c1a08b220d84e2 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 29 Jul 2026 21:54:46 +0800 Subject: [PATCH 40/99] bread-capture: one command for every app, flags for a single one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plain `bread-capture` with no flags now captures every known app's every view in one run — each binary resolved by its own bare name via $PATH, same as invoking it directly by name would (so an installed bread ecosystem needs nothing but `bread-capture` to regenerate every screenshot). Previously --app-path was required, so there was no way to run more than one app per invocation. --app restricts to a single app (resolved via $PATH, no path needed); --app-path still works alone too, inferring which app by its file stem exactly as before. --view further restricts to one view — apps without a matching view are silently skipped rather than treated as an error, since view names naturally don't overlap across apps in a multi-app run, but an unmatched --view in a single-app run (or one that matches nothing across every selected app) is still a real error. --- bread-capture/src/main.rs | 127 ++++++++++++++++++++++++++------------ 1 file changed, 88 insertions(+), 39 deletions(-) diff --git a/bread-capture/src/main.rs b/bread-capture/src/main.rs index 2f26756..5af8be9 100644 --- a/bread-capture/src/main.rs +++ b/bread-capture/src/main.rs @@ -2,10 +2,14 @@ //! //! Drives each target app's `--screenshot --output ` mode (see //! `bread-screenshots` for what that mode does inside the app) and reports -//! pass/fail per view. One app per invocation, selected by `--app-name` -//! (defaults to `--app-path`'s file stem, so `--app-path -//! ./target/release/breadbox` needs no separate `--app-name`) — the view -//! list for each app is looked up from [`TARGETS`] below. Flat output +//! pass/fail per view/app. Plain `bread-capture` with no flags captures +//! every known app's every view in one run — each app's binary is resolved +//! by its own bare name via `$PATH`, same as running it directly by name +//! would. `--app ` restricts to one app; `--app-path ` +//! overrides where its binary is found (and, without `--app`, also selects +//! which app by its file stem — so `--app-path ./target/release/breadbox` +//! alone still works); `--view ` further restricts to one view. The +//! view list for each app is looked up from [`TARGETS`] below. Flat output //! directory for now — no versioned `screenshots/vX.Y.Z/latest` structure //! or manifest file yet, since that's still not earning its complexity over //! a handful of apps. @@ -115,14 +119,26 @@ const TARGETS: &[(&str, &[(&str, &str)])] = &[ #[derive(Parser)] struct Cli { - /// Path to the target app's binary (resolved via $PATH if not a path). + /// Restrict to one app (see `TARGETS` for known names). Omit to capture + /// every known app's every view in one run. #[arg(long)] - app_path: String, + app: Option, - /// Which app's view list to use (see `TARGETS`). Defaults to - /// `--app-path`'s file stem, e.g. `./target/release/breadbox` -> `breadbox`. + /// Path to that app's binary (resolved via $PATH if not a path). + /// Without `--app`, this also selects *which* app by its file stem + /// (e.g. `./target/release/breadbox` -> `breadbox`) — so a single-app + /// run never needs both flags. Ignored (with a warning) if given + /// together with a multi-app run (no `--app`, and the path isn't + /// resolvable to exactly one app). #[arg(long)] - app_name: Option, + app_path: Option, + + /// Restrict to one view within the selected app(s) (see each app's + /// entry in `TARGETS` for known view names). Apps that don't have a + /// view by this name are skipped, not treated as an error, since a + /// multi-app run's view names naturally don't all overlap. + #[arg(long)] + view: Option, /// Directory to write captured PNGs into. #[arg(long, default_value = "./screenshots")] @@ -143,21 +159,49 @@ struct Cli { isolate_height: u32, } -fn main() -> Result { - let cli = Cli::parse(); +fn known_app_names() -> String { + TARGETS.iter().map(|(n, _)| *n).collect::>().join(", ") +} - let app_name = cli.app_name.clone().unwrap_or_else(|| { - PathBuf::from(&cli.app_path) +/// (app_name, binary_path, views) per selected app. +type SelectedTarget = (&'static str, String, &'static [(&'static str, &'static str)]); + +/// Resolves which `TARGETS` entries this run covers, and the binary path +/// to use for each. +fn selected_targets(cli: &Cli) -> Result> { + if let Some(app) = &cli.app { + let Some((name, views)) = TARGETS.iter().find(|(n, _)| n == app) else { + bail!("no known view list for app '{app}' (known: {})", known_app_names()); + }; + let path = cli.app_path.clone().unwrap_or_else(|| name.to_string()); + return Ok(vec![(name, path, views)]); + } + + if let Some(path) = &cli.app_path { + let stem = PathBuf::from(path) .file_stem() .map(|s| s.to_string_lossy().into_owned()) - .unwrap_or_else(|| cli.app_path.clone()) - }); - let Some((_, views)) = TARGETS.iter().find(|(name, _)| *name == app_name) else { - bail!( - "no known view list for app '{app_name}' (known: {})", - TARGETS.iter().map(|(n, _)| *n).collect::>().join(", ") - ); - }; + .unwrap_or_else(|| path.clone()); + let Some((name, views)) = TARGETS.iter().find(|(n, _)| *n == stem) else { + bail!("no known view list for app '{stem}' (known: {})", known_app_names()); + }; + return Ok(vec![(name, path.clone(), views)]); + } + + // No --app / --app-path at all: every known app, resolved by its own + // bare name via $PATH. + Ok(TARGETS.iter().map(|(name, views)| (*name, name.to_string(), *views)).collect()) +} + +fn main() -> Result { + let cli = Cli::parse(); + let targets = selected_targets(&cli)?; + + if let Some(view) = &cli.view { + if !targets.iter().any(|(_, _, views)| views.iter().any(|(v, _)| v == view)) { + bail!("view '{view}' doesn't match any selected app's views"); + } + } // Bound, not dropped-and-discarded: `_isolation`'s teardown (kill the // compositor, remove its socket/config) must run via Drop regardless of @@ -174,24 +218,29 @@ fn main() -> Result { let height_str = cli.isolate_height.to_string(); let mut failed = false; - for (view, filename) in *views { - let out_path = cli.out_dir.join(filename); - let out_str = out_path.to_string_lossy(); - let result = bread_utils::proc::run( - &cli.app_path, - &[ - "--screenshot", view, - "--output", &out_str, - "--width", &width_str, - "--height", &height_str, - ], - CAPTURE_TIMEOUT, - ); - if result.success { - println!("ok {app_name}/{view} -> {}", out_path.display()); - } else { - failed = true; - println!("FAIL {app_name}/{view}: {}", result.stderr.trim()); + for (app_name, app_path, views) in &targets { + for (view, filename) in *views { + if cli.view.as_deref().is_some_and(|v| v != *view) { + continue; + } + let out_path = cli.out_dir.join(filename); + let out_str = out_path.to_string_lossy(); + let result = bread_utils::proc::run( + app_path, + &[ + "--screenshot", view, + "--output", &out_str, + "--width", &width_str, + "--height", &height_str, + ], + CAPTURE_TIMEOUT, + ); + if result.success { + println!("ok {app_name}/{view} -> {}", out_path.display()); + } else { + failed = true; + println!("FAIL {app_name}/{view}: {}", result.stderr.trim()); + } } } From 7c7881ddf43d410b75e3106bf1fd61f25a228704 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 29 Jul 2026 22:02:54 +0800 Subject: [PATCH 41/99] bread-capture: write each app's captures into its own subfolder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the app-name filename prefix (redundant with the folder name) — output is now //.png instead of a flat /-.png. Makes browsing a multi-app run's output directory clearer, and is what a real screenshots-folder deliverable should look like. --- bread-capture/src/main.rs | 127 +++++++++++++++++++------------------- 1 file changed, 65 insertions(+), 62 deletions(-) diff --git a/bread-capture/src/main.rs b/bread-capture/src/main.rs index 5af8be9..e05a756 100644 --- a/bread-capture/src/main.rs +++ b/bread-capture/src/main.rs @@ -9,10 +9,11 @@ //! overrides where its binary is found (and, without `--app`, also selects //! which app by its file stem — so `--app-path ./target/release/breadbox` //! alone still works); `--view ` further restricts to one view. The -//! view list for each app is looked up from [`TARGETS`] below. Flat output -//! directory for now — no versioned `screenshots/vX.Y.Z/latest` structure -//! or manifest file yet, since that's still not earning its complexity over -//! a handful of apps. +//! view list for each app is looked up from [`TARGETS`] below. Each app +//! gets its own subdirectory under `--out-dir` (`//.png`) +//! — no versioned `screenshots/vX.Y.Z/latest` structure or manifest file +//! yet, since that's still not earning its complexity over a handful of +//! apps. //! //! By default every capture runs inside a throwaway headless Sway instance //! (see [`isolation`]) rather than the operator's live desktop, so another @@ -32,87 +33,89 @@ use std::time::Duration; const CAPTURE_TIMEOUT: Duration = Duration::from_secs(10); /// Per-app (view name, output filename) lists. Keyed by the app's binary -/// name — see `--app-name`. +/// name — see `--app-name`. Filenames are plain (no app prefix): each app +/// gets its own subdirectory under `--out-dir` (`//`), +/// so the prefix would just be redundant with the folder name. const TARGETS: &[(&str, &[(&str, &str)])] = &[ ( "breadbar", &[ - ("bar", "breadbar-bar.png"), - ("control-panel", "breadbar-control-panel.png"), - ("connectivity-wifi", "breadbar-connectivity-wifi.png"), - ("connectivity-bluetooth", "breadbar-connectivity-bluetooth.png"), - ("media-popover", "breadbar-media-popover.png"), - ("notification", "breadbar-notification.png"), - ("notification-critical", "breadbar-notification-critical.png"), - ("osd-volume", "breadbar-osd-volume.png"), - ("osd-brightness", "breadbar-osd-brightness.png"), - ("wifi-add-dialog", "breadbar-wifi-add-dialog.png"), + ("bar", "bar.png"), + ("control-panel", "control-panel.png"), + ("connectivity-wifi", "connectivity-wifi.png"), + ("connectivity-bluetooth", "connectivity-bluetooth.png"), + ("media-popover", "media-popover.png"), + ("notification", "notification.png"), + ("notification-critical", "notification-critical.png"), + ("osd-volume", "osd-volume.png"), + ("osd-brightness", "osd-brightness.png"), + ("wifi-add-dialog", "wifi-add-dialog.png"), ], ), - ("breadbox", &[("launcher", "breadbox-launcher.png")]), - ("breadclip", &[("history", "breadclip-history.png")]), - ("breadsearch", &[("search", "breadsearch-search.png")]), + ("breadbox", &[("launcher", "launcher.png")]), + ("breadclip", &[("history", "history.png")]), + ("breadsearch", &[("search", "search.png")]), ( "breadpad", &[ - ("popup", "breadpad-popup.png"), - ("reminder", "breadpad-reminder.png"), - ("reminder-snooze", "breadpad-reminder-snooze.png"), + ("popup", "popup.png"), + ("reminder", "reminder.png"), + ("reminder-snooze", "reminder-snooze.png"), ], ), ( "breadhelp", &[ - ("home", "breadhelp-home.png"), - ("learn", "breadhelp-learn.png"), - ("ask", "breadhelp-ask.png"), - ("troubleshoot-wizard", "breadhelp-troubleshoot-wizard.png"), + ("home", "home.png"), + ("learn", "learn.png"), + ("ask", "ask.png"), + ("troubleshoot-wizard", "troubleshoot-wizard.png"), ], ), ( "breadman", &[ - ("all", "breadman-all.png"), - ("upcoming", "breadman-upcoming.png"), - ("todo", "breadman-todo.png"), - ("reminder", "breadman-reminder.png"), - ("idea", "breadman-idea.png"), - ("note", "breadman-note.png"), - ("question", "breadman-question.png"), - ("archive", "breadman-archive.png"), - ("settings", "breadman-settings.png"), - ("errors", "breadman-errors.png"), - ("editor", "breadman-editor.png"), - ("new-note", "breadman-new-note.png"), + ("all", "all.png"), + ("upcoming", "upcoming.png"), + ("todo", "todo.png"), + ("reminder", "reminder.png"), + ("idea", "idea.png"), + ("note", "note.png"), + ("question", "question.png"), + ("archive", "archive.png"), + ("settings", "settings.png"), + ("errors", "errors.png"), + ("editor", "editor.png"), + ("new-note", "new-note.png"), ], ), ( "bos-settings", &[ - ("network", "bos-settings-network.png"), - ("breadcrumbs", "bos-settings-breadcrumbs.png"), - ("bluetooth", "bos-settings-bluetooth.png"), - ("firewall", "bos-settings-firewall.png"), - ("sound", "bos-settings-sound.png"), - ("power", "bos-settings-power.png"), - ("datetime", "bos-settings-datetime.png"), - ("hyprland", "bos-settings-hyprland.png"), - ("keybinds", "bos-settings-keybinds.png"), - ("autostart", "bos-settings-autostart.png"), - ("users", "bos-settings-users.png"), - ("appearance", "bos-settings-appearance.png"), - ("breadpaper", "bos-settings-breadpaper.png"), - ("breadbar", "bos-settings-breadbar.png"), - ("breadbox", "bos-settings-breadbox.png"), - ("breadclip", "bos-settings-breadclip.png"), - ("breadpad", "bos-settings-breadpad.png"), - ("breadsearch", "bos-settings-breadsearch.png"), - ("bread", "bos-settings-bread.png"), - ("packages", "bos-settings-packages.png"), - ("aur", "bos-settings-aur.png"), - ("firmware", "bos-settings-firmware.png"), - ("snapshots", "bos-settings-snapshots.png"), - ("about", "bos-settings-about.png"), + ("network", "network.png"), + ("breadcrumbs", "breadcrumbs.png"), + ("bluetooth", "bluetooth.png"), + ("firewall", "firewall.png"), + ("sound", "sound.png"), + ("power", "power.png"), + ("datetime", "datetime.png"), + ("hyprland", "hyprland.png"), + ("keybinds", "keybinds.png"), + ("autostart", "autostart.png"), + ("users", "users.png"), + ("appearance", "appearance.png"), + ("breadpaper", "breadpaper.png"), + ("breadbar", "breadbar.png"), + ("breadbox", "breadbox.png"), + ("breadclip", "breadclip.png"), + ("breadpad", "breadpad.png"), + ("breadsearch", "breadsearch.png"), + ("bread", "bread.png"), + ("packages", "packages.png"), + ("aur", "aur.png"), + ("firmware", "firmware.png"), + ("snapshots", "snapshots.png"), + ("about", "about.png"), ], ), ]; @@ -223,7 +226,7 @@ fn main() -> Result { if cli.view.as_deref().is_some_and(|v| v != *view) { continue; } - let out_path = cli.out_dir.join(filename); + let out_path = cli.out_dir.join(app_name).join(filename); let out_str = out_path.to_string_lossy(); let result = bread_utils::proc::run( app_path, From e898535bb4da79033a262a35c61f9b6c88bdb79f Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 29 Jul 2026 22:45:18 +0800 Subject: [PATCH 42/99] bread-theme: add libadwaita components + fix slider/chip theming gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `adw` feature (gated separately from `gtk`, since AdwApplicationWindow isn't compatible with gtk4-layer-shell — the five panel/launcher apps stay on plain `gtk`, only breadman/breadhelp-style plain-window apps want this): preferences_group/toggle_row/spin_row/action_row/preferences_page, wrapping libadwaita's PreferencesGroup/SwitchRow/SpinRow/ActionRow/PreferencesPage. adw::init() also forces dark color-scheme, since bread-theme's whole design is a fixed dark base regardless of system GTK preference. These directly target defects a design critique found: hand-rolled switch+label rows with no intrinsic width (breadman/settings' ~1400px stretched toggles) and spinners stranded far from their label — both just don't happen when the row is a real AdwSwitchRow/AdwSpinRow instead of a box assembled from scratch. Also, two shared-stylesheet fixes usable by every app immediately, gtk feature only: - `scale` (slider) had no rule at all, so every volume/brightness slider showed GTK's own default blue instead of the palette accent — the same critique flagged breadbar's control-panel sliders contradicting its own on-brand OSD fill two clicks away. - A new `chip()`/`set_chip_active()` helper in gtk.rs uses the existing (already-tokenized, already-defined) `.chip`/`.pill` stylesheet rule instead of each app hand-rolling its own filter-chip CSS — which is how breadclip/breadpad/breadman ended up with three different, mutually disagreeing pill fills for what's supposed to be one shared component. --- Cargo.lock | 31 +++++++++++++++++ bread-theme/Cargo.toml | 13 +++++++ bread-theme/src/adw.rs | 78 ++++++++++++++++++++++++++++++++++++++++++ bread-theme/src/gtk.rs | 23 +++++++++++++ bread-theme/src/lib.rs | 10 ++++++ 5 files changed, 155 insertions(+) create mode 100644 bread-theme/src/adw.rs diff --git a/Cargo.lock b/Cargo.lock index b0ebcac..e99d7bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -199,6 +199,7 @@ version = "0.3.1" dependencies = [ "dirs", "gtk4", + "libadwaita", "serde", "serde_json", ] @@ -1279,6 +1280,36 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "libadwaita" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85b9900e67182a4b5b1f157b448d94f0715c8b9770cce21cf000801917f53bfa" +dependencies = [ + "gdk4", + "gio", + "glib", + "gtk4", + "libadwaita-sys", + "pango", +] + +[[package]] +name = "libadwaita-sys" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3c27642b389852aa99341bd4a4c19ec6f8a2b63ebdd7f5ba1952198079ccd" +dependencies = [ + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "libc", + "pango-sys", + "system-deps", +] + [[package]] name = "libc" version = "0.2.186" diff --git a/bread-theme/Cargo.toml b/bread-theme/Cargo.toml index 43c3952..f5e1cda 100644 --- a/bread-theme/Cargo.toml +++ b/bread-theme/Cargo.toml @@ -13,11 +13,24 @@ serde = { workspace = true } serde_json = { workspace = true } dirs = { workspace = true } gtk4 = { version = "0.11", features = ["v4_12"], optional = true } +# Rust bindings for libadwaita (GNOME's widget library on top of GTK4) — the +# actual source of the modern GNOME look (grouped preference rows, real +# toggle/spin rows, view switchers), not just a CSS reskin of plain GTK4 +# widgets. `v1_7` for ToggleGroup (used for tab-row-style pickers); the +# system library only needs to be >= that (this machine has 1.9.2). +libadwaita = { version = "0.9", features = ["v1_7"], optional = true } [features] # Enable GTK4 CSS provider helpers (breadbar, breadbox, breadpad use this). # bread (daemon) and breadcrumbs (CLI) depend on this crate without the feature. gtk = ["dep:gtk4"] +# Composite libadwaita-based widgets (bread_theme::adw) — separate from `gtk` +# because libadwaita's own top-level window chrome (AdwApplicationWindow) +# isn't compatible with gtk4-layer-shell surfaces, so the five layer-shell +# apps (breadbar, breadbox, breadclip, breadsearch, breadpad) only want +# plain CSS, not this. Apps with an ordinary top-level window (breadman, +# breadhelp) want both. +adw = ["gtk", "dep:libadwaita"] # The generator CLI. It only touches the gtk-free lib API (render + write), so # it builds without the gtk feature and stays light. diff --git a/bread-theme/src/adw.rs b/bread-theme/src/adw.rs new file mode 100644 index 0000000..118e528 --- /dev/null +++ b/bread-theme/src/adw.rs @@ -0,0 +1,78 @@ +//! Composite libadwaita widgets for the bread ecosystem's design system — +//! the actual mechanism (real GNOME-style widgets, not more hand-rolled CSS) +//! behind why bos-settings' sidebar/section/toggle rows read as more polished +//! than the plain-GTK4 apps'. An app calls these instead of assembling boxes +//! and labels and raw widgets from scratch each time, so spacing/sizing/ +//! grouping decisions get made once, correctly, here — not re-derived per +//! screen. +//! +//! Not usable from the five `gtk4-layer-shell` apps (breadbar, breadbox, +//! breadclip, breadsearch, breadpad): `AdwApplicationWindow`'s own chrome +//! isn't compatible with a layer-shell surface, and these helpers assume an +//! ordinary top-level window. Apps with a plain top-level window (breadman, +//! breadhelp) can use the full set. + +use libadwaita as adw; +use adw::prelude::*; + +/// Call once at startup, before building any widgets from this module — +/// initializes libadwaita's style manager and forces dark mode regardless of +/// the system GTK theme preference. bread-theme's whole design is a *fixed* +/// dark base (only the accent tracks pywal — see `palette::FIXED_BACKGROUND` +/// etc.) so an app respecting a light system preference here would silently +/// break that contract the moment someone's GNOME settings say "light". +pub fn init() { + adw::init().expect("failed to initialize libadwaita"); + adw::StyleManager::default().set_color_scheme(adw::ColorScheme::ForceDark); +} + +/// A titled, optionally-described group of setting rows — the +/// title-then-description-then-rows rhythm bos-settings already uses per +/// section, now available to native GTK4/relm4 apps instead of a hand-rolled +/// vbox with a bold label glued to the top. +pub fn preferences_group(title: &str, description: Option<&str>) -> adw::PreferencesGroup { + let group = adw::PreferencesGroup::builder().title(title).build(); + if let Some(desc) = description { + group.set_description(Some(desc)); + } + group +} + +/// A single on/off setting row with a correctly-sized, correctly-positioned +/// switch — the direct fix for the ~1400px-wide stretched-switch bug +/// (breadman/settings had no intrinsic width on its hand-rolled switch, so +/// it filled the row like a progress bar). +pub fn toggle_row(title: &str, subtitle: Option<&str>, active: bool) -> adw::SwitchRow { + let row = adw::SwitchRow::builder().title(title).active(active).build(); + if let Some(sub) = subtitle { + row.set_subtitle(sub); + } + row +} + +/// A single numeric setting row (spin button docked to its own label, +/// instead of stranded ~1300px away at the window's far edge). +pub fn spin_row(title: &str, subtitle: Option<&str>, adjustment: >k4::Adjustment) -> adw::SpinRow { + let row = adw::SpinRow::builder().title(title).adjustment(adjustment).build(); + if let Some(sub) = subtitle { + row.set_subtitle(sub); + } + row +} + +/// A general label(+subtitle) row with room for a trailing widget +/// (`row.add_suffix(&widget)`) — for settings that don't fit switch/spin +/// (text entries, buttons, dropdowns, a raw value display). +pub fn action_row(title: &str, subtitle: Option<&str>) -> adw::ActionRow { + let row = adw::ActionRow::builder().title(title).build(); + if let Some(sub) = subtitle { + row.set_subtitle(sub); + } + row +} + +/// A page of one or more `preferences_group`s, with correct margins and +/// scroll handling — the top-level content container for a settings screen. +pub fn preferences_page() -> adw::PreferencesPage { + adw::PreferencesPage::new() +} diff --git a/bread-theme/src/gtk.rs b/bread-theme/src/gtk.rs index aab7d01..fb759c7 100644 --- a/bread-theme/src/gtk.rs +++ b/bread-theme/src/gtk.rs @@ -114,6 +114,29 @@ pub fn apply_css(css: &str, provider: &RefCell>) { } } +/// A filter/tag chip using the shared `.chip` stylesheet rule (an +/// `@overlay`-filled pill, `@accent`-filled when the `active` CSS class is +/// set) instead of a fresh literal color — this is the fix for the same +/// component drifting to three different fills across breadclip (grey), +/// breadpad, and breadman (both cream), none of which agreed with each +/// other or with the shared token. +pub fn chip(label: &str) -> gtk4::Button { + gtk4::Button::builder().label(label).css_classes(["chip"]).build() +} + +/// Toggles a chip's (or any widget's) `active` CSS class — the `.chip.active` +/// stylesheet rule fills it with the accent instead of the neutral overlay. +/// Wiring *when* a chip becomes active (single-select filter, multi-select +/// tags, etc.) is genuinely per-app, so that stays the caller's job; this is +/// just the one-line visual toggle every case needs. +pub fn set_chip_active(chip: &impl IsA, active: bool) { + if active { + chip.add_css_class("active"); + } else { + chip.remove_css_class("active"); + } +} + /// Apply a user CSS override file at USER priority. Clears the provider if the /// file is absent so stale overrides don't persist across SIGHUP reloads. pub fn apply_user_css(path: &Path, provider: &RefCell>) { diff --git a/bread-theme/src/lib.rs b/bread-theme/src/lib.rs index a97588b..5d1ff7f 100644 --- a/bread-theme/src/lib.rs +++ b/bread-theme/src/lib.rs @@ -1,6 +1,8 @@ pub mod palette; #[cfg(feature = "gtk")] pub mod gtk; +#[cfg(feature = "adw")] +pub mod adw; pub use palette::{load_palette, Palette}; @@ -187,6 +189,14 @@ pub fn stylesheet(p: &Palette) -> String { switch {{ background-color: @overlay; border-radius: {pill}px; }}\n\ switch:checked {{ background-color: @accent; }}\n\ switch slider {{ background-color: @on-surface; border-radius: {pill}px; }}\n\ + /* GtkScale (sliders) render with GTK's own default accent (a fixed\ + blue, independent of the app's theme) unless styled explicitly —\ + every app with a volume/brightness slider was silently showing\ + that default instead of the palette's accent until this rule\ + existed. */\n\ + scale trough {{ background-color: @overlay; border-radius: {pill}px; min-height: 6px; }}\n\ + scale trough highlight {{ background-color: @accent; border-radius: {pill}px; min-height: 6px; }}\n\ + scale slider {{ background-color: @on-bg; border-radius: {pill}px; }}\n\ list, listbox {{ background-color: transparent; }}\n\ row {{ border-radius: {r2}px; }}\n\ row:selected, list row:selected {{ background-color: @accent; color: @on-accent; }}\n\ From 9b097218fde2d6886a10784c2757289e9a99383c Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 30 Jul 2026 20:01:47 +0800 Subject: [PATCH 43/99] bread-theme: fix libadwaita class collisions and restore boxed-list styling Two bare class selectors (.title, .subtitle) were colliding with libadwaita's own internal row/window-title label classes of the same name, causing every AdwActionRow/AdwSwitchRow/AdwSpinRow title to inherit the 1.4em heading size meant for app view-titles - the root cause of breadman settings' ~24px row-title bug found in design review. Renamed to .page-title/.page-subtitle (breadhelp, the only caller, updated separately). Also scoped a .boxed-list override so AdwPreferencesGroup's boxed-list GtkListBox gets its surface fill + radius back - the shared `list, listbox { background-color: transparent }` rule (needed for plain GTK4 sidebars) was stripping it with equal specificity. --- bread-theme/src/lib.rs | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/bread-theme/src/lib.rs b/bread-theme/src/lib.rs index 5d1ff7f..fd50c53 100644 --- a/bread-theme/src/lib.rs +++ b/bread-theme/src/lib.rs @@ -166,9 +166,20 @@ pub fn stylesheet(p: &Palette) -> String { would override a container's colour on its own child labels. */\n\ window {{ background-color: @bg; color: @on-bg; }}\n\ .dim-label, .dim {{ opacity: 0.6; font-size: {sec}px; }}\n\ - .title {{ font-size: 1.4em; font-weight: bold; }}\n\ + /* Named `.page-title`, not the more obvious `.title` - libadwaita's\ + own row/window-title widgets (AdwActionRow, AdwWindowTitle, GtkHeaderBar)\ + put a bare `title` CSS class on their internal label, so a generic\ + `.title` rule here would inflate every libadwaita row's title text\ + to 1.4em too (this is exactly what caused the settings screen's\ + ~24px row-title bug). Scoping the name avoids the collision instead\ + of trying to out-specificity a first-party GTK/libadwaita class. */\n\ + .page-title {{ font-size: 1.4em; font-weight: bold; }}\n\ .heading {{ font-weight: bold; opacity: 0.85; }}\n\ - .subtitle {{ opacity: 0.7; font-size: {sec}px; }}\n\ + /* Same libadwaita-collision reasoning as `.page-title` above - a bare\ + `.subtitle` also matches libadwaita's internal row-subtitle labels.\ + Unused by any app today, but scoped so a future caller doesn't\ + reintroduce the fight. */\n\ + .page-subtitle {{ opacity: 0.7; font-size: {sec}px; }}\n\ button {{ background-color: @surface; color: @on-surface; border: none;\ border-radius: {r1}px; padding: {sm}px {lg}px; }}\n\ button:hover {{ background-color: alpha(@on-surface, 0.14); }}\n\ @@ -198,6 +209,14 @@ pub fn stylesheet(p: &Palette) -> String { scale trough highlight {{ background-color: @accent; border-radius: {pill}px; min-height: 6px; }}\n\ scale slider {{ background-color: @on-bg; border-radius: {pill}px; }}\n\ list, listbox {{ background-color: transparent; }}\n\ + /* libadwaita's AdwPreferencesGroup wraps its rows in a GtkListBox\ + carrying the `boxed-list` class, expecting a surface fill + radius\ + to read as a card. The bare-type rule above (needed so plain\ + GTK4 sidebars/lists stay transparent) was overriding that with\ + equal specificity and no fill ever won, leaving preference groups\ + as a bare bordered table instead of a card. This is scoped to the\ + class only, so it doesn't touch any non-adw list. */\n\ + list.boxed-list, listbox.boxed-list {{ background-color: @surface; border-radius: {r1}px; }}\n\ row {{ border-radius: {r2}px; }}\n\ row:selected, list row:selected {{ background-color: @accent; color: @on-accent; }}\n\ .sidebar {{ background-color: @surface; color: @on-surface; }}\n\ @@ -321,7 +340,7 @@ mod tests { assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}"); } // a representative spread of the shared component selectors - for sel in &["button", "entry", "switch:checked", ".card", ".sidebar", "scrollbar slider", ".title"] { + for sel in &["button", "entry", "switch:checked", ".card", ".sidebar", "scrollbar slider", ".page-title"] { assert!(css.contains(sel), "stylesheet missing selector: {sel}"); } assert!(css.contains("Varela Round")); From 594c18bf1b4fc79e39718e52b55c24bfd5131bf1 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 31 Jul 2026 07:08:00 +0800 Subject: [PATCH 44/99] bread-theme: hardcode destructive-action red instead of pywal @red pywal derives @red from the wallpaper and can hand it any hue - on a blue-toned wallpaper the "red" slot is itself blue, making destructive buttons indistinguishable from normal accent/confirm buttons. GNOME's own destructive-action style is a fixed red for the same reason; this is now the one button in the shared stylesheet that intentionally ignores the palette. --- bread-theme/src/lib.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/bread-theme/src/lib.rs b/bread-theme/src/lib.rs index fd50c53..15058fb 100644 --- a/bread-theme/src/lib.rs +++ b/bread-theme/src/lib.rs @@ -188,8 +188,15 @@ pub fn stylesheet(p: &Palette) -> String { button.flat {{ background-color: transparent; color: @on-bg; }}\n\ button.suggested-action {{ background-color: @accent; color: @on-accent; }}\n\ button.suggested-action:hover {{ background-color: alpha(@accent, 0.85); }}\n\ - button.destructive-action {{ background-color: @red; color: @on-red; }}\n\ - button.destructive-action:hover {{ background-color: alpha(@red, 0.85); }}\n\ + /* Deliberately NOT @red: pywal can hand `red` any hue depending on\ + the wallpaper (a blue-toned wallpaper's \"red\" slot can literally\ + render blue), which would make a destructive action indistinguishable\ + from a normal accent button - exactly backwards for a warning colour.\ + GNOME's own destructive-action is a fixed red for the same reason;\ + this is the one button style in the whole system that intentionally\ + doesn't follow the palette. */\n\ + button.destructive-action {{ background-color: #e01b24; color: #ffffff; }}\n\ + button.destructive-action:hover {{ background-color: #c01c28; }}\n\ entry, spinbutton {{ background-color: @surface; color: @on-surface;\ border: 1px solid @overlay; border-radius: {r2}px;\ padding: {xs}px {sm}px; caret-color: @on-surface; }}\n\ From 3caad809a336796623d2f404f39ce23f41f65187 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 31 Jul 2026 11:05:17 +0800 Subject: [PATCH 45/99] =?UTF-8?q?CI:=20single-trunk=20model=20=E2=80=94=20?= =?UTF-8?q?dev=20triggers=20on=20main,=20beta=20becomes=20RC-tag-triggered?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the dev/beta branch split with one trunk (main): dev-track builds still publish on every push, but the beta track now publishes from a vX.Y.Z-rc.N prerelease tag instead of a separately-maintained beta branch. Removes the branch nobody reliably kept in sync. --- .forgejo/workflows/dev-bakery.yml | 12 +++--- .forgejo/workflows/dev-bread-theme.yml | 12 +++--- .../{beta-bakery.yml => rc-bakery.yml} | 43 ++++--------------- ...eta-bread-theme.yml => rc-bread-theme.yml} | 38 ++++------------ .forgejo/workflows/release-bakery.yml | 1 + .forgejo/workflows/release-bread-theme.yml | 1 + 6 files changed, 32 insertions(+), 75 deletions(-) rename .forgejo/workflows/{beta-bakery.yml => rc-bakery.yml} (58%) rename .forgejo/workflows/{beta-bread-theme.yml => rc-bread-theme.yml} (60%) diff --git a/.forgejo/workflows/dev-bakery.yml b/.forgejo/workflows/dev-bakery.yml index ae2e9ef..f63769e 100644 --- a/.forgejo/workflows/dev-bakery.yml +++ b/.forgejo/workflows/dev-bakery.yml @@ -1,11 +1,11 @@ name: dev bakery -# Publishes a dev-track build on every push to `dev` — separate from -# release-bakery.yml's tag-triggered stable releases. See docs/release-channels.md -# for the three-track policy (stable/beta/dev) this is part of. +# Publishes a dev-track build on every push to `main` (the trunk branch — +# there is no separate `dev` branch). See docs/release-channels.md for the +# release-track policy this is part of. on: push: - branches: ['dev'] + branches: ['main'] paths: - 'bakery/**' - 'Cargo.toml' @@ -20,7 +20,7 @@ jobs: run: | set -euo pipefail rm -rf src && mkdir src - git clone --branch dev --depth 1 \ + git clone --branch main --depth 1 \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build @@ -45,7 +45,7 @@ jobs: # what's already installed and bakery would correctly refuse it. LATEST_TAG="$(git ls-remote --tags --refs \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \ - | awk -F/ '{print $NF}' | sed 's/^v//' | sort -V | tail -1)" + | awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)" if [ -n "${LATEST_TAG}" ]; then CUR="${LATEST_TAG}" else diff --git a/.forgejo/workflows/dev-bread-theme.yml b/.forgejo/workflows/dev-bread-theme.yml index 3d645d2..1cff78e 100644 --- a/.forgejo/workflows/dev-bread-theme.yml +++ b/.forgejo/workflows/dev-bread-theme.yml @@ -1,11 +1,11 @@ name: dev bread-theme -# Publishes a dev-track build on every push to `dev` — separate from -# release-bread-theme.yml's tag-triggered stable releases. See -# docs/release-channels.md for the three-track policy this is part of. +# Publishes a dev-track build on every push to `main` (the trunk branch — +# there is no separate `dev` branch). See docs/release-channels.md for the +# release-track policy this is part of. on: push: - branches: ['dev'] + branches: ['main'] paths: - 'bread-theme/**' - 'Cargo.toml' @@ -20,7 +20,7 @@ jobs: run: | set -euo pipefail rm -rf src && mkdir src - git clone --branch dev --depth 1 \ + git clone --branch main --depth 1 \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build @@ -37,7 +37,7 @@ jobs: # what's already installed and bakery would correctly refuse it. LATEST_TAG="$(git ls-remote --tags --refs \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \ - | awk -F/ '{print $NF}' | sed 's/^v//' | sort -V | tail -1)" + | awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)" if [ -n "${LATEST_TAG}" ]; then CUR="${LATEST_TAG}" else diff --git a/.forgejo/workflows/beta-bakery.yml b/.forgejo/workflows/rc-bakery.yml similarity index 58% rename from .forgejo/workflows/beta-bakery.yml rename to .forgejo/workflows/rc-bakery.yml index c20c069..dc7f695 100644 --- a/.forgejo/workflows/beta-bakery.yml +++ b/.forgejo/workflows/rc-bakery.yml @@ -1,12 +1,12 @@ -name: beta bakery +name: beta (rc) bakery -# Publishes a beta-track build on every push to `beta` — a frozen -# stabilization branch cut from `dev` when ready to stabilize; only -# fix/ branches merged into `beta` should land here afterward. -# See docs/release-channels.md for the three-track policy (stable/beta/dev). +# Publishes a beta-track build for any `vX.Y.Z-rc.N` prerelease tag pushed +# to `main` — there is no separate `beta` branch; "freezing" is just +# pausing pushes to main while an RC gets tested. See +# docs/release-channels.md for the release-track policy. on: push: - branches: ['beta'] + tags: ['v*'] paths: - 'bakery/**' - 'Cargo.toml' @@ -15,13 +15,14 @@ on: jobs: build: + if: ${{ contains(github.ref_name, '-rc.') }} runs-on: [self-hosted, hestia] steps: - name: checkout run: | set -euo pipefail rm -rf src && mkdir src - git clone --branch beta --depth 1 \ + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build @@ -30,36 +31,10 @@ jobs: - name: test run: cd src && cargo test --release --locked -p bakery - # Auto-bumps the patch version from the latest tag and appends a - # timestamp+sha beta suffix — no developer discipline required, and the - # result is visibly "ahead of" the last stable patch release while - # staying valid semver (comparable within the beta track by bakery's - # `is_newer`). - - name: compute beta version - run: | - set -euo pipefail - cd src - # Base the beta version off the latest published stable tag, - # not Cargo.toml — Cargo.toml can go stale relative to the last - # real release (seen in practice: breadbox/breadpad/breadcrumbs/ - # breadpaper), which would make a beta build sort as OLDER than - # what's already installed and bakery would correctly refuse it. - LATEST_TAG="$(git ls-remote --tags --refs \ - "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \ - | awk -F/ '{print $NF}' | sed 's/^v//' | sort -V | tail -1)" - if [ -n "${LATEST_TAG}" ]; then - CUR="${LATEST_TAG}" - else - CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" - fi - IFS='.' read -r MA MI PA <<< "${CUR}" - SHA="$(git rev-parse --short HEAD)" - TS="$(date -u +%Y%m%d%H%M%S)" - echo "VERSION=${MA}.${MI}.$((PA + 1))-beta.${TS}+${SHA}" >> "$GITHUB_ENV" - - name: prepare artifacts run: | set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" PKG_DIR="/srv/breadway-dl/beta/bakery/${VERSION}" mkdir -p "${PKG_DIR}" cp "src/target/release/bakery" "${PKG_DIR}/bakery-x86_64" diff --git a/.forgejo/workflows/beta-bread-theme.yml b/.forgejo/workflows/rc-bread-theme.yml similarity index 60% rename from .forgejo/workflows/beta-bread-theme.yml rename to .forgejo/workflows/rc-bread-theme.yml index c1521b9..c364ce6 100644 --- a/.forgejo/workflows/beta-bread-theme.yml +++ b/.forgejo/workflows/rc-bread-theme.yml @@ -1,12 +1,12 @@ -name: beta bread-theme +name: beta (rc) bread-theme -# Publishes a beta-track build on every push to `beta` — a frozen -# stabilization branch cut from `dev` when ready to stabilize; only -# fix/ branches merged into `beta` should land here afterward. -# See docs/release-channels.md for the three-track policy this is part of. +# Publishes a beta-track build for any `vX.Y.Z-rc.N` prerelease tag pushed +# to `main` — there is no separate `beta` branch; "freezing" is just +# pausing pushes to main while an RC gets tested. See +# docs/release-channels.md for the release-track policy. on: push: - branches: ['beta'] + tags: ['v*'] paths: - 'bread-theme/**' - 'Cargo.toml' @@ -15,43 +15,23 @@ on: jobs: build: + if: ${{ contains(github.ref_name, '-rc.') }} runs-on: [self-hosted, hestia] steps: - name: checkout run: | set -euo pipefail rm -rf src && mkdir src - git clone --branch beta --depth 1 \ + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build run: cd src && cargo build --release --locked -p bread-theme --bin bread-theme - - name: compute beta version - run: | - set -euo pipefail - cd src - # Base the beta version off the latest published stable tag, - # not Cargo.toml — Cargo.toml can go stale relative to the last - # real release (seen in practice: breadbox/breadpad/breadcrumbs/ - # breadpaper), which would make a beta build sort as OLDER than - # what's already installed and bakery would correctly refuse it. - LATEST_TAG="$(git ls-remote --tags --refs \ - "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \ - | awk -F/ '{print $NF}' | sed 's/^v//' | sort -V | tail -1)" - if [ -n "${LATEST_TAG}" ]; then - CUR="${LATEST_TAG}" - else - CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" - fi - IFS='.' read -r MA MI PA <<< "${CUR}" - SHA="$(git rev-parse --short HEAD)" - TS="$(date -u +%Y%m%d%H%M%S)" - echo "VERSION=${MA}.${MI}.$((PA + 1))-beta.${TS}+${SHA}" >> "$GITHUB_ENV" - - name: prepare artifacts run: | set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" PKG_DIR="/srv/breadway-dl/beta/bread-theme/${VERSION}" mkdir -p "${PKG_DIR}" cp "src/target/release/bread-theme" "${PKG_DIR}/bread-theme-x86_64" diff --git a/.forgejo/workflows/release-bakery.yml b/.forgejo/workflows/release-bakery.yml index 283153b..a2373cb 100644 --- a/.forgejo/workflows/release-bakery.yml +++ b/.forgejo/workflows/release-bakery.yml @@ -6,6 +6,7 @@ on: jobs: build: + if: ${{ !contains(github.ref_name, '-rc.') }} runs-on: [self-hosted, hestia] steps: - name: checkout diff --git a/.forgejo/workflows/release-bread-theme.yml b/.forgejo/workflows/release-bread-theme.yml index 335f982..a8eb8df 100644 --- a/.forgejo/workflows/release-bread-theme.yml +++ b/.forgejo/workflows/release-bread-theme.yml @@ -6,6 +6,7 @@ on: jobs: build: + if: ${{ !contains(github.ref_name, '-rc.') }} runs-on: [self-hosted, hestia] steps: - name: checkout From 4f0fe2571de16f1840102a0550d6942c65ca4146 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 31 Jul 2026 11:08:41 +0800 Subject: [PATCH 46/99] CONTRIBUTING.md: document single-trunk + RC-tag release model --- CONTRIBUTING.md | 86 ++++++++++++++++++++++++++----------------------- 1 file changed, 45 insertions(+), 41 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6087898..8d141b2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,16 +7,10 @@ workflow described here. ## Branches -- **`main`** — release branch, always tag-ready. Nothing is committed to it - directly; it only moves forward via a `beta` merge (see below). -- **`dev`** — integration branch. All day-to-day work lands here first. - Every push to `dev` automatically builds and publishes a **dev-track** - build (see Tracks below) — use this to test your change in a real install - before it goes any further. -- **`beta`** — a frozen stabilization branch, cut from `dev` periodically. - Every push to `beta` automatically builds and publishes a **beta-track** - build. While a freeze is active, only fixes for issues found *in that - freeze* should land on `beta`. +There is one long-lived branch: **`main`**. All day-to-day work lands here. +Every push to `main` automatically builds and publishes a **dev-track** +build for both products (see Tracks below) — use this to test your change +in a real install before cutting anything more formal. New work — features and bug fixes alike — goes on a short-lived branch: @@ -25,28 +19,35 @@ feature/ fix/ ``` -Branch off `dev`, open a PR/push back into `dev` when ready. If you're fixing -something reported against an active `beta` freeze, branch off `beta` -instead, merge the fix there to unblock testers, and also forward the same -fix into `dev` so it doesn't quietly reappear next cycle. +Branch off `main`, open a PR/push back into `main` when ready. Short-lived +branches get deleted on merge — they never accumulate the kind of drift a +second long-lived branch does. ## The release cycle -1. Work accumulates on `dev` via `feature/x` / `fix/x` branches. Each push - auto-publishes a dev build — install it with `bakery track set dev` and - `bakery update --all`, then report or fix anything broken with another - push to `dev`. -2. Once `dev` has gone roughly **a week** without new issues, `beta` is cut - fresh from `dev`'s current tip. This freezes it as the stabilization - target — `dev` keeps moving independently starting the next cycle. -3. `beta` is open for anyone to test: `bakery track set beta` and - `bakery update --all`. **File issues against anything you find on this - repo's Forgejo issue tracker.** Fixes land via `fix/` branches - merged into `beta`. -4. Once `beta` has gone roughly **a month** without new issues, it's merged - into `main` and tagged `vX.Y.Z` — that tag is what actually triggers the - stable release build. `beta` is then reset from `dev` to start the next - cycle. +There's no separate `beta` or release branch — "stable" and "beta" are both +just **tags** on `main`, not branches that need to be kept in sync: + +1. Work accumulates on `main` via `feature/x` / `fix/x` branches. Each push + auto-publishes a dev build for both `bakery` and `bread-theme` — install + with `bakery track set dev` and `bakery update --all`, then fix anything + broken with another push. +2. When you want to stabilize before a real release, tag a release + candidate: `git tag vX.Y.Z-rc.1 && git push origin vX.Y.Z-rc.1` (push to + both remotes). That tag alone triggers a beta-track build — + "freezing" is just pausing pushes to `main` while you test it, not a + branch operation. Cut `-rc.2`, `-rc.3`, etc. for further fixes. +3. Once an RC has gone without issues, tag the real release: + `git tag vX.Y.Z && git push origin vX.Y.Z` — that's what triggers the + signed stable release build. + +**Note**: `bakery` and `bread-theme` share the same `v*` tag pattern +(both `release-bakery.yml` and `release-bread-theme.yml` trigger on +`tags: ['v*']`, pre-existing behavior this doc isn't changing) — a single +tag push builds and publishes a release for *both* products at once. If +you ever need to release one independently of the other, that's a real gap +worth fixing in the workflow files themselves, not something to work around +by hand. ## Tracks, from a user's perspective @@ -58,14 +59,15 @@ bakery update --all # pull the latest build on your current track | Track | What it is | Published from | |--------|-----------|-----------------| -| `stable` | The last tagged release | `main`, on a `vX.Y.Z` tag push | -| `beta` | Current stabilization freeze | `beta`, on every push | -| `dev` | Bleeding edge | `dev`, on every push | +| `stable` | The last tagged release | a `vX.Y.Z` tag | +| `beta` | Latest release candidate | a `vX.Y.Z-rc.N` tag | +| `dev` | Bleeding edge | `main`, on every push | -Dev/beta versions are auto-computed (`X.Y.Z-dev.+` / -`-beta.…`) from the latest published stable tag, so they always sort as -newer than what you have installed — no manual version bumping needed when -pushing to `dev` or `beta`. +Dev versions are auto-computed (`X.Y.Z-dev.+`) from the +latest published stable tag, so they always sort as newer than what you +have installed — no manual version bumping needed. Beta versions are just +the RC tag itself (already valid semver, already sorts below the real +release it's a candidate for). ## Local development @@ -79,11 +81,13 @@ Run the same commands with `-p bread-theme --bin bread-theme` for that crate. ## CI -- `dev-bakery.yml` / `dev-bread-theme.yml` — triggered on push to `dev`. -- `beta-bakery.yml` / `beta-bread-theme.yml` — triggered on push to `beta`. -- `release-bakery.yml` / `release-bread-theme.yml` — triggered on a `v*` tag - push, cuts the actual stable release. -- `package.yml` — publishes to the `[breadway]` pacman repo, also tag-triggered. +- `dev-bakery.yml` / `dev-bread-theme.yml` — triggered on push to `main`. +- `rc-bakery.yml` / `rc-bread-theme.yml` — triggered on any `vX.Y.Z-rc.N` + tag push. +- `release-bakery.yml` / `release-bread-theme.yml` — triggered on any other + `v*` tag push, cuts the actual stable release. +- `package.yml` — publishes `bakery` to the `[breadway]` pacman repo, also + tag-triggered. All CI runs on a self-hosted runner; nothing runs automatically on plain commits or PRs beyond the track builds above. See From 036f270b07516addd46f71f2d4ab42673ee35e72 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 31 Jul 2026 11:11:17 +0800 Subject: [PATCH 47/99] docs: update release-channels.md for single-trunk + RC-tag model --- docs/release-channels.md | 121 +++++++++++++++++++++++---------------- 1 file changed, 71 insertions(+), 50 deletions(-) diff --git a/docs/release-channels.md b/docs/release-channels.md index 019ecf3..ad2b286 100644 --- a/docs/release-channels.md +++ b/docs/release-channels.md @@ -53,57 +53,70 @@ should never carry a `bakery.toml` or `PKGBUILD`. ## Build tracks (stable/beta/dev) — orthogonal to channels Within the **bakery channel only**, a repo can additionally publish up to -three **tracks**: `stable` (the existing tag-triggered `v*` flow, unchanged), -`beta` (a frozen stabilization branch), and `dev` (published automatically on -every push to the `dev` branch). Don't confuse "track" with "channel" above — -channel is *how* a binary reaches a user (bakery vs. pacman); track is *which -build* of a bakery-channel package they get. +three **tracks**: `stable`, `beta`, and `dev`. Don't confuse "track" with +"channel" above — channel is *how* a binary reaches a user (bakery vs. +pacman); track is *which build* of a bakery-channel package they get. -Each track lives in its own subtree so they never collide: +There is no per-track branch anymore — every bakery-channel repo has exactly +one long-lived branch, `main`. Tracks are driven entirely by *what you push*, +not *which branch you push to*: | Track | Index URL | Artifact root | Trigger | |---|---|---|---| -| stable | `dl.breadway.dev/index.json` | `/srv/breadway-dl///` | push tag `v*` on `main` | -| beta | `dl.breadway.dev/beta/index.json` | `/srv/breadway-dl/beta///` | push to branch `beta` | -| dev | `dl.breadway.dev/dev/index.json` | `/srv/breadway-dl/dev///` | push to branch `dev` | +| stable | `dl.breadway.dev/index.json` | `/srv/breadway-dl///` | push tag `vX.Y.Z` | +| beta | `dl.breadway.dev/beta/index.json` | `/srv/breadway-dl/beta///` | push tag `vX.Y.Z-rc.N` | +| dev | `dl.breadway.dev/dev/index.json` | `/srv/breadway-dl/dev///` | push to branch `main` | `scripts/gen-index.sh` takes a `TRACK` env var (default `stable`) to select -which subtree it reads/writes — every existing stable release workflow needs -zero changes. Dev/beta builds skip the GitHub Release upload step entirely -(no release-per-commit spam, and beta doesn't need a GitHub mirror either) — +which subtree it reads/writes — this didn't need to change. Dev/beta builds +skip the GitHub Release upload step entirely (no release-per-commit spam) — `dl.breadway.dev` is their only distribution point. -**The full branch lifecycle** (see also `CLAUDE.md`'s Branch model section): -day-to-day work lands on `feature/` or `fix/` branches, merged -into `dev`. `dev` publishes a fresh dev-track build on every push — this is -the "test for a while, fix forward with another push" loop. When `dev` has -gone roughly a week without new issues, cut `beta` fresh from `dev`'s current -tip (`git branch -f beta dev` from a clean checkout, then force-push) — this -freezes it as the stabilization target. `beta` publishes on every push the -same way `dev` does, but only `fix/` branches merged directly into -`beta` should land there afterward; `dev` keeps moving independently for the -next cycle. After roughly a month of `beta` going without new issues, merge -`beta` into `main` and push a `vX.Y.Z` tag from `main` to cut the actual -stable release (the merge itself triggers nothing — tag-push is what fires -`release.yml`). Reset `beta` fresh from `dev` again to start the next cycle. +**Why no beta/dev branches**: the old model had `dev`/`beta`/`main` as three +separate branches, with `beta` cut from `dev` periodically and `main` +supposed to move forward only via a `beta` merge. In practice `main` rotted +silently in most repos — the "merge beta into main" step was a manual, +easy-to-forget action across a dozen-plus repos with no team and no +calendar enforcement, and it also collided with a real Forgejo Actions +gotcha: tag-triggered workflows resolve *which version of the workflow +YAML to run* from the repo's default branch, not the tagged commit's +branch, so a stale `main` could silently run stale release logic even when +the tag itself pointed at fresh code. Collapsing everything onto one +branch removes the class of bug entirely — there's nothing left to fall +out of sync. -Auto-versioning: both `dev` and `beta` compute their build version from the -latest published `vX.Y.Z` tag (via `git ls-remote --tags`, not `Cargo.toml` — -`Cargo.toml` can drift stale relative to the actual last release) plus a -`-dev.+` / `-beta.+` suffix. This is -self-healing regardless of `Cargo.toml` drift and keeps `bakery`'s semver -check (`is_newer`) meaningful — it will correctly refuse to "update" to a -build that isn't actually newer than what's installed. +**The full lifecycle** (see also `CONTRIBUTING.md`): day-to-day work lands +on `feature/` or `fix/` branches, merged into `main`. `main` +publishes a fresh dev-track build on every push — this is the "test for a +while, fix forward with another push" loop. When you want to stabilize +before a real release, tag a release candidate directly off whatever +commit on `main` you're happy with: `git tag vX.Y.Z-rc.1 && git push +origin vX.Y.Z-rc.1` (both remotes). "Freezing" is just pausing pushes to +`main` while the RC gets tested, not a branch operation — cut `-rc.2`, +`-rc.3`, etc. for further fixes without needing to touch any branch. Once +an RC has gone without issues, tag the real release the same way, dropping +the `-rc.N` suffix (`vX.Y.Z`) — that's what fires `release.yml`. -Adding beta/dev to a bakery-channel repo: copy `dev-bakery.yml` / -`beta-bakery.yml` (or `bread`'s `dev-release.yml` / `beta-release.yml` if the +Auto-versioning: `dev` computes its build version from the latest published +*stable* `vX.Y.Z` tag (via `git ls-remote --tags`, filtered to exclude any +tag containing a `-`, not `Cargo.toml` — `Cargo.toml` can drift stale +relative to the actual last release) plus a `-dev.+` +suffix. `beta` needs no computation at all — the RC tag itself +(`X.Y.Z-rc.N`) is already valid semver and is used as the version verbatim. +`bakery`'s semver check (`is_newer`), backed by the real `semver` crate, +already orders these correctly with zero special-casing: a prerelease +identifier sorts below the same version without one, and `dev` < `rc` +alphabetically, giving `X.Y.Z-dev... < X.Y.Z-rc.N < X.Y.Z` for the same +base version. + +Adding dev/beta to a bakery-channel repo: copy `dev-bakery.yml` / +`rc-bakery.yml` (or `bread`'s `dev-release.yml` / `rc-release.yml` if the repo isn't part of this monorepo) from `bread-ecosystem`/`bread`, and swap the repo/binary names the same way the checklist below describes for -`release.yml`. Also create the repo's `dev` and `beta` branches if they don't -exist yet (`git checkout -b dev main` / `git checkout -b beta dev`, push -both). Not every bakery-channel repo needs beta/dev on day one — -`gen-index.sh` silently skips any product with no release dir under a given -track's tree, same as it already does for an unreleased product on stable. +`release.yml`. No branch setup needed beyond the repo's single `main`. Not +every bakery-channel repo needs beta/dev on day one — `gen-index.sh` +silently skips any product with no release dir under a given track's tree, +same as it already does for an unreleased product on stable. Client side: `bakery track show` / `bakery track set ` remembers a global track preference (`~/.local/state/bakery/installed.json`) @@ -127,10 +140,11 @@ missing it; that gap is intentional and about to be moot everywhere. - **Bakery**: write `bakery.toml`, add a `[[products]]` entry to `bread-ecosystem/registry/bread-ecosystem.toml`, copy a sibling's - `release.yml` (prefer one with the same shape: single binary vs. binary + - systemd service — compare against `bread/release.yml` if there's a - service to install, `breadmon/release.yml` if not) and swap the repo - name / binary name / `PKG_DIR`. + `dev-release.yml` / `rc-release.yml` / `release.yml` trio (prefer one with + the same shape: single binary vs. binary + systemd service — compare + against `bread`'s if there's a service to install, `breadmon`'s if not) + and swap the repo name / binary name / `PKG_DIR`. No branch setup beyond + the repo's single `main`. - **Pacman**: write `packaging/PKGBUILD` (or `packaging/arch/PKGBUILD`), copy a sibling's `package.yml` and swap the repo/package name and `system_deps`→`pacman -Syu` package list. @@ -145,11 +159,18 @@ 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 | `release-bakery.yml` recovered from a dead `.github/workflows/release.yml` that referenced a `hestia` self-hosted runner GitHub never had registered | -| bread-ecosystem (bread-theme product) | yes | no | stable, beta, dev | | -| bread, breadbar, breadbox, breadcrumbs, breadpad, breadpaper | yes | yes | stable, beta, dev | complete on all three tracks | -| breadclip, breadmon, breadsearch, breadshot | yes | no | stable, beta, dev | complete on all three tracks | -| breadlock, breadhelp | no | yes | n/a | breadlock's `bakery.toml` was removed as orphaned; its README wrongly claimed it was a registry entry | -| bos-settings | yes | yes | stable only | was missing both the registry entry and `release.yml`; both added | -| bos | no | no | n/a | ISO-only via `release-iso.yml`; had an erroneous `bakery.toml` copy-pasted from bos-settings, removed | +| 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 (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 | +| 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 | + +`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 +single-`main`-branch model for the same rot-avoidance reason. It additionally +carries a `stable` branch that CI fast-forwards to whatever commit the latest +`vX.Y.Z` tag points at — a marker only, never merged into by hand, so it +can't drift the way a manually-promoted branch did before. From f86e299f4a0ea73ff485cd84923b986ddcc8242e Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 31 Jul 2026 11:11:54 +0800 Subject: [PATCH 48/99] CLAUDE.md: update repo-hygiene notes for single-trunk + RC-tag model --- CLAUDE.md | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 17810c9..60f0005 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,26 +4,31 @@ Scope: this file covers *repo hygiene* — branching, remotes, CI, cleanup. It i This repo follows the branch/release workflow documented in `CONTRIBUTING.md` — read and follow it for any git, branch, or release work here (the -dev/beta/main lifecycle, `feature/x`/`fix/x` branch naming, when to cut or -reset `beta`, etc). Don't improvise a different workflow. The short version: -`main` is tag-ready and only moves via a `beta` merge; `dev` and `beta` both -auto-publish a build on every push (dev-track / beta-track respectively); -`beta` is a frozen stabilization branch cut from `dev` roughly weekly and -promoted to `main` roughly monthly. `git branch -f beta dev` (plain -branch-pointer move) is how `beta` gets reset — never `git checkout -main`/`git merge` for this. +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. ## Remotes - `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative. - `github` — GitHub mirror. Push both when publishing. ## CI -- `.forgejo/workflows/package.yml`, `release-bakery.yml`, `release-bread-theme.yml` all trigger only on `push: tags: ['v*']` — pushing to `dev`, `beta`, or `main` doesn't run these. Tag a release to trigger packaging. -- `dev-bakery.yml` / `dev-bread-theme.yml` trigger on `push: branches: ['dev']`; `beta-bakery.yml` / `beta-bread-theme.yml` trigger on `push: branches: ['beta']` — both auto-publish a signed, auto-versioned build to `dl.breadway.dev/{dev,beta}/`. See `docs/release-channels.md` for the full three-track (stable/beta/dev) policy. -- No build/lint/test CI runs on ordinary commits or PRs to `dev`/`beta` beyond what those track workflows do — there's no separate lint/PR-check pipeline. +- `.forgejo/workflows/package.yml`, `release-bakery.yml`, `release-bread-theme.yml` all trigger on `push: tags: ['v*']`, gated to skip any tag containing `-rc.` — pushing to `main` doesn't run these. Tag a release to trigger packaging. +- `dev-bakery.yml` / `dev-bread-theme.yml` trigger on `push: branches: ['main']`; `rc-bakery.yml` / `rc-bread-theme.yml` trigger on `push: tags: ['v*']` gated to *only* run for `-rc.` tags — both auto-publish a signed, auto-versioned build to `dl.breadway.dev/{dev,beta}/`. See `docs/release-channels.md` for the full track (stable/beta/dev) policy. +- No build/lint/test CI runs on ordinary commits or PRs to `main` beyond the dev-track workflow above — there's no separate lint/PR-check pipeline. ## Cleanup -- Delete feature/fix branches (local + remote) once merged. Check with `git branch --merged dev` / `git branch --merged main`. +- Delete feature/fix branches (local + remote) once merged. Check with `git branch --merged main`. - A `fix/audit-findings` branch and a merged `copilot/create-readme-md` branch (both local and on `origin`/`github`) were found stale and fully merged here on 2026-07-21 and removed. ## Don't From 3f5f24198540659a76776a0d27583a0e5be2cd78 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 4 Aug 2026 18:06:20 +0800 Subject: [PATCH 49/99] ci: add shared Arch build image/script for GTK4 product repos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit breadpad's CI used to rebuild libadwaita from source in an uncached Fedora container on every push and broke repeatedly on version drift. The fix there was a pinned Arch container (current gtk4/libadwaita/ gtk4-layer-shell/graphene are prebuilt pacman packages, no source build needed) — this centralizes that image/script here so every GTK4 layer-shell product in the ecosystem can share it instead of each repo carrying its own copy. ci/build.sh takes a product repo root + cargo command, and reads an optional ci/deps.txt from that repo for product-specific extra pacman packages (EXTRA_PKGS build-arg) without forking the Containerfile. Product repos should pin this to a commit sha, not track main — an unrelated change here would otherwise silently affect every product's next release build. --- ci/Containerfile | 30 +++++++++++++++++++++++++++ ci/build.sh | 54 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 ci/Containerfile create mode 100755 ci/build.sh diff --git a/ci/Containerfile b/ci/Containerfile new file mode 100644 index 0000000..3b5722f --- /dev/null +++ b/ci/Containerfile @@ -0,0 +1,30 @@ +# Shared CI build environment for bread-ecosystem GTK4/libadwaita apps. +# +# Arch base: current gtk4/libadwaita/gtk4-layer-shell/graphene are all +# available as prebuilt pacman packages, so no from-source library builds +# are needed (unlike Fedora, where breadpad's CI used to rebuild libadwaita +# from source on every single run and broke repeatedly on version drift). +# +# Base image pinned by digest, package set frozen at build time: this image +# only changes when someone deliberately rebuilds it, not on every push. +# Product repos that depend on this file should pin it to a commit sha +# (see each product's ci/bread-ecosystem.rev), not track `main` — otherwise +# an unrelated change here silently breaks every product's next release. +# +# EXTRA_PKGS lets a product layer on extra pacman packages (see that +# product's ci/deps.txt) without forking this file. +FROM archlinux@sha256:fae033b815a16f930325c2697e620362be4d2e5d739a301b10ad1fc9c8643a06 + +ARG EXTRA_PKGS="" + +RUN pacman -Syu --noconfirm --needed \ + base-devel \ + git \ + pkgconf \ + rust \ + gtk4 \ + libadwaita \ + gtk4-layer-shell \ + graphene \ + ${EXTRA_PKGS} \ + && pacman -Scc --noconfirm diff --git a/ci/build.sh b/ci/build.sh new file mode 100755 index 0000000..4e3269b --- /dev/null +++ b/ci/build.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Shared CI build script for bread-ecosystem GTK4/libadwaita apps. +# +# Builds (or reuses, via docker's own layer cache) the pinned Arch image +# from ci/Containerfile, then runs the given cargo command inside it +# against a product repo checkout. +# +# Usage: ci/build.sh +# e.g. ci/build.sh /path/to/breadpad cargo build --release --locked +# +# If /ci/deps.txt exists (one pacman package per line, +# '#' comments and blank lines ignored), those packages are installed on +# top of the shared base image. +# +# Cargo's registry/git caches are shared across all products (same crates +# regardless of which app is building); CARGO_TARGET_DIR is cached +# per-product. Both persist in named docker volumes across runs. +set -euo pipefail + +if [ $# -lt 2 ]; then + echo "usage: build.sh " >&2 + exit 1 +fi + +REPO_ROOT="$(cd "$1" && pwd)" +shift + +CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PRODUCT="$(basename "$REPO_ROOT")" + +EXTRA_PKGS="" +if [ -f "${REPO_ROOT}/ci/deps.txt" ]; then + EXTRA_PKGS="$(grep -vE '^\s*(#|$)' "${REPO_ROOT}/ci/deps.txt" | tr '\n' ' ')" +fi + +docker build \ + --build-arg "EXTRA_PKGS=${EXTRA_PKGS}" \ + -t "bread-ci:${PRODUCT}" \ + -f "${CI_DIR}/Containerfile" "${CI_DIR}" + +docker run --rm \ + -v "${REPO_ROOT}:/workspace" \ + -v "bread-ci-cargo-registry:/root/.cargo/registry" \ + -v "bread-ci-cargo-git:/root/.cargo/git" \ + -v "bread-ci-${PRODUCT}-target:/cargo-target" \ + -w /workspace \ + -e CARGO_TARGET_DIR=/cargo-target \ + "bread-ci:${PRODUCT}" \ + bash -c ' + set -euo pipefail + "$@" + mkdir -p /workspace/target + cp -a /cargo-target/. /workspace/target/ + ' bash "$@" From 69c24f6d04c7ca140c4d6db2a2d3d8edf9e79c6e Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 5 Aug 2026 09:03:22 +0800 Subject: [PATCH 50/99] ci: take product name explicitly instead of deriving it from checkout dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every consuming product's CI checks out into a directory literally named `src` (see e.g. breadpad's checkout step), so basename(repo_root) resolved to "src" for every product in real CI runs — not the actual product name, which only looked right in local testing because that happened to run from a directory actually named after the product. In production this meant every product sharing the runner would have collided on the same image tag (bread-ci:src) and the same cargo-target cache volume, silently mixing compiled artifacts across unrelated repos. Caught before a second product (breadmon/breadclip/breadshot) started using this and made the collision real. --- ci/build.sh | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/ci/build.sh b/ci/build.sh index 4e3269b..6e6b8b7 100755 --- a/ci/build.sh +++ b/ci/build.sh @@ -5,8 +5,14 @@ # from ci/Containerfile, then runs the given cargo command inside it # against a product repo checkout. # -# Usage: ci/build.sh -# e.g. ci/build.sh /path/to/breadpad cargo build --release --locked +# Usage: ci/build.sh +# e.g. ci/build.sh breadpad /path/to/breadpad cargo build --release --locked +# +# is used verbatim as the image tag and cache-volume name — +# it must be passed explicitly rather than derived from 's +# basename, because every product's CI checks out into a directory literally +# named `src`, which would otherwise collide across every product sharing +# this runner (same image tag, same cargo-target cache volume). # # If /ci/deps.txt exists (one pacman package per line, # '#' comments and blank lines ignored), those packages are installed on @@ -17,16 +23,16 @@ # per-product. Both persist in named docker volumes across runs. set -euo pipefail -if [ $# -lt 2 ]; then - echo "usage: build.sh " >&2 +if [ $# -lt 3 ]; then + echo "usage: build.sh " >&2 exit 1 fi -REPO_ROOT="$(cd "$1" && pwd)" -shift +PRODUCT="$1" +REPO_ROOT="$(cd "$2" && pwd)" +shift 2 CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PRODUCT="$(basename "$REPO_ROOT")" EXTRA_PKGS="" if [ -f "${REPO_ROOT}/ci/deps.txt" ]; then From d45fc422f249848673da78b0056f43b8d7b4d94a Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 5 Aug 2026 13:55:57 +0800 Subject: [PATCH 51/99] bakery: fix correctness, reliability, and security issues from audit Track switches now always take effect on `update --all` instead of silently no-op'ing or permanently refusing on strict semver comparison. `remove` no longer aborts cleanup on the first failed binary removal, orphaning the systemd unit. State reads/writes are now lock-protected and go through fsync'd atomic writes (also fixes a temp-path collision in binary installs). The index loader falls back to a stale-but-signed cache instead of hard-failing offline. systemd units now re-fetch on every update instead of freezing after first install. `doctor` now flags missing recorded binaries. Security hardening: path-traversal guard on all index-controlled filenames, archive extraction now rejects symlink/traversal entries before tar touches disk, archive temp files use secure unique paths, post_install hooks are gated behind --no-hooks/confirmation, response buffering is capped, empty-checksum downloads get a clear error, and both stable-track CI workflows now hard-fail on a missing signing key (matching the existing dev/rc guard) instead of silently publishing an index next to a stale signature. gen-index.sh now publishes the index and its signature atomically. Also: bakery install on an already-installed package no longer silently reinstalls/downgrades, cmd_update exits non-zero for unknown packages, and the unused toml dependency is removed. --- .forgejo/workflows/release-bakery.yml | 8 +- .forgejo/workflows/release-bread-theme.yml | 8 +- Cargo.lock | 36 +- bakery/Cargo.toml | 5 +- bakery/src/download.rs | 35 +- bakery/src/install.rs | 382 +++++++++++++++++---- bakery/src/main.rs | 163 +++++++-- bakery/src/manifest.rs | 44 ++- bakery/src/state.rs | 74 +++- scripts/gen-index.sh | 6 +- 10 files changed, 624 insertions(+), 137 deletions(-) diff --git a/.forgejo/workflows/release-bakery.yml b/.forgejo/workflows/release-bakery.yml index a2373cb..2ac03bf 100644 --- a/.forgejo/workflows/release-bakery.yml +++ b/.forgejo/workflows/release-bakery.yml @@ -59,7 +59,13 @@ jobs: - name: regenerate index.json env: MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} - run: cd src && bash scripts/gen-index.sh + run: | + set -euo pipefail + if [ -z "${MINISIGN_SEC_KEY:-}" ]; then + echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate stable index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the stable track)" + exit 1 + fi + cd src && bash scripts/gen-index.sh - name: upload to GitHub Release env: diff --git a/.forgejo/workflows/release-bread-theme.yml b/.forgejo/workflows/release-bread-theme.yml index a8eb8df..2892e14 100644 --- a/.forgejo/workflows/release-bread-theme.yml +++ b/.forgejo/workflows/release-bread-theme.yml @@ -57,7 +57,13 @@ jobs: - name: regenerate index.json env: MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} - run: cd src && bash scripts/gen-index.sh + run: | + set -euo pipefail + if [ -z "${MINISIGN_SEC_KEY:-}" ]; then + echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate stable index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the stable track)" + exit 1 + fi + cd src && bash scripts/gen-index.sh - name: upload to GitHub Release env: diff --git a/Cargo.lock b/Cargo.lock index e99d7bd..b3391cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -107,9 +107,11 @@ name = "bakery" version = "0.3.1" dependencies = [ "anyhow", + "bread-utils", "chrono", "clap", "dirs", + "fs4", "hex", "minisign-verify", "semver", @@ -117,7 +119,6 @@ dependencies = [ "serde_json", "sha2", "tempfile", - "toml 0.8.23", "ureq", ] @@ -655,6 +656,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs4" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7e180ac76c23b45e767bd7ae9579bc0bb458618c4bc71835926e098e61d15f8" +dependencies = [ + "rustix 0.38.44", + "windows-sys 0.52.0", +] + [[package]] name = "futures-channel" version = "0.3.33" @@ -1325,6 +1336,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1815,6 +1832,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1824,7 +1854,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -2063,7 +2093,7 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] diff --git a/bakery/Cargo.toml b/bakery/Cargo.toml index f65aedd..377a6fe 100644 --- a/bakery/Cargo.toml +++ b/bakery/Cargo.toml @@ -11,7 +11,6 @@ repository = "https://git.breadway.dev/Breadway/bread-ecosystem" anyhow = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -toml = { workspace = true } dirs = { workspace = true } ureq = { workspace = true } sha2 = { workspace = true } @@ -20,6 +19,6 @@ clap = { workspace = true } chrono = { workspace = true } minisign-verify = { workspace = true } semver = { workspace = true } - -[dev-dependencies] +bread-utils = { path = "../bread-utils" } +fs4 = { version = "0.8", features = ["sync"] } tempfile = "3" diff --git a/bakery/src/download.rs b/bakery/src/download.rs index 3362bd0..0e53ef3 100644 --- a/bakery/src/download.rs +++ b/bakery/src/download.rs @@ -4,8 +4,10 @@ use std::path::Path; use crate::manifest::{fetch_binary, Binary}; -/// Download a binary to a temp path, verify its SHA-256, then atomically move -/// it into place. Bails before touching `dest` if the checksum fails. +/// Download a binary, verify its SHA-256, then atomically write it into +/// place (fsynced, temp-in-same-dir-with-unique-name then rename — see +/// `bread_utils::atomic`). Bails before touching `dest` if the checksum +/// fails. pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result<()> { println!(" downloading {}…", binary.name); let bytes = fetch_binary(&binary.dl_url, &binary.github_url) @@ -14,20 +16,8 @@ pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result<()> { verify_sha256(&bytes, &binary.sha256) .with_context(|| format!("checksum mismatch for {}", binary.name))?; - if let Some(dir) = dest.parent() { - std::fs::create_dir_all(dir)?; - } - - let tmp = dest.with_extension("tmp"); - std::fs::write(&tmp, &bytes).context("writing binary to tmp")?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))?; - } - - std::fs::rename(&tmp, dest).context("placing binary")?; + bread_utils::atomic::write_atomic_bytes(dest, &bytes, Some(0o755)) + .with_context(|| format!("placing binary at {}", dest.display()))?; println!(" installed {}", dest.display()); Ok(()) } @@ -38,6 +28,9 @@ pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result<()> { /// [`fetch_and_place`]), and config-example / systemd-unit downloads in /// `install.rs` — so all downloaded artifacts get the same integrity check. pub fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<()> { + if expected_hex.is_empty() { + bail!("index entry has no sha256 recorded — refusing to trust an unverifiable download"); + } let mut hasher = Sha256::new(); hasher.update(bytes); let actual = hex::encode(hasher.finalize()); @@ -80,4 +73,14 @@ mod tests { let hash = sha256_hex(bytes); assert!(verify_sha256(bytes, &hash).is_ok()); } + + #[test] + fn verify_missing_sha256_gives_a_clear_error() { + // gen-index.sh emits an empty sha256 string when a .sha256 sidecar + // is missing — must not fall through to the generic mismatch + // message ("expected: \n actual: "), which is confusing about + // what actually went wrong. + let err = verify_sha256(b"anything", "").unwrap_err(); + assert!(err.to_string().contains("no sha256 recorded")); + } } diff --git a/bakery/src/install.rs b/bakery/src/install.rs index e6ab26e..858edca 100644 --- a/bakery/src/install.rs +++ b/bakery/src/install.rs @@ -1,19 +1,62 @@ -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; +use std::io::IsTerminal; use std::path::{Path, PathBuf}; use std::process::Command; use crate::download::{fetch_and_place, verify_sha256}; use crate::manifest::{fetch_binary, Package, Service}; use crate::state::{InstalledPackage, State}; +use crate::track::Track; -pub fn install_package(pkg: &Package, bin_dir: &Path) -> Result<()> { +/// Rejects a filename that isn't a safe single path component — no `/`, +/// `\`, empty, `.`, or `..`. `bin.name`/`svc.unit`/`cfg.example`/`pkg.name`/ +/// `license_file`/`desktop_file`/`data_archive` all come from the +/// minisign-verified index, so this is defense in depth (not exploitable +/// without a compromised signing key) rather than the primary guard — but +/// closing the path-traversal class is cheap enough to do anyway before any +/// of these are joined onto a fixed base directory. +fn ensure_safe_component(name: &str, what: &str) -> Result<()> { + if name.is_empty() || name == "." || name == ".." || name.contains('/') || name.contains('\\') { + bail!("refusing to install: {what} '{name}' is not a safe filename"); + } + Ok(()) +} + +/// Prompts `prompt [y/N] ` and returns the answer. `assume_yes` (the global +/// `--yes` flag) skips the prompt entirely; otherwise, a non-tty stdin +/// (CI, piped input) answers "no" rather than blocking on a read that will +/// never resolve. +fn confirm(prompt: &str, assume_yes: bool) -> bool { + if assume_yes { + return true; + } + if !std::io::stdin().is_terminal() { + return false; + } + use std::io::Write; + print!("{prompt} [y/N] "); + std::io::stdout().flush().ok(); + let mut buf = String::new(); + std::io::stdin().read_line(&mut buf).ok(); + matches!(buf.trim().to_lowercase().as_str(), "y" | "yes") +} + +pub fn install_package( + pkg: &Package, + bin_dir: &Path, + track: Track, + no_hooks: bool, + assume_yes: bool, +) -> Result<()> { + ensure_safe_component(&pkg.name, "package name")?; println!("installing {}@{}…", pkg.name, pkg.version); // 1. Download and verify all binaries. let mut binary_names = Vec::new(); for bin in &pkg.binaries { + ensure_safe_component(&bin.name, "binary name")?; let install_name = strip_arch_suffix(&bin.name); - let dest = bin_dir.join(&install_name); + let dest = bin_dir.join(install_name); fetch_and_place(bin, &dest)?; binary_names.push(install_name.to_string()); } @@ -45,46 +88,74 @@ pub fn install_package(pkg: &Package, bin_dir: &Path) -> Result<()> { service_names.push(svc.unit.clone()); } - // 7. Run post_install hooks. - for cmd in &pkg.post_install { - run_hook(cmd, &pkg.name)?; + // 7. Run post_install hooks — arbitrary `sh -c` on index-controlled + // strings, so this is gated behind --no-hooks and an interactive + // confirmation rather than running unconditionally. + if !pkg.post_install.is_empty() { + if no_hooks { + println!( + " note: skipped {} post_install hook(s) for {} (--no-hooks)", + pkg.post_install.len(), + pkg.name + ); + } else if confirm( + &format!( + " run {} post_install hook(s) for {}?", + pkg.post_install.len(), + pkg.name + ), + assume_yes, + ) { + for cmd in &pkg.post_install { + run_hook(cmd, &pkg.name)?; + } + } else { + println!(" skipped post_install hooks for {} (declined)", pkg.name); + } } - // 8. Record in state. - let mut state = State::load()?; - state.record(InstalledPackage { - name: pkg.name.clone(), - version: pkg.version.clone(), - binaries: binary_names, - services: service_names, - installed_at: chrono::Utc::now().to_rfc3339(), - }); - state.save()?; + // 8. Record in state, under an exclusive lock so a concurrent `bakery` + // invocation can't clobber this install's record with its own. + State::with_lock(|state| { + state.record(InstalledPackage { + name: pkg.name.clone(), + version: pkg.version.clone(), + binaries: binary_names, + services: service_names, + installed_at: chrono::Utc::now().to_rfc3339(), + track, + }); + Ok(()) + })?; println!(" {} installed successfully", pkg.name); warn_path_if_needed(bin_dir); Ok(()) } -pub fn remove_package(pkg_name: &str, bin_dir: &Path) -> Result<()> { - let mut state = State::load()?; - let installed = match state.remove(pkg_name) { +pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool) -> Result<()> { + let installed = State::with_lock(|state| Ok(state.remove(pkg_name)))?; + let installed = match installed { Some(p) => p, None => { eprintln!("{pkg_name} is not installed"); return Ok(()); } }; - // Commit removal immediately — file cleanup below is best-effort. - state.save()?; + // State is already committed by with_lock above — everything from here + // is best-effort file cleanup, and must all run even if part of it fails. - // Remove binaries. + // Remove binaries. Collect failures instead of aborting on the first one + // so a stuck/permission-denied binary doesn't skip service removal and + // the config/data-preserved messages below. + let mut failures = Vec::new(); for bin in &installed.binaries { let path = bin_dir.join(bin); if path.exists() { - std::fs::remove_file(&path) - .with_context(|| format!("removing {}", path.display()))?; - println!(" removed {}", path.display()); + match std::fs::remove_file(&path) { + Ok(()) => println!(" removed {}", path.display()), + Err(e) => failures.push(format!("{}: {e}", path.display())), + } } } @@ -93,7 +164,7 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path) -> Result<()> { let service_dir = systemd_user_dir(); for unit in &installed.services { let unit_path = service_dir.join(unit); - if confirm_remove_unit(unit) { + if confirm_remove_unit(unit, assume_yes) { let _ = Command::new("systemctl") .args(["--user", "disable", "--now", unit]) .status(); @@ -121,6 +192,14 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path) -> Result<()> { println!(" data preserved at {}", data_dir.display()); } + if !failures.is_empty() { + eprintln!(" failed to remove {} binary/binaries:", failures.len()); + for f in &failures { + eprintln!(" {f}"); + } + bail!("{pkg_name} removed from state, but some binaries could not be deleted"); + } + println!(" {pkg_name} removed"); Ok(()) } @@ -130,6 +209,7 @@ fn scaffold_config(cfg: &crate::manifest::ConfigScaffold, pkg: &Package) -> Resu std::fs::create_dir_all(&dir)?; if let Some(example) = &cfg.example { + ensure_safe_component(example, "config.example")?; let dest = dir.join(example); if !dest.exists() { if let Some((primary, fallback)) = pkg.artifact_urls(example) { @@ -217,6 +297,7 @@ fn fetch_verify_write( } fn install_license(pkg: &Package, filename: &str) -> Result<()> { + ensure_safe_component(filename, "license_file")?; let dest = dirs::data_dir() .unwrap_or_else(|| PathBuf::from("~/.local/share")) .join("licenses") @@ -226,6 +307,7 @@ fn install_license(pkg: &Package, filename: &str) -> Result<()> { } fn install_desktop_file(pkg: &Package, filename: &str) -> Result<()> { + ensure_safe_component(filename, "desktop_file")?; let dest = dirs::data_dir() .unwrap_or_else(|| PathBuf::from("~/.local/share")) .join("applications") @@ -234,6 +316,7 @@ fn install_desktop_file(pkg: &Package, filename: &str) -> Result<()> { } fn install_data_archive(pkg: &Package, filename: &str) -> Result<()> { + ensure_safe_component(filename, "data_archive")?; let data_dir = dirs::data_dir() .unwrap_or_else(|| PathBuf::from("~/.local/share")) .join(&pkg.name); @@ -253,7 +336,15 @@ fn fetch_extract_archive( sha256: &Option, dest_dir: &Path, ) -> Result<()> { - let tmp_archive = std::env::temp_dir().join(format!("bakery-{}-{filename}", pkg.name)); + // A securely-named, process-unique temp file — the old + // `std::env::temp_dir().join(format!("bakery-{name}-{filename}"))` was a + // predictable path on a shared /tmp, so another local user could + // pre-plant a symlink there for `fetch_verify_write`'s write to follow. + let tmp_archive = tempfile::Builder::new() + .prefix(&format!("bakery-{}-", pkg.name)) + .tempfile() + .context("creating temp file for archive download")? + .into_temp_path(); fetch_verify_write(pkg, filename, sha256, &tmp_archive, "data archive")?; if !tmp_archive.exists() { @@ -261,13 +352,21 @@ fn fetch_extract_archive( return Ok(()); } + verify_archive_paths(&tmp_archive)?; + std::fs::create_dir_all(dest_dir)?; let status = Command::new("tar") - .args(["xzf", &tmp_archive.to_string_lossy(), "-C"]) + .args([ + "xzf", + &tmp_archive.to_string_lossy(), + "--no-same-owner", + "--no-same-permissions", + "-C", + ]) .arg(dest_dir) .status() .with_context(|| format!("running tar to extract {filename}"))?; - let _ = std::fs::remove_file(&tmp_archive); + // `tmp_archive` (a `TempPath` guard) deletes the file when it drops here. if status.success() { println!(" extracted {filename} to {}", dest_dir.display()); @@ -277,44 +376,99 @@ fn fetch_extract_archive( Ok(()) } +/// Lists `archive_path`'s contents via `tar tvf` and rejects the archive +/// outright (no extraction) if any entry is a symlink or has an unsafe path +/// (`..` component, or absolute). `--no-same-owner --no-same-permissions` on +/// the actual extraction covers ownership/permission escalation, but not a +/// symlink or `../` entry walking the extraction outside `dest_dir` — this +/// closes that gap before `tar` ever touches disk. +fn verify_archive_paths(archive_path: &Path) -> Result<()> { + let output = Command::new("tar") + .arg("tvf") + .arg(archive_path) + .output() + .context("listing archive contents")?; + if !output.status.success() { + bail!( + "tar tvf exited with {} listing archive contents — refusing to extract", + output.status + ); + } + + let listing = String::from_utf8_lossy(&output.stdout); + for line in listing.lines() { + if line.trim().is_empty() { + continue; + } + // tar -tvf: ` /