From 157ed6e3782ac2facd8b517d7c247713f36854ed Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 21 Jul 2026 19:07:49 +0800 Subject: [PATCH 01/89] 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 02/89] 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 03/89] 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 04/89] 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 05/89] 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 06/89] 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 07/89] 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 08/89] 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 09/89] 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 10/89] 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 11/89] 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 12/89] 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 13/89] 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 14/89] 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 15/89] 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 16/89] 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 17/89] 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 18/89] 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 19/89] 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 20/89] 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 21/89] 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 22/89] 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 23/89] 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 24/89] 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 25/89] 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 26/89] 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 27/89] 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 28/89] 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 29/89] 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 30/89] 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 31/89] 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 32/89] 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 33/89] 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 34/89] 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 35/89] =?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 36/89] 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 37/89] 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 38/89] 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 39/89] 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 40/89] 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 41/89] 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: ` /