Compare commits

...

17 commits

Author SHA1 Message Date
Breadway
4ac54c610d bakery: add stable/beta/dev build tracks
All checks were successful
dev bread-theme / build (push) Successful in 13s
dev bakery / build (push) Successful in 1m2s
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.
2026-07-22 09:19:31 +08:00
Breadway
db2fa3c4b4 ci: remove GitHub push-mirror workflow
Some checks failed
release bakery / build (push) Failing after 43s
release bread-theme / build (push) Failing after 12s
Build and publish package / package (push) Successful in 1m7s
2026-07-21 19:19:56 +08:00
Breadway
0b272838df gen-index.sh: exclude .minisig sidecar files from the binaries list
Some checks failed
Mirror to GitHub / mirror (push) Failing after 1s
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.
2026-07-21 19:11:15 +08:00
Breadway
157ed6e378 bakery: rotate signing key, fix broken index-signature verification
Some checks failed
Mirror to GitHub / mirror (push) Failing after 1s
release bakery / build (push) Failing after 44s
release bread-theme / build (push) Failing after 15s
Build and publish package / package (push) Successful in 1m13s
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.
2026-07-21 19:07:49 +08:00
Breadway
8e82d2d833 Add push-mirror provisioning, bread_client, README/identity fixes; bump to 0.3.0
Some checks failed
Mirror to GitHub / mirror (push) Failing after 2s
release bakery / build (push) Failing after 58s
Build and publish package / package (push) Successful in 1m18s
release bread-theme / build (push) Failing after 11s
2026-07-19 03:07:40 +08:00
Breadway
ab4e882baa Add push-mirror provisioning + old mirror.yml cleanup scripts
Replaces the per-repo .forgejo/workflows/mirror.yml + MIRROR_TOKEN
pattern with Forgejo's native Push Mirror feature, provisioned
centrally instead of per-repo:

- setup-push-mirrors.sh: reads the repo list live from the Forgejo API
  (GET /users/Breadway/repos — confirmed Breadway is a user account,
  not an org, so the /orgs/ endpoint 404s and this falls back
  correctly) instead of a hardcoded repo list, checks each repo's
  existing push_mirrors for idempotency, and POSTs a new one
  (sync_on_commit + 8h interval) for any repo missing one. Private
  repos are skipped by default (found novacana-engine on the live
  account) since mirroring one to a public GitHub repo is a
  disclosure decision this script should never make silently — pass
  --include-private to override per-run. --dry-run prints every
  request (GH token redacted) without POSTing.

- cleanup-old-mirror-workflows.sh: deletes mirror.yml from each repo's
  default branch (live commit via the contents API, not a local
  change) and removes the MIRROR_TOKEN secret. Refuses to run at all
  — dry-run included — without an explicit
  --i-have-verified-push-mirrors-work flag, since it should only ever
  run after confirming the new push mirrors are actually syncing.

Neither script has been run for real. setup-push-mirrors.sh has only
been run with --dry-run against the live Forgejo API (read-only GETs);
cleanup-old-mirror-workflows.sh has not been run at all beyond
confirming its guardrail refuses to execute.
2026-07-17 14:11:30 +08:00
Breadway
98812af020 Register bos-settings as a bakery-channel product
bos-settings has carried a bakery.toml since it was split out (per
bos/DESIGN.md: "bos-settings gets a bakery.toml and is added to the
bread-ecosystem registry"), but the registry-side half of that was
never done, so gen-index.sh has never actually picked it up. Paired
with the release.yml added in bos-settings-fix-worktree tonight.
2026-07-17 14:06:25 +08:00
Breadway
025e27b496 Move bakery's own release workflow from .github to .forgejo
.github/workflows/release.yml built and published the bakery binary
itself, but it lived under .github/ and targeted runs-on:
[self-hosted, hestia] — a runner label only registered against
Forgejo, never against GitHub Actions. It has therefore never run;
get.sh has been pointing at dl.breadway.dev/bakery/... this whole time
with nothing actually publishing there.

Recreated the same logic as .forgejo/workflows/release-bakery.yml,
matching the sibling release-bread-theme.yml in this repo (manual
clone instead of actions/checkout, GH_RELEASE_TOKEN instead of the
GitHub-provided GITHUB_TOKEN, same dormant-until-provisioned minisign
signing step). Removed the dead .github copy.
2026-07-17 14:06:12 +08:00
Breadway
6ae7edb83c bread-utils: expose xdg::home_dir(); document two more tilde-fallback bug sites
home_dir() was already computed internally (home_or_root) but not exposed
— breadarr-shared's own expand_home() helper needs it to fix the same bug
class documented in this module: its own fallback (when HOME itself isn't
set) returned the literal unexpanded "~/..." input string instead of a
real path. Also documented breadmon/src/profile.rs's profiles_dir(),
fixed in that repo's own commit.
2026-07-17 10:11:53 +08:00
Breadway
49b384c549 bread-onnx: default a MIGraphX kernel cache path if the caller hasn't set one
Found while investigating why breadpad's test suite went from fast (silent
CPU-only ROCm no-op) to multi-minutes slow after fixing the ROCm->MIGraphX
bug in the previous commit: MIGraphX has no Rust-level cache-directory
builder (unlike OpenVINO/Vitis) — it's controlled purely by the
ORT_MIGRAPHX_MODEL_CACHE_PATH env var, read by the underlying MIGraphX
library. Left unset, every session build recompiles every kernel from
scratch with no persistence at all, visible in breadpad's test output as
repeated `migraphx_save: Error: ... write_buffer: Failure opening file:
""/<hash>.mxr` lines (empty path prefix = var never set).

breadmill's own packaged systemd unit already sets this explicitly
(packaging/breadmill.service: ORT_MIGRAPHX_MODEL_CACHE_PATH=%h/.cache/
breadsearch/migraphx-cache) but nothing enforced any other consumer doing
the same — breadpad had no systemd unit or cache path at all. Provider::
MiGraphX's to_dispatch now defaults ORT_MIGRAPHX_MODEL_CACHE_PATH to
~/.cache/bread-onnx/migraphx if unset, so every consumer gets kernel-cache
persistence for free instead of only the ones that remembered to
configure it themselves.
2026-07-17 10:08:26 +08:00
Breadway
41276479aa bread-utils: add socket timeouts to hypr::request
Neither original implementation this replaces (breadbox's
get_active_workspace, breadclip's hyprctl_json) set a read/write timeout
on the Hyprland IPC socket — a wedged or mid-reload Hyprland instance
could hang the call indefinitely, and every current caller runs it on the
GTK main thread, so a hang here freezes the whole UI. Found while
reviewing the crate for this pass's "any existing bugs" sweep; same class
of bug as everything else bread_utils::proc/hypr exists to fix, just one
I'd introduced myself by porting the original code faithfully without
also porting the missing safety net.
2026-07-17 10:05:26 +08:00
Breadway
49c63c8ccf bread-utils: fix flaky test isolation around process-global env vars
singleton.rs's tests used to call the real kill() on the current test
process itself (the recorded "other instance" PID was our own, since
tests run single-process) — split try_acquire (side-effect-free) out from
toggle_or_kill (sends the signal) so the concurrency assertions no longer
risk SIGTERM-ing the test binary.

Separately, hypr.rs's env-var test mutates HYPRLAND_INSTANCE_SIGNATURE/
XDG_RUNTIME_DIR process-globally; cargo runs tests in parallel threads by
default, so it could race a concurrently-running singleton or xdg test
expecting the real XDG_RUNTIME_DIR, intermittently failing them with
ENOENT. Added a shared env_test_lock() all env-var-touching tests now
acquire for their duration.

Verified via 5 repeated full test runs with zero flakes (28/28 passing
each time).
2026-07-17 10:02:42 +08:00
Breadway
853ee33415 Add bread-utils and bread-onnx: shared crates for ecosystem-wide duplication
bread-utils extracts genuinely duplicated logic found across breadbox,
breadclip, breadmon, breadcrumbs, bos-settings, and breadhelp:

- hypr: Hyprland socket1 request/response client (breadbox's
  get_active_workspace + breadclip's position.rs hyprctl_json were
  near-identical), socket2 path resolution (breadmon), and a
  version-tolerant `fullscreen` field parser (Hyprland has shipped both
  bool and int representations across versions).
- singleton: correct flock-based single-instance toggle, replacing the
  TOCTOU-prone read-pid/check-proc/kill/write-pid pattern duplicated
  verbatim between breadbox and breadclip (breadclip's own comment says
  "matches breadbox pattern").
- proc: breadcrumbs' timeout-guarded subprocess runner, promoted verbatim
  as the one implementation in the ecosystem that already got this right.
- atomic + xdg: atomic (temp-then-rename) file writes with an optional
  .bak-before-overwrite variant, and XDG path helpers that never fall back
  to a literal "~/..." string (the exact breadclip-core and
  breadpad-shared bug: PathBuf never expands `~`).
- tomlcfg (feature "toml"): the load_doc/save_doc TOML-editing discipline
  bos-settings and breadhelp both implemented byte-for-byte identically in
  the same fix pass that introduced it.
- gtk_popup (feature "gtk"): layer-shell overlay window setup, visible-row
  navigation, and click-outside-close, deduplicated from breadbox and
  breadclip (~150 duplicated lines, per both apps' own "same as breadbox"
  comments).

bread-onnx extracts the embedding pipeline (tokenize -> tensor build ->
mean-pool -> L2-normalize) duplicated near-verbatim between breadarr and
breadsearch, a shared execution-provider session builder with loud EP-
registration logging, and a model download+integrity helper. Defaults AMD
iGPU acceleration to ort::ep::MIGraphX (not ROCm) per this machine's own
breadsearch-gpu-backends lesson: ROCMExecutionProvider silently no-ops to
CPU on distro ROCm onnxruntime builds compiled with --use_migraphx.

Both crates build and pass their own test suites standalone. Consumer
migrations follow in subsequent commits.
2026-07-17 09:15:54 +08:00
Breadway
394a252f9e Fix audit findings: bakery index signing, artifact checksums, stale theme docs
- Add minisign-based signing/verification for the bakery index:
  scripts/gen-index.sh signs index.json (MINISIGN_SEC_KEY env var, dormant
  no-op with a loud warning until a key is provisioned); bakery/src/manifest.rs
  fetches index.json.minisig and verifies it with minisign-verify against a
  hardcoded PUBKEY before parsing/caching, and re-verifies the cached copy
  on every load (falls back to one re-fetch if the cache predates signing
  or fails verification; a fresh fetch that fails verification is a hard
  error).
- Close the previously-unchecksummed config-example and systemd-unit
  downloads in bakery/src/install.rs (scaffold_config, install_service):
  index.json now carries `sha256`/`example_sha256` for these artifacts
  (computed in gen-index.sh), verified via the same download::verify_sha256
  used for binaries. Downloads without a matching sha256 in the index are
  refused rather than installed unverified.
- scripts/get.sh now verifies the bakery release binary itself against a
  pinned minisign public key before installing it (falls back to the
  existing sha256-only check with a loud warning if no .minisig is
  published yet or minisign isn't installed; a present-but-invalid
  signature is a hard failure).
- Add dormant "sign release binary" steps to the bakery and bread-theme
  release workflows (.github/workflows/release.yml,
  .forgejo/workflows/release-bread-theme.yml), gated on secrets that are
  not yet configured — binaries ship unsigned exactly as before until the
  owner wires up the secret.
- .gitignore: add *.minisign-sec / minisign.key so the signing key can
  never be committed by accident.
- bread-theme: fix stale docs describing a "Catppuccin Mocha fallback"
  (BREAD_DESIGN_SYSTEM.md, README.md, Cargo.toml/bakery.toml/registry
  descriptions) — the actual implementation (palette.rs) uses a fixed BOS
  dark base with only accent colors from pywal.
- bread-theme: fix the legacy css_vars() path, which had its own
  hand-written @define-color block that predated the `accent` and computed
  `on-*` ink colors used by the rest of the stylesheet — any caller whose
  CSS referenced those names against css_vars()'s output would hit
  undefined colors (the illegible-text bug). css_vars() now delegates to
  the same define_colors() the full stylesheet uses, so the two can't
  drift apart again.
2026-07-17 03:37:51 +08:00
Breadway
fa0597f482 bread-theme: register as a bakery-managed product
Some checks failed
Mirror to GitHub / mirror (push) Successful in 1s
release bread-theme / build (push) Failing after 13s
Build and publish package / package (push) Successful in 1m9s
Was previously an unmanaged binary baked into /etc/skel — never updated by
bakery or pacman, which is why fresh BOS installs (and existing ones) kept
regenerating the shared theme.css with the pre-fix pywal-driven background
even after bos-settings itself shipped the fix.
2026-07-16 19:02:02 +08:00
Breadway
17d1bb8580 bread-theme: pin background/surface/overlay/foreground, only accents follow pywal
All checks were successful
Mirror to GitHub / mirror (push) Successful in 1s
Build and publish package / package (push) Successful in 58s
A light or muddy-toned wallpaper (e.g. an all-beige photo) makes pywal hand
back a light or off-hue background/surface slot, and every bread GUI's
panels inherited it directly — the app stopped looking like a dark BOS tool
and started looking like whatever colour the wallpaper happened to be.

background/foreground/color0(surface)/color7(overlay) are now fixed BOS
dark-theme constants, never read from pywal's special/colors blocks. Only
color1-6 (the actual accent slots) still track the wallpaper via pywal, which
is the feature breadpaper is for.
2026-07-03 22:46:40 +08:00
Breadway
5bc4796a98 registry: add breadmon, breadsearch, breadclip, breadshot
All checks were successful
Mirror to GitHub / mirror (push) Successful in 1s
2026-07-02 21:08:52 +08:00
52 changed files with 5096 additions and 550 deletions

View file

@ -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" </dev/null
echo "signed bakery-x86_64"
else
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/.
- name: regenerate beta index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
TRACK: beta
run: cd src && bash scripts/gen-index.sh

View file

@ -0,0 +1,56 @@
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.
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 bread-theme --bin bread-theme
- 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"
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/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" </dev/null
echo "signed bread-theme-x86_64"
else
echo "::warning::BAKERY_MINISIGN_SEC_KEY_PATH not set — shipping bread-theme-x86_64 UNSIGNED"
fi
- name: regenerate beta index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
TRACK: beta
run: cd src && bash scripts/gen-index.sh

View file

@ -0,0 +1,80 @@
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.
on:
push:
branches: ['dev']
paths:
- 'bakery/**'
- 'Cargo.toml'
- 'Cargo.lock'
- '.forgejo/workflows/dev-bakery.yml'
jobs:
build:
runs-on: [self-hosted, hestia]
steps:
- name: checkout
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch dev --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
# Auto-bumps the patch version from Cargo.toml and appends a
# timestamp+sha dev suffix — no developer discipline required, and the
# result is visibly "ahead of" the last stable patch release while
# staying valid semver (comparable within the dev track by bakery's
# `is_newer`).
- name: compute dev version
run: |
set -euo pipefail
cd src
CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')"
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))-dev.${TS}+${SHA}" >> "$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" </dev/null
echo "signed bakery-x86_64"
else
echo "::warning::BAKERY_MINISIGN_SEC_KEY_PATH not set — shipping bakery-x86_64 UNSIGNED"
fi
# No GitHub Release upload step here, unlike release-bakery.yml — dev
# builds happen on every push and would spam a release per commit, so
# dl.breadway.dev/dev/ is the only distribution point for this track.
- name: regenerate dev index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
TRACK: dev
run: cd src && bash scripts/gen-index.sh

View file

@ -0,0 +1,69 @@
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.
on:
push:
branches: ['dev']
paths:
- 'bread-theme/**'
- 'Cargo.toml'
- 'Cargo.lock'
- '.forgejo/workflows/dev-bread-theme.yml'
jobs:
build:
runs-on: [self-hosted, hestia]
steps:
- name: checkout
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch dev --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 dev version
run: |
set -euo pipefail
cd src
CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')"
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))-dev.${TS}+${SHA}" >> "$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" </dev/null
echo "signed bread-theme-x86_64"
else
echo "::warning::BAKERY_MINISIGN_SEC_KEY_PATH not set — shipping bread-theme-x86_64 UNSIGNED"
fi
- name: regenerate dev index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
TRACK: dev
run: cd src && bash scripts/gen-index.sh

View file

@ -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/*'

View file

@ -0,0 +1,74 @@
name: release bakery
on:
push:
tags: ['v*']
jobs:
build:
runs-on: [self-hosted, hestia]
steps:
- name: checkout
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build
run: cd src && cargo build --release --locked -p bakery
- name: test
run: cd src && cargo test --release --locked -p bakery
- name: prepare artifacts
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
PKG_DIR="/srv/breadway-dl/bakery/${VERSION}"
mkdir -p "${PKG_DIR}"
cp "src/target/release/bakery" "${PKG_DIR}/bakery-x86_64"
strip "${PKG_DIR}/bakery-x86_64"
sha256sum "${PKG_DIR}/bakery-x86_64" | awk '{print $1}' \
> "${PKG_DIR}/bakery-x86_64.sha256"
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
ln -sfn "${VERSION}" "/srv/breadway-dl/bakery/latest"
# Signs the bakery binary itself with the shared bakery ecosystem signing
# key (same key that signs index.json and bread-theme — see
# release-bread-theme.yml). BAKERY_MINISIGN_SEC_KEY_PATH is a *path on
# this runner's disk* (hestia has persistent storage), not the key
# contents. Dormant (binary ships unsigned, as today) until that secret
# is provisioned.
- name: sign release binary
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
PKG_DIR="/srv/breadway-dl/bakery/${VERSION}"
if [ -n "${MINISIGN_SEC_KEY:-}" ]; then
minisign -W -S -s "${MINISIGN_SEC_KEY}" -m "${PKG_DIR}/bakery-x86_64" \
-x "${PKG_DIR}/bakery-x86_64.minisig" </dev/null
echo "signed bakery-x86_64"
else
echo "::warning::BAKERY_MINISIGN_SEC_KEY_PATH not set — shipping bakery-x86_64 UNSIGNED"
fi
- name: regenerate index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: cd src && bash scripts/gen-index.sh
- name: upload to GitHub Release
env:
GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }}
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
PKG_DIR="/srv/breadway-dl/bakery/${VERSION}"
gh release create "${GITHUB_REF_NAME}" --repo Breadway/bread-ecosystem \
--title "bakery ${GITHUB_REF_NAME}" --generate-notes 2>/dev/null || true
ASSETS="${PKG_DIR}/bakery-x86_64 ${PKG_DIR}/bakery-x86_64.sha256"
[ -f "${PKG_DIR}/bakery-x86_64.minisig" ] && ASSETS="${ASSETS} ${PKG_DIR}/bakery-x86_64.minisig"
gh release upload "${GITHUB_REF_NAME}" --repo Breadway/bread-ecosystem ${ASSETS} --clobber

View file

@ -0,0 +1,72 @@
name: release bread-theme
on:
push:
tags: ['v*']
jobs:
build:
runs-on: [self-hosted, hestia]
steps:
- name: checkout
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build
run: cd src && cargo build --release --locked -p bread-theme --bin bread-theme
- name: prepare artifacts
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
PKG_DIR="/srv/breadway-dl/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/bread-theme/latest"
# Signs the bread-theme binary with the shared bakery ecosystem signing
# key (same key that signs index.json — get.sh / manifest.rs pin the
# matching public key). BAKERY_MINISIGN_SEC_KEY_PATH is a *path on this
# runner's disk* (hestia has persistent storage, unlike a fresh
# GitHub-hosted runner), not the key contents — see the handoff note
# in scripts/gen-index.sh. Dormant (binary ships unsigned, as today)
# until that secret is provisioned.
- name: sign release binary
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
PKG_DIR="/srv/breadway-dl/bread-theme/${VERSION}"
if [ -n "${MINISIGN_SEC_KEY:-}" ]; then
minisign -W -S -s "${MINISIGN_SEC_KEY}" -m "${PKG_DIR}/bread-theme-x86_64" \
-x "${PKG_DIR}/bread-theme-x86_64.minisig" </dev/null
echo "signed bread-theme-x86_64"
else
echo "::warning::BAKERY_MINISIGN_SEC_KEY_PATH not set — shipping bread-theme-x86_64 UNSIGNED"
fi
- name: regenerate index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: cd src && bash scripts/gen-index.sh
- name: upload to GitHub Release
env:
GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }}
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
PKG_DIR="/srv/breadway-dl/bread-theme/${VERSION}"
gh release create "${GITHUB_REF_NAME}" --repo Breadway/bread-ecosystem \
--title "bread-ecosystem ${GITHUB_REF_NAME}" --generate-notes 2>/dev/null || true
ASSETS="${PKG_DIR}/bread-theme-x86_64 ${PKG_DIR}/bread-theme-x86_64.sha256"
[ -f "${PKG_DIR}/bread-theme-x86_64.minisig" ] && ASSETS="${ASSETS} ${PKG_DIR}/bread-theme-x86_64.minisig"
gh release upload "${GITHUB_REF_NAME}" --repo Breadway/bread-ecosystem ${ASSETS} --clobber

View file

@ -1,53 +0,0 @@
name: release
on:
push:
tags: ["v*"]
permissions:
contents: write
env:
DL_DIR: /srv/breadway-dl
jobs:
build:
runs-on: [self-hosted, hestia]
steps:
- uses: actions/checkout@v4
- name: build
run: cargo build --release --locked -p bakery
- name: test
run: cargo test --locked --workspace
- name: prepare artifacts
run: |
VERSION="${GITHUB_REF_NAME#v}"
PKG_DIR="${DL_DIR}/bakery/${VERSION}"
mkdir -p "${PKG_DIR}"
cp target/release/bakery "${PKG_DIR}/bakery-x86_64"
strip "${PKG_DIR}/bakery-x86_64"
sha256sum "${PKG_DIR}/bakery-x86_64" | awk '{print $1}' \
> "${PKG_DIR}/bakery-x86_64.sha256"
cp bakery.toml "${PKG_DIR}/bakery.toml"
ln -sfn "${VERSION}" "${DL_DIR}/bakery/latest"
- name: regenerate index.json
run: bash "${GITHUB_WORKSPACE}/scripts/gen-index.sh"
- name: upload to GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="${GITHUB_REF_NAME#v}"
PKG_DIR="${DL_DIR}/bakery/${VERSION}"
gh release create "${GITHUB_REF_NAME}" \
--title "bakery v${VERSION}" --generate-notes 2>/dev/null || true
gh release upload "${GITHUB_REF_NAME}" \
"${PKG_DIR}/bakery-x86_64" \
"${PKG_DIR}/bakery-x86_64.sha256" \
--clobber

6
.gitignore vendored
View file

@ -1 +1,7 @@
/target/
# minisign secret keys must never be committed — the bakery/index signing
# key lives outside this repo entirely (see scripts/gen-index.sh /
# scripts/get.sh for how it's consumed via MINISIGN_SEC_KEY).
*.minisign-sec
minisign.key

View file

@ -48,16 +48,27 @@ Establish a visual hierarchy with consistent rounding:
## Color System
All projects use **pywal dynamic theming** with **Catppuccin Mocha** as the fallback palette:
All projects use **pywal dynamic theming** for accents, layered on a **fixed BOS
dark base** — background, surface, overlay, and foreground never come from
pywal, only the accent slots (color16) track the current wallpaper:
- **Background**: `#1e1e2e` (Catppuccin)
- **Foreground**: `#cdd6f4` (Catppuccin)
- **Surface**: `#181825` (Catppuccin)
- **Accent**: Dynamic (from pywal)
- **Background**: `#0c0c0c` (fixed)
- **Foreground**: `#e8e8e8` (fixed)
- **Surface**: `#1a1a1a` (fixed, `color0`)
- **Overlay**: `#d8d8d8` (fixed, `color7`)
- **Accent**: Dynamic (from pywal `color4`), with curated bread-toned defaults
before any wallpaper has been set
Without pinning bg/surface/overlay, a light or muddy-toned wallpaper makes
pywal hand back a light or off-hue background, and every bread GUI's panels
inherit it — see `bread-theme/src/palette.rs` for the implementation.
Color palette slots (via wal):
- color0color7: ANSI colors
- color0color7: ANSI colors (0 and 7 fixed, 16 pywal-derived)
- Semantic: red, green, yellow, blue, pink, teal
- Computed ink: `on-bg`, `on-surface`, `on-accent`, `on-red`, `on-overlay`
black or white text, whichever is legible against that background (see
`bread_theme::ink_on`)
## Component Standards
@ -104,8 +115,9 @@ add only app-specific rules:
hardcoded Nord palette; migrated to the shared stylesheet).
- **breadcrumbs** — CLI tool; ANSI colours only, no GUI styling.
> Palette note: the fallback is Catppuccin Mocha, but installs (e.g. BOS) drive
> the real palette from pywal — BOS ships a black-base palette.
> Palette note: background/surface/overlay/foreground are a fixed BOS dark
> base, never pywal-derived; only the accent slots (color16) track the
> current wallpaper via pywal.
## Future Consistency Checks

1161
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,12 @@
[workspace]
members = ["bakery", "bread-theme"]
members = ["bakery", "bread-theme", "bread-utils", "bread-onnx"]
resolver = "2"
[workspace.package]
version = "0.2.3"
version = "0.3.1"
edition = "2021"
license = "MIT"
authors = ["Breadway <rileyhorsham@gmail.com>"]
authors = ["Breadway <plasticbread849@gmail.com>"]
[workspace.dependencies]
anyhow = "1"
@ -19,6 +19,9 @@ sha2 = "0.10"
hex = "0.4"
clap = { version = "4", features = ["derive", "env"] }
chrono = "0.4"
minisign-verify = "0.2"
tracing = "0.1"
semver = "1"
[profile.release]
lto = "thin"

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Breadway
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -17,6 +17,7 @@ bakery install breadbar
| `breadbox` | GTK4 fuzzy app launcher for Hyprland with context-aware sorting; ships an icon-sync daemon (`breadbox-sync`) |
| `breadcrumbs` | Profile-aware Wi-Fi state machine with Tailscale exit-node management and a self-healing watch daemon |
| `breadpad` | Quick-capture scratchpad popup with AI-powered note classification, reminders, recurrence, and a full note viewer (`breadman`) |
| `breadpaper` | Wallpaper manager for the bread desktop |
## Recommended keybinds
@ -35,17 +36,31 @@ to keys.
## Theming
All GUIs share one look via `bread-theme`. The `bread-theme` CLI renders the
component stylesheet from your pywal palette (Catppuccin Mocha fallback) to
`$XDG_RUNTIME_DIR/bread/theme.css`; every app loads that file and **live-reloads**
it, so changing your wallpaper recolours the whole ecosystem with no rebuilds:
All GUI products (breadbar, breadbox, breadpad) share one stylesheet via
`bread-theme`. Background, surface, overlay, and foreground are always BOS's
fixed dark values; only the accent colors are read from the pywal palette in
`~/.cache/wal/colors.json`. When that file is absent, the accents fall back
to BOS's curated bread-toned defaults (not Catppuccin Mocha). The stylesheet
is written to `$XDG_RUNTIME_DIR/bread/theme.css`; running apps watch that
file and recolour live when it changes. Per-app CSS overrides live at
`~/.config/<app>/style.css`.
```sh
wal -i ~/Pictures/wall.png # regenerate pywal palette
bread-theme generate # render the shared stylesheet (run from a wal hook)
```
See [`BREAD_DESIGN_SYSTEM.md`](BREAD_DESIGN_SYSTEM.md) for the tokens (fonts,
`bread-theme` subcommands:
| Subcommand | Description |
|------------|-------------|
| `generate` | Render the current palette and write the shared stylesheet (default) |
| `reload` | Same as `generate`; use after a palette change to trigger live recolour in running apps |
| `path` | Print the stylesheet path |
| `print` | Render the stylesheet to stdout without writing |
The shared theming logic lives in the `bread-theme` crate in this repo. See
[`BREAD_DESIGN_SYSTEM.md`](BREAD_DESIGN_SYSTEM.md) for the design tokens (fonts,
spacing, radii, colour roles) the stylesheet is built from.
## Installing bakery
@ -92,14 +107,6 @@ bakery remove <pkg> # remove a package (data files are never deleted)
Install all required deps with `sudo pacman -S <packages>`. Use `pacman -Q <pkg>` to check whether any are already present.
## Theming
All GUI products (breadbar, breadbox, breadpad) read pywal colors from
`~/.cache/wal/colors.json` and fall back to Catppuccin Mocha when that file
is absent. Per-app CSS overrides live at `~/.config/<app>/style.css`.
The shared theming logic lives in the `bread-theme` crate in this repo.
## Workspace
This repo is a Cargo workspace:
@ -107,7 +114,7 @@ This repo is a Cargo workspace:
```
bread-ecosystem/
├── bakery/ # package manager binary
├── bread-theme/ # shared pywal + Catppuccin theming crate
├── bread-theme/ # shared pywal + fixed-dark-base theming crate
├── registry/ # bread-ecosystem.toml — product registry
└── scripts/
├── get.sh # curl | sh bootstrap

View file

@ -5,7 +5,7 @@ edition.workspace = true
license.workspace = true
authors.workspace = true
description = "Package manager for the bread ecosystem"
repository = "https://github.com/Breadway/bread-ecosystem"
repository = "https://git.breadway.dev/Breadway/bread-ecosystem"
[dependencies]
anyhow = { workspace = true }
@ -18,6 +18,8 @@ sha2 = { workspace = true }
hex = { workspace = true }
clap = { workspace = true }
chrono = { workspace = true }
minisign-verify = { workspace = true }
semver = { workspace = true }
[dev-dependencies]
tempfile = "3"

View file

@ -1,3 +1,4 @@
use crate::ui;
use anyhow::Result;
use std::process::Command;
@ -52,28 +53,37 @@ fn pkg_config_exists(lib: &str) -> 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

View file

@ -32,7 +32,12 @@ pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result<()> {
Ok(())
}
fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<()> {
/// Verify that `bytes` hashes to `expected_hex` under SHA-256.
///
/// Shared by every artifact download path — binaries (via
/// [`fetch_and_place`]), and config-example / systemd-unit downloads in
/// `install.rs` — so all downloaded artifacts get the same integrity check.
pub fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<()> {
let mut hasher = Sha256::new();
hasher.update(bytes);
let actual = hex::encode(hasher.finalize());

View file

@ -2,7 +2,7 @@ use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::download::fetch_and_place;
use crate::download::{fetch_and_place, verify_sha256};
use crate::manifest::{fetch_binary, Package, Service};
use crate::state::{InstalledPackage, State};
@ -119,11 +119,28 @@ fn scaffold_config(cfg: &crate::manifest::ConfigScaffold, pkg: &Package) -> Resu
if !dest.exists() {
if let Some((primary, fallback)) = pkg.artifact_urls(example) {
match fetch_binary(&primary, &fallback) {
Ok(bytes) => {
std::fs::write(&dest, &bytes)
.with_context(|| format!("writing {}", dest.display()))?;
println!(" installed example config at {}", dest.display());
}
Ok(bytes) => match &cfg.example_sha256 {
Some(expected) => match verify_sha256(&bytes, expected) {
Ok(()) => {
std::fs::write(&dest, &bytes)
.with_context(|| format!("writing {}", dest.display()))?;
println!(" installed example config at {}", dest.display());
}
Err(e) => {
eprintln!(
" warning: checksum mismatch for example config {example}: {e} — not installed"
);
println!(" config dir created at {}", dir.display());
}
},
None => {
eprintln!(
" warning: index.json has no sha256 for example config \
{example} refusing to install an unverified download"
);
println!(" config dir created at {}", dir.display());
}
},
Err(e) => {
eprintln!(" warning: could not download example config {example}: {e}");
println!(" config dir created at {}", dir.display());
@ -151,11 +168,19 @@ fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> {
if !unit_path.exists() {
if let Some((primary, fallback)) = pkg.artifact_urls(&svc.unit) {
match fetch_binary(&primary, &fallback) {
Ok(bytes) => {
std::fs::write(&unit_path, &bytes)
.with_context(|| format!("writing {}", unit_path.display()))?;
println!(" downloaded unit {}", unit_path.display());
}
Ok(bytes) => match verify_sha256(&bytes, &svc.sha256) {
Ok(()) => {
std::fs::write(&unit_path, &bytes)
.with_context(|| format!("writing {}", unit_path.display()))?;
println!(" downloaded unit {}", unit_path.display());
}
Err(e) => {
eprintln!(
" warning: checksum mismatch for unit {}: {e} — not installed",
svc.unit
);
}
},
Err(e) => {
eprintln!(" warning: could not download {}: {e}", svc.unit);
}

View file

@ -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<String>,
},
/// 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<String> = 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<String> = 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"));
}
}

View file

@ -1,11 +1,66 @@
use crate::track::Track;
use anyhow::{bail, Context, Result};
use minisign_verify::{PublicKey, Signature};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
const PRIMARY_URL: &str = "https://dl.breadway.dev/index.json";
const 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
/// this repo) to sign `index.json` with `minisign` as part of publishing a
/// new index — see `scripts/gen-index.sh`. Every fetch of `index.json`, and
/// every load of the on-disk cache, must verify against this key before the
/// bytes are trusted or parsed. This is the single control point: the
/// per-artifact `sha256` fields and `post_install` hook strings all live
/// inside `index.json` itself, so a valid signature transitively covers them.
const PUBKEY: &str = "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 —
/// missing/malformed signature, wrong key, or a hash mismatch.
fn verify_index_signature(bytes: &[u8], sig_text: &str) -> Result<()> {
verify_against_key(bytes, sig_text, PUBKEY)
}
/// Verify `bytes` against a minisign `sig_text` using an arbitrary base64
/// public key. Split out from [`verify_index_signature`] purely so tests can
/// exercise the verification logic with a throwaway keypair instead of the
/// real production key.
fn verify_against_key(bytes: &[u8], sig_text: &str, pubkey_b64: &str) -> Result<()> {
let public_key =
PublicKey::from_base64(pubkey_b64).context("public key is malformed")?;
let signature =
Signature::decode(sig_text).context("index.json.minisig is malformed or unreadable")?;
public_key
.verify(bytes, &signature, false)
.context("index.json failed signature verification against the pinned bakery key")
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Binary {
pub name: String,
@ -18,6 +73,10 @@ pub struct Binary {
pub struct Service {
pub unit: String,
pub enable: bool,
/// SHA-256 of the unit file artifact. Required to verify the download in
/// `install::install_service`, same as binaries; `index.json` carries it
/// (and is itself minisign-signed, which is what makes it trustworthy).
pub sha256: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
@ -25,6 +84,10 @@ pub struct ConfigScaffold {
pub dir: String,
/// Example config filename, relative to the release artifact directory.
pub example: Option<String>,
/// SHA-256 of the example config artifact, when `example` is set.
/// Verified in `install::scaffold_config` the same way binaries are.
#[serde(default)]
pub example_sha256: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
@ -78,17 +141,46 @@ impl Index {
}
}
/// Load the manifest, using the on-disk cache when it is fresh enough.
/// Always fetches if `force_refresh` is true.
pub fn load(force_refresh: bool) -> Result<Index> {
let cache_path = cache_path();
/// 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
/// trusted. A signature failure on a freshly fetched index is always a hard
/// error. A signature failure on the *cached* copy is treated as a
/// (possibly tampered, possibly just stale-format) cache and triggers one
/// re-fetch from the network rather than bricking the CLI outright; if the
/// freshly fetched copy also fails to verify, that's a hard error.
pub fn load(force_refresh: bool, track: Track) -> Result<Index> {
let cache_path = cache_path(track);
let sig_cache_path = sig_cache_path(&cache_path);
if !force_refresh && cache_is_fresh(&cache_path) {
let text = std::fs::read_to_string(&cache_path).context("reading cached index")?;
return serde_json::from_str(&text).context("parsing cached index");
match read_and_verify_cache(&cache_path, &sig_cache_path, track) {
Ok(index) => return Ok(index),
Err(err) => {
eprintln!(
" warning: cached index.json failed verification ({err}), re-fetching…"
);
}
}
}
fetch_and_cache(&cache_path)
fetch_and_cache(&cache_path, &sig_cache_path, track)
}
fn read_and_verify_cache(
cache_path: &PathBuf,
sig_cache_path: &PathBuf,
track: Track,
) -> Result<Index> {
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).with_context(|| {
format!("cached {track} index failed signature verification")
})?;
serde_json::from_slice(&bytes).context("parsing cached index")
}
fn cache_is_fresh(path: &PathBuf) -> bool {
@ -98,13 +190,31 @@ fn cache_is_fresh(path: &PathBuf) -> bool {
.unwrap_or(false)
}
fn fetch_and_cache(cache_path: &PathBuf) -> Result<Index> {
let text = fetch_text(PRIMARY_URL)?;
fn fetch_and_cache(cache_path: &PathBuf, sig_cache_path: &PathBuf, track: Track) -> Result<Index> {
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)
.with_context(|| format!("freshly fetched {track} index failed signature verification"))?;
if let Some(dir) = cache_path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(cache_path, &text)?;
serde_json::from_str(&text).context("parsing index.json")
std::fs::write(cache_path, &bytes)?;
std::fs::write(sig_cache_path, &sig_text)?;
serde_json::from_slice(&bytes).context("parsing index.json")
}
fn sig_cache_path(cache_path: &Path) -> PathBuf {
let mut name = cache_path.file_name().unwrap_or_default().to_os_string();
name.push(".minisig");
cache_path.with_file_name(name)
}
fn fetch_text(url: &str) -> Result<String> {
@ -115,10 +225,19 @@ fn fetch_text(url: &str) -> Result<String> {
.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`
@ -151,3 +270,84 @@ fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
.context("reading response")?;
Ok(buf)
}
#[cfg(test)]
mod tests {
use super::*;
// A throwaway test-only minisign keypair, generated solely to produce
// these fixtures (`minisign -G` then `minisign -S`). It has no
// relationship to the real bakery signing key (PUBKEY above) and the
// matching secret key was discarded — these are just fixed vectors to
// exercise the verification code path deterministically.
const TEST_PUBKEY: &str = "RWQTYQi9Fe4trQDQmbb9txWDxzUIPYs57J//A5wG9BHcZXgC8YP0Cf59";
const TEST_DATA: &[u8] = b"{\"hello\":\"world\"}\n";
const TEST_SIG: &str = "untrusted comment: signature from minisign secret key\n\
RUQTYQi9Fe4trXY/WBxk++476WhTqtVd3hlNWQj5h5DF8keP8sEJn22LDG2hloNgJesXt6HsTQs9uktayRVp/HB4XfC6e+rhYAs=\n\
trusted comment: timestamp:1784230084\tfile:test-data.json\thashed\n\
znmVfINB4jFDR2a4wuY8rOKlUBeSDOFjMkHYDXV3vxvAjK+r4V12ae9ZRQkfVtQ1YIEmFXbnJfbxywg+NR/1AA==\n";
#[test]
fn valid_signature_verifies() {
verify_against_key(TEST_DATA, TEST_SIG, TEST_PUBKEY)
.expect("known-good signature must verify");
}
#[test]
fn tampered_bytes_fail_verification() {
let tampered = b"{\"hello\":\"world!\"}\n".to_vec();
assert!(verify_against_key(&tampered, TEST_SIG, TEST_PUBKEY).is_err());
}
#[test]
fn wrong_key_fails_verification() {
// PUBKEY is the real production key — unrelated to the throwaway
// TEST_PUBKEY the fixture was signed with, so it must not verify.
assert!(verify_against_key(TEST_DATA, TEST_SIG, PUBKEY).is_err());
}
#[test]
fn malformed_signature_text_errors_cleanly() {
assert!(verify_against_key(TEST_DATA, "not a real signature", TEST_PUBKEY).is_err());
}
#[test]
fn production_pubkey_constant_is_well_formed() {
// Guards against a future typo/truncation in the hardcoded PUBKEY —
// it must at least parse as a valid minisign public key.
PublicKey::from_base64(PUBKEY).expect("PUBKEY must be a valid minisign public key");
}
#[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()));
}
}

View file

@ -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<String, InstalledPackage>,
}
@ -52,6 +58,10 @@ impl State {
pub fn remove(&mut self, name: &str) -> Option<InstalledPackage> {
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();

90
bakery/src/track.rs Normal file
View file

@ -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<Self, Self::Err> {
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::<Track>().unwrap(), track);
}
}
#[test]
fn from_str_is_case_insensitive() {
assert_eq!("DEV".parse::<Track>().unwrap(), Track::Dev);
assert_eq!("Beta".parse::<Track>().unwrap(), Track::Beta);
}
#[test]
fn from_str_rejects_unknown() {
assert!("nightly".parse::<Track>().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);
}
}

60
bakery/src/ui.rs Normal file
View file

@ -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());
}
}

34
bread-onnx/Cargo.toml Normal file
View file

@ -0,0 +1,34 @@
[package]
name = "bread-onnx"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
description = "Shared ONNX Runtime plumbing for the bread ecosystem: session building, execution-provider fallback with loud diagnostics, embedding-pipeline tensor math, and verified model downloads"
repository = "https://git.breadway.dev/Breadway/bread-ecosystem"
keywords = ["onnx", "onnxruntime", "ml", "embeddings"]
[dependencies]
bread-utils = { path = "../bread-utils" }
# Left at default-features = false, with no api-XX/download-binaries/
# load-dynamic/tls-native features of our own: those choices (how each app
# obtains/links its onnxruntime .so, and which ONNX Runtime C API version to
# bind) are consumer-build-environment decisions that stay in each app's own
# Cargo.toml (breadarr, breadmill, and breadpad already each pin different
# ones). Cargo's feature unification means this crate's minimal declaration
# just rides along with whatever the consuming app already selected.
ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "tracing"] }
# Default features left on (unlike `ort` above) — breadarr and breadmill
# both already build against plain default-featured tokenizers; only
# breadpad customizes this (http, fancy-regex), and Cargo's feature
# unification only ever adds features on top of this minimal baseline, so
# breadpad's own selection still applies in its own build.
tokenizers = "0.23"
tracing = { workspace = true }
ureq = { workspace = true }
sha2 = { workspace = true }
hex = { workspace = true }
anyhow = { workspace = true }
[dev-dependencies]
tempfile = "3"

112
bread-onnx/src/download.rs Normal file
View file

@ -0,0 +1,112 @@
//! Model download + integrity checking.
//!
//! `breadarrd/src/matcher/mod.rs::download` (async, `reqwest`) and
//! `breadmill/src/main.rs::download_if_missing` (sync, `ureq`) independently
//! implement "download to a temp file, then rename over the destination"
//! for fetching an ONNX model/tokenizer if it isn't already present —
//! genuinely duplicated intent, different HTTP clients. Neither verifies
//! the download's integrity beyond "the response wasn't empty". This module
//! is a fresh, shared implementation (sync, `ureq` — matching this
//! workspace's existing `bakery` convention for downloads) that adds an
//! optional SHA-256 check, built on [`bread_utils::atomic::write_atomic_bytes`]
//! for the same crash-safety property both originals already had.
//!
//! `breadarrd`'s async caller should wrap a call to [`ensure_file`] in
//! `tokio::task::spawn_blocking` rather than block its async runtime
//! directly — see that crate's migration for the concrete pattern.
use std::io::Read;
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
/// Download `url` to `dest` if `dest` doesn't already exist. If
/// `expected_sha256` is given, verifies the downloaded bytes against it
/// (case-insensitive hex) before the atomic rename and returns an error on
/// mismatch — the temp file is discarded, `dest` is left untouched. An
/// already-present `dest` is trusted as-is and not re-verified (matches
/// both original implementations' "if it exists, skip" behavior; re-hashing
/// a ~90MB+ model file on every startup would be wasted work for the common
/// case of a stable, previously-verified file).
pub fn ensure_file(url: &str, dest: &Path, expected_sha256: Option<&str>) -> anyhow::Result<PathBuf> {
if dest.exists() {
return Ok(dest.to_path_buf());
}
tracing::info!("bread-onnx: downloading {url} -> {}", dest.display());
let agent = ureq::AgentBuilder::new()
.timeout(std::time::Duration::from_secs(300))
.build();
let response = agent
.get(url)
.call()
.map_err(|e| anyhow::anyhow!("failed to download {url}: {e}"))?;
let mut bytes = Vec::new();
response
.into_reader()
.read_to_end(&mut bytes)
.map_err(|e| anyhow::anyhow!("failed to read response body from {url}: {e}"))?;
if bytes.is_empty() {
anyhow::bail!("empty download from {url}");
}
if let Some(expected) = expected_sha256 {
let actual = sha256_hex(&bytes);
if !actual.eq_ignore_ascii_case(expected) {
anyhow::bail!(
"checksum mismatch for {url}: expected {expected}, got {actual} — refusing to install"
);
}
tracing::info!("bread-onnx: verified sha256 for {}", dest.display());
}
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
bread_utils::atomic::write_atomic_bytes(dest, &bytes, None)
.map_err(|e| anyhow::anyhow!("failed to write {}: {e}", dest.display()))?;
tracing::info!(
"bread-onnx: saved {} ({:.1} MB)",
dest.display(),
bytes.len() as f64 / 1_048_576.0
);
Ok(dest.to_path_buf())
}
pub fn sha256_hex(data: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
hex::encode(hasher.finalize())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sha256_hex_matches_known_vector() {
// sha256("") — well-known empty-input digest.
assert_eq!(
sha256_hex(b""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn ensure_file_skips_download_when_already_present() {
let dir = std::env::temp_dir().join(format!("bread-onnx-download-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let dest = dir.join("model.onnx");
std::fs::write(&dest, b"already here").unwrap();
// A bogus URL would fail if actually requested — success here proves
// the existing-file short-circuit fired instead of dialing out.
let result = ensure_file("http://127.0.0.1:1/unreachable", &dest, None);
assert!(result.is_ok());
assert_eq!(std::fs::read(&dest).unwrap(), b"already here");
let _ = std::fs::remove_dir_all(&dir);
}
}

220
bread-onnx/src/embedding.rs Normal file
View file

@ -0,0 +1,220 @@
//! Shared BERT-family embedding pipeline: tokenize → build `input_ids`/
//! `attention_mask`/`token_type_ids` tensors → run → mean-pool the
//! non-padded positions of `last_hidden_state` → L2-normalize → clamp/pad to
//! a configured output dimension.
//!
//! This is extracted from two independently-written but essentially
//! byte-identical implementations:
//! - `breadarrd/src/matcher/embed.rs::OrtEmbedder::embed` (lines 45-111) and
//! its `l2_normalize` (lines 114-121)
//! - `breadmill/src/embed.rs::OrtEmbedder::embed_with_prefix` (lines 65-153)
//! and its `l2_normalize` (lines 156-163)
//!
//! Both truncate to a max sequence length, build the same three `i64`
//! tensors, run the same `input_ids`/`attention_mask`/`token_type_ids` →
//! `last_hidden_state` shape contract, mean-pool over `actual_seq.min(mask.len())`
//! positions (both already independently arrived at the same `.min()` guard
//! for execution providers that pad the output sequence dimension), and
//! L2-normalize with the same `1e-10` epsilon. `breadmill`'s only real
//! difference is prepending a document/query prefix string before
//! tokenizing, which stays the caller's responsibility here — pass the
//! already-prefixed text to [`EmbeddingSession::embed`].
use std::path::Path;
use ort::session::builder::GraphOptimizationLevel;
use ort::session::Session;
use ort::value::Tensor;
use tokenizers::Tokenizer;
use crate::provider::Provider;
use crate::session::build_session;
pub struct EmbeddingSession {
session: Session,
tokenizer: Tokenizer,
dim: usize,
max_seq_len: usize,
}
impl EmbeddingSession {
/// Load a BERT-family embedding model + tokenizer, selecting execution
/// providers via [`build_session`]. `dim` is the output embedding
/// dimension (results are truncated/zero-padded to it — matches how
/// both original implementations handled a model whose `dim` config
/// might not exactly match `last_hidden_state`'s actual width). `max_seq_len`
/// caps tokenized input length before inference (truncating, not
/// erroring) to bound attention memory on pathological inputs.
pub fn load(
model_path: &Path,
tokenizer_path: &Path,
dim: usize,
max_seq_len: usize,
providers: &[Provider],
) -> anyhow::Result<Self> {
let session = build_session(model_path, GraphOptimizationLevel::Level3, providers)?;
let tokenizer = Tokenizer::from_file(tokenizer_path)
.map_err(|e| anyhow::anyhow!("failed to load tokenizer: {e}"))?;
Ok(Self { session, tokenizer, dim, max_seq_len })
}
/// Embed `text` (already prefixed by the caller, if the model expects a
/// document/query prefix). Returns an L2-normalized vector of length
/// `dim`.
pub fn embed(&mut self, text: &str) -> anyhow::Result<Vec<f32>> {
let encoding = self
.tokenizer
.encode(text, true)
.map_err(|e| anyhow::anyhow!("tokenization failed: {e}"))?;
let mut ids: Vec<i64> = encoding.get_ids().iter().map(|&x| x as i64).collect();
let mut mask: Vec<i64> = encoding.get_attention_mask().iter().map(|&x| x as i64).collect();
let mut type_ids: Vec<i64> = encoding.get_type_ids().iter().map(|&x| x as i64).collect();
ids.truncate(self.max_seq_len);
mask.truncate(self.max_seq_len);
type_ids.truncate(self.max_seq_len);
let seq_len = ids.len() as i64;
let id_tensor = Tensor::<i64>::from_array((vec![1i64, seq_len], ids))
.map_err(|e| anyhow::anyhow!("failed to build input_ids tensor: {e}"))?;
let mask_tensor = Tensor::<i64>::from_array((vec![1i64, seq_len], mask.clone()))
.map_err(|e| anyhow::anyhow!("failed to build attention_mask tensor: {e}"))?;
let type_tensor = Tensor::<i64>::from_array((vec![1i64, seq_len], type_ids))
.map_err(|e| anyhow::anyhow!("failed to build token_type_ids tensor: {e}"))?;
let outputs = self
.session
.run(ort::inputs! {
"input_ids" => id_tensor,
"attention_mask" => mask_tensor,
"token_type_ids" => type_tensor,
})
.map_err(|e| anyhow::anyhow!("ort inference failed: {e}"))?;
let (shape, data) = outputs["last_hidden_state"]
.try_extract_tensor::<f32>()
.map_err(|e| anyhow::anyhow!("failed to extract last_hidden_state: {e}"))?;
let actual_seq = shape[1] as usize;
let actual_dim = shape[2] as usize;
Ok(mean_pool_normalize(data, &mask, actual_seq, actual_dim, self.dim))
}
}
/// Mean-pool `data` (flattened `[1, actual_seq, actual_dim]`) over the
/// positions `mask` marks as non-padding, L2-normalize the result, then
/// clamp/zero-pad to `target_dim`. `actual_seq.min(mask.len())` guards
/// against execution providers (MIGraphX observed doing this) that pad the
/// output sequence dimension for kernel efficiency, making `actual_seq`
/// exceed the caller's own `mask` length.
fn mean_pool_normalize(data: &[f32], mask: &[i64], actual_seq: usize, actual_dim: usize, target_dim: usize) -> Vec<f32> {
let mut result = vec![0.0f32; actual_dim];
let mut count = 0usize;
for t in 0..actual_seq.min(mask.len()) {
if mask[t] > 0 {
for d in 0..actual_dim {
result[d] += data[t * actual_dim + d];
}
count += 1;
}
}
if count > 0 {
for x in &mut result {
*x /= count as f32;
}
}
l2_normalize(&mut result);
result.truncate(target_dim);
while result.len() < target_dim {
result.push(0.0);
}
result
}
fn l2_normalize(v: &mut [f32]) {
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm > 1e-10 {
for x in v.iter_mut() {
*x /= norm;
}
}
}
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
a.iter().zip(b).map(|(x, y)| x * y).sum()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn l2_normalize_produces_unit_vector() {
let mut v = vec![3.0, 4.0];
l2_normalize(&mut v);
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
assert!((norm - 1.0).abs() < 1e-6);
}
#[test]
fn l2_normalize_leaves_zero_vector_untouched() {
let mut v = vec![0.0, 0.0, 0.0];
l2_normalize(&mut v);
assert_eq!(v, vec![0.0, 0.0, 0.0]);
}
#[test]
fn cosine_similarity_of_identical_unit_vectors_is_one() {
let mut v = vec![1.0, 2.0, 3.0];
l2_normalize(&mut v);
let sim = cosine_similarity(&v, &v);
assert!((sim - 1.0).abs() < 1e-6);
}
#[test]
fn cosine_similarity_of_orthogonal_vectors_is_zero() {
let a = vec![1.0, 0.0];
let b = vec![0.0, 1.0];
assert!(cosine_similarity(&a, &b).abs() < 1e-6);
}
#[test]
fn mean_pool_ignores_padded_positions() {
// actual_dim = 2, 3 positions: two real tokens + one padded (mask=0)
let data = vec![
1.0, 1.0, // t0: real
9.0, 9.0, // t1: padded, should be ignored
3.0, 3.0, // t2: real
];
let mask = vec![1, 0, 1];
let pooled = mean_pool_normalize(&data, &mask, 3, 2, 2);
// Mean of (1,1) and (3,3) is (2,2), normalized to unit length.
let expected_norm = (2.0f32 * 2.0 + 2.0 * 2.0).sqrt();
assert!((pooled[0] - 2.0 / expected_norm).abs() < 1e-5);
assert!((pooled[1] - 2.0 / expected_norm).abs() < 1e-5);
}
#[test]
fn mean_pool_clamps_actual_seq_to_mask_len_for_padded_ep_output() {
// Regression guard for the MIGraphX-padded-output-sequence case both
// original implementations independently guarded against: actual_seq
// (4) exceeds mask.len() (2) — must not index out of the mask.
let data = vec![1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0];
let mask = vec![1, 1];
let pooled = mean_pool_normalize(&data, &mask, 4, 2, 2);
assert!(pooled.iter().all(|x| x.is_finite()));
}
#[test]
fn mean_pool_pads_short_result_to_target_dim() {
let data = vec![1.0, 1.0];
let mask = vec![1];
let pooled = mean_pool_normalize(&data, &mask, 1, 1, 4);
assert_eq!(pooled.len(), 4);
assert_eq!(pooled[2], 0.0);
assert_eq!(pooled[3], 0.0);
}
}

32
bread-onnx/src/lib.rs Normal file
View file

@ -0,0 +1,32 @@
//! Shared ONNX Runtime plumbing for the bread ecosystem.
//!
//! Extracted from breadarr, breadsearch, and breadpad during the
//! 2026-07-16 ecosystem-wide utility audit — see each module's doc comment
//! for the original file:line duplication it replaces.
//!
//! **Important**: [`session::build_session`] logs execution-provider
//! selection via the `tracing` crate, but does *not* initialize a
//! subscriber itself. Without one, ONNX Runtime's own "successfully
//! registered `XExecutionProvider`" log line (and this crate's own
//! selection logging) go nowhere — which is exactly how a GPU execution
//! provider can silently no-op back to CPU with zero visible error (see
//! [`provider`]'s doc comment for the concrete history behind this). All
//! three current consumers already call `tracing_subscriber::fmt().init()`
//! (or an `EnvFilter`-configured equivalent) at startup; any new consumer
//! must do the same before calling [`session::build_session`].
//!
//! - [`provider`] — the [`provider::Provider`] enum and the
//! MIGraphX-not-ROCm default rationale.
//! - [`session`] — session construction with EP fallback + loud logging.
//! - [`embedding`] — the shared tokenize → tensor → mean-pool → normalize
//! pipeline for BERT-family embedding models.
//! - [`download`] — model download with atomic write + optional SHA-256
//! integrity check.
pub mod download;
pub mod embedding;
pub mod provider;
pub mod session;
pub use provider::Provider;
pub use session::build_session;

152
bread-onnx/src/provider.rs Normal file
View file

@ -0,0 +1,152 @@
//! Execution-provider selection.
//!
//! This crate defaults AMD iGPU acceleration to
//! [`ort::ep::MIGraphX`](ort::ep::MIGraphX), *not*
//! [`ort::ep::ROCm`](ort::ep::ROCm), on purpose. `breadpad-shared/src/
//! classifier.rs::try_load_session` used the classic `ROCMExecutionProvider`
//! and — per the hard-won lesson recorded in this machine's own operator
//! notes (`breadsearch-gpu-backends`, from `breadsearch`'s own history) —
//! that EP silently no-ops on this class of system and falls back to CPU
//! with zero visible error: distro ROCm ONNX Runtime builds (e.g. Arch's
//! `onnxruntime-rocm`) are commonly compiled with `--use_migraphx`, not
//! `--use_rocm`, so `ROCMExecutionProvider` never actually registers, and
//! nothing surfaces that fact unless a `tracing` subscriber is initialized
//! to catch ONNX Runtime's own EP-registration log line. `breadmill/src/
//! embed.rs::rocm_session` already got this right; this module promotes
//! that provider choice (and the loud logging around it) to the shared
//! crate so it can't silently regress in any consumer again.
use std::path::PathBuf;
/// A requested execution provider, in the shared vocabulary consumers use.
/// Convert to an `ort` dispatch entry with [`Provider::to_dispatch`].
#[derive(Debug, Clone)]
pub enum Provider {
Cpu,
/// AMD iGPU/dGPU via MIGraphX (ROCm-backed onnxruntime builds). See this
/// module's doc comment for why this — not `ROCm` — is the correct
/// choice on this class of system.
MiGraphX { device_id: i32 },
/// NVIDIA GPU via CUDA.
Cuda { device_id: i32 },
/// Intel iGPU/dGPU (Arc) via OpenVINO. `cache_dir` stores OpenVINO's
/// compiled-model blobs between runs.
OpenVino { device_type: String, cache_dir: PathBuf },
/// AMD XDNA NPU via the VitisAI execution provider (Ryzen AI SDK).
/// `cache_dir` stores the compiled NPU model between runs.
Vitis {
config_file: PathBuf,
cache_dir: PathBuf,
cache_key: String,
},
}
impl Provider {
pub fn name(&self) -> &'static str {
match self {
Provider::Cpu => "CPU",
Provider::MiGraphX { .. } => "MIGraphX (AMD iGPU/dGPU)",
Provider::Cuda { .. } => "CUDA (NVIDIA GPU)",
Provider::OpenVino { .. } => "OpenVINO (Intel iGPU/dGPU)",
Provider::Vitis { .. } => "VitisAI (AMD XDNA NPU)",
}
}
/// The literal execution-provider name ONNX Runtime's own log line
/// reports on successful registration (e.g. `"Successfully registered
/// \`MIGraphXExecutionProvider\`"`) — used to build the loud log hint in
/// [`crate::session::build_session`].
fn ort_registration_name(&self) -> &'static str {
match self {
Provider::Cpu => "CPUExecutionProvider",
Provider::MiGraphX { .. } => "MIGraphXExecutionProvider",
Provider::Cuda { .. } => "CUDAExecutionProvider",
Provider::OpenVino { .. } => "OpenVINOExecutionProvider",
Provider::Vitis { .. } => "VitisAIExecutionProvider",
}
}
pub(crate) fn to_dispatch(&self) -> anyhow::Result<ort::ep::ExecutionProviderDispatch> {
Ok(match self {
Provider::Cpu => ort::ep::CPU::default().build(),
Provider::MiGraphX { device_id } => {
ensure_migraphx_cache_path_default()?;
ort::ep::MIGraphX::default().with_device_id(*device_id).build()
}
Provider::Cuda { device_id } => {
ort::ep::CUDA::default().with_device_id(*device_id).build()
}
Provider::OpenVino { device_type, cache_dir } => {
std::fs::create_dir_all(cache_dir)?;
ort::ep::OpenVINO::default()
.with_device_type(device_type.clone())
.with_cache_dir(cache_dir.to_string_lossy())
.build()
}
Provider::Vitis { config_file, cache_dir, cache_key } => {
std::fs::create_dir_all(cache_dir)?;
ort::ep::Vitis::default()
.with_config_file(config_file.to_string_lossy())
.with_cache_dir(cache_dir.to_string_lossy())
.with_cache_key(cache_key.clone())
.build()
}
})
}
/// Log a loud, consistent "using X" line plus (for non-CPU providers) a
/// reminder of exactly what to grep ONNX Runtime's own log output for —
/// this is the "at minimum log EP registration success/failure loudly
/// by default" half of the fix, independent of whether the caller has
/// wired up `tracing_subscriber` (all three current consumers already
/// do, at their own startup).
pub(crate) fn log_selection(&self) {
tracing::info!("bread-onnx: requesting {} execution provider", self.name());
if !matches!(self, Provider::Cpu) {
tracing::info!(
"bread-onnx: check ONNX Runtime's own log output for \"Successfully registered \
`{}`\" — if it's missing, the ONNX Runtime build in use wasn't compiled/shipped \
with this provider and inference silently fell back to CPU. This line only \
appears if a `tracing` subscriber is initialized.",
self.ort_registration_name()
);
}
}
}
/// MIGraphX has no Rust-level "cache directory" builder (unlike OpenVINO/
/// Vitis above) — it's controlled purely by the `ORT_MIGRAPHX_MODEL_CACHE_PATH`
/// environment variable, read by the underlying MIGraphX library at EP-
/// registration time. Left unset, MIGraphX still *works*, but recompiles
/// every kernel from scratch on every single session build — no persistence
/// between runs, or even between two sessions in the same process. Found via
/// this pass's own migration: `breadmill`'s packaged systemd unit already
/// sets this explicitly (`packaging/breadmill.service`), but nothing
/// enforced any other consumer doing the same, and `breadpad` (which had no
/// systemd unit or cache path at all) hit exactly this — every
/// `Classifier::load` call during its own test suite recompiled from a cold
/// cache, visible as repeated `migraphx_save: Error: ... write_buffer:
/// Failure opening file: ""/<hash>.mxr` log lines (an empty path prefix,
/// i.e. the env var was never set) and multi-minute test runs.
///
/// This sets a sensible shared default (`~/.cache/bread-onnx/migraphx`) if
/// the caller hasn't already set one — so every consumer gets kernel-cache
/// persistence for free instead of only the ones that remembered to
/// configure it themselves.
fn ensure_migraphx_cache_path_default() -> anyhow::Result<()> {
if std::env::var_os("ORT_MIGRAPHX_MODEL_CACHE_PATH").is_some() {
return Ok(());
}
let dir = bread_utils::xdg::cache_dir("bread-onnx").join("migraphx");
std::fs::create_dir_all(&dir)?;
tracing::info!(
"bread-onnx: ORT_MIGRAPHX_MODEL_CACHE_PATH not set; defaulting to {} \
so MIGraphX kernel compiles persist across runs",
dir.display()
);
// SAFETY: this runs before any session build spawns worker threads that
// might read the environment concurrently — same caveat as any
// `set_var` call, documented here rather than papered over.
unsafe { std::env::set_var("ORT_MIGRAPHX_MODEL_CACHE_PATH", &dir) };
Ok(())
}

49
bread-onnx/src/session.rs Normal file
View file

@ -0,0 +1,49 @@
//! Session construction with execution-provider fallback.
//!
//! Builds one `ort::session::Session` whose execution-provider dispatch
//! list is exactly `providers` (in order) with an implicit `CPU` appended
//! if the caller didn't already include one — ONNX Runtime tries each
//! listed EP per-node and falls through the list on failure, so this
//! mirrors (and replaces) the identical `.with_execution_providers([primary,
//! CPU])` pattern already proven out in `breadmill/src/embed.rs::rocm_session`
//! /`cuda_session`/`openvino_session`/`npu_session`.
use std::path::Path;
use ort::session::builder::GraphOptimizationLevel;
use ort::session::Session;
use crate::provider::Provider;
/// Build a session, trying each of `providers` in order (ONNX Runtime falls
/// through per-node on registration failure) with a trailing `CPU` fallback
/// implicitly appended if not already present. Always logs which provider
/// was requested — see [`Provider::log_selection`] — regardless of whether
/// `tracing_subscriber` is initialized, so at minimum the *attempt* is
/// visible even without wired-up logging; the actual per-EP success/failure
/// detail only surfaces once a subscriber is listening.
pub fn build_session(
model_path: &Path,
opt_level: GraphOptimizationLevel,
providers: &[Provider],
) -> anyhow::Result<Session> {
let mut dispatch = Vec::with_capacity(providers.len() + 1);
for p in providers {
p.log_selection();
dispatch.push(p.to_dispatch()?);
}
if !providers.iter().any(|p| matches!(p, Provider::Cpu)) {
dispatch.push(Provider::Cpu.to_dispatch()?);
}
let mut builder = Session::builder()
.map_err(|e| anyhow::anyhow!("failed to create ort session builder: {e}"))?
.with_optimization_level(opt_level)
.map_err(|e| anyhow::anyhow!("failed to set optimization level: {e}"))?
.with_execution_providers(dispatch)
.map_err(|e| anyhow::anyhow!("failed to configure execution providers: {e}"))?;
builder
.commit_from_file(model_path)
.map_err(|e| anyhow::anyhow!("failed to load model from {}: {e}", model_path.display()))
}

View file

@ -4,8 +4,8 @@ version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
description = "Shared pywal + Catppuccin theming crate for the bread ecosystem"
repository = "https://github.com/Breadway/bread-ecosystem"
description = "Shared pywal-accented, fixed-dark-base theming crate for the bread ecosystem"
repository = "https://git.breadway.dev/Breadway/bread-ecosystem"
keywords = ["theming", "pywal", "gtk4", "wayland"]
[dependencies]

11
bread-theme/bakery.toml Normal file
View file

@ -0,0 +1,11 @@
name = "bread-theme"
description = "Shared pywal-accented, fixed-dark-base theming CLI for the bread ecosystem — generates the shared GTK4 stylesheet every bread app loads"
binaries = ["bread-theme"]
system_deps = []
optional_system_deps = ["python-pywal"]
bread_deps = []
[install]
post_install = [
"bread-theme generate || true",
]

View file

@ -24,31 +24,23 @@ pub mod tokens {
pub const RADIUS_PILL: u16 = 999;
}
/// Emit the `@define-color` block that all bread apps use.
/// Apps append their own rules below this; user CSS goes on top.
/// Emit the `@define-color` block that all bread apps use, plus the shared
/// font rule.
///
/// Kept for API compatibility with older callers that only want the color
/// variables (not the full [`stylesheet`] component rules). It used to carry
/// its own hand-written `@define-color` block that predated the `accent` and
/// computed-ink (`on-*`) colors — that duplication is exactly what let it
/// drift out of sync and reintroduce the illegible-text bug (light pywal
/// colors + no computed ink meant white-on-white / black-on-black text
/// wherever a caller's own CSS referenced `@on-surface`, `@on-accent`, etc.,
/// since those names simply didn't exist in this block). It now delegates
/// to the same [`define_colors`] the full stylesheet uses, so there is only
/// one color-block implementation and it cannot drift again.
pub fn css_vars(p: &Palette) -> String {
format!(
"@define-color bg {bg};\n\
@define-color fg {fg};\n\
@define-color surface {c0};\n\
@define-color red {c1};\n\
@define-color green {c2};\n\
@define-color yellow {c3};\n\
@define-color blue {c4};\n\
@define-color pink {c5};\n\
@define-color teal {c6};\n\
@define-color overlay {c7};\n\
* {{ font-family: '{font}'; font-size: {size}px; }}\n",
bg = p.background,
fg = p.foreground,
c0 = p.color0,
c1 = p.color1,
c2 = p.color2,
c3 = p.color3,
c4 = p.color4,
c5 = p.color5,
c6 = p.color6,
c7 = p.color7,
"{vars}* {{ font-family: '{font}'; font-size: {size}px; }}\n",
vars = define_colors(p),
font = tokens::FONT_FAMILY,
size = tokens::FONT_SIZE_BASE,
)
@ -241,6 +233,33 @@ mod tests {
assert!(css.contains("14px"));
}
#[test]
fn css_vars_includes_accent_and_computed_ink_colors() {
// Regression test: css_vars() used to be a second, hand-written
// @define-color block that predated `accent` and the computed `on-*`
// ink colors. Any caller whose own CSS referenced `@on-surface` /
// `@on-accent` etc. against that older block would hit an undefined
// color name — the illegible-text bug. css_vars() must now emit
// exactly the same color set as the full stylesheet.
let css = css_vars(&Palette::default());
for name in &["accent", "on-bg", "on-surface", "on-accent", "on-red", "on-overlay"] {
assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
}
}
#[test]
fn css_vars_and_stylesheet_agree_on_color_block() {
// Both must derive their color variables from the same
// `define_colors` implementation, so they can't drift apart again.
let p = Palette::default();
let vars = css_vars(&p);
let sheet = stylesheet(&p);
for name in &["bg", "fg", "surface", "overlay", "accent", "on-bg", "on-surface", "on-accent"] {
let needle = format!("@define-color {name} ");
assert!(vars.contains(&needle) && sheet.contains(&needle));
}
}
#[test]
fn stylesheet_defines_canonical_colors_and_components() {
let css = stylesheet(&Palette::default());

View file

@ -2,42 +2,66 @@ use serde::Deserialize;
use std::collections::HashMap;
use std::path::PathBuf;
/// Full 8-colour pywal palette. Catppuccin Mocha is the fallback.
/// BOS's fixed dark theme — background, surface, overlay, and foreground never
/// come from pywal. Only the accent slots (color1-6) track the wallpaper.
/// Without this, a light or muddy-toned wallpaper (a beige bread photo, a
/// snapshot, a bright desktop screenshot) makes pywal hand back a light or
/// off-hue background, and every bread GUI's panels inherit it — the app
/// stops looking like a dark BOS tool and starts looking like whatever colour
/// the wallpaper happened to be.
const FIXED_BACKGROUND: &str = "#0c0c0c";
const FIXED_FOREGROUND: &str = "#e8e8e8";
const FIXED_SURFACE: &str = "#1a1a1a";
const FIXED_OVERLAY: &str = "#d8d8d8";
/// Accent fallback when no pywal palette exists yet (fresh install, before
/// any wallpaper has been set for real) — BOS's own bread-toned accents,
/// matching the curated default `colors.json` baked into every install.
const DEFAULT_COLOR1: &str = "#b98749";
const DEFAULT_COLOR2: &str = "#cd9450";
const DEFAULT_COLOR3: &str = "#e3a85c";
const DEFAULT_COLOR4: &str = "#eab672";
const DEFAULT_COLOR5: &str = "#f6c477";
const DEFAULT_COLOR6: &str = "#eabe82";
/// Full 8-colour pywal palette. background/foreground/color0/color7 are
/// BOS's fixed dark theme (see [`FIXED_BACKGROUND`] etc.); only color1-6
/// are ever pywal-derived.
#[derive(Debug, Clone)]
pub struct Palette {
pub background: String,
pub foreground: String,
/// ANSI color0 — darkest surface / overlay
/// Fixed — darkest surface / overlay, never pywal-derived.
pub color0: String,
/// ANSI color1 — red
/// ANSI color1 — red (pywal accent)
pub color1: String,
/// ANSI color2 — green
/// ANSI color2 — green (pywal accent)
pub color2: String,
/// ANSI color3 — yellow
/// ANSI color3 — yellow (pywal accent)
pub color3: String,
/// ANSI color4 — blue (primary accent)
/// ANSI color4 — blue / primary accent (pywal accent)
pub color4: String,
/// ANSI color5 — pink / magenta
/// ANSI color5 — pink / magenta (pywal accent)
pub color5: String,
/// ANSI color6 — teal / cyan
/// ANSI color6 — teal / cyan (pywal accent)
pub color6: String,
/// ANSI color7 — light overlay / muted fg
/// Fixed — light overlay / muted fg, never pywal-derived.
pub color7: String,
}
impl Default for Palette {
fn default() -> Self {
Palette {
background: "#1e1e2e".into(),
foreground: "#cdd6f4".into(),
color0: "#45475a".into(),
color1: "#f38ba8".into(),
color2: "#a6e3a1".into(),
color3: "#f9e2af".into(),
color4: "#89b4fa".into(),
color5: "#f5c2e7".into(),
color6: "#94e2d5".into(),
color7: "#bac2de".into(),
background: FIXED_BACKGROUND.into(),
foreground: FIXED_FOREGROUND.into(),
color0: FIXED_SURFACE.into(),
color1: DEFAULT_COLOR1.into(),
color2: DEFAULT_COLOR2.into(),
color3: DEFAULT_COLOR3.into(),
color4: DEFAULT_COLOR4.into(),
color5: DEFAULT_COLOR5.into(),
color6: DEFAULT_COLOR6.into(),
color7: FIXED_OVERLAY.into(),
}
}
}
@ -46,16 +70,9 @@ impl Default for Palette {
struct WalColors {
#[serde(default)]
colors: HashMap<String, String>,
special: Option<WalSpecial>,
}
#[derive(Deserialize)]
struct WalSpecial {
background: Option<String>,
foreground: Option<String>,
}
/// Load palette from pywal's `colors.json`. Falls back to Catppuccin Mocha.
/// Load palette from pywal's `colors.json`. Falls back to [`Palette::default`].
pub fn load_palette() -> Palette {
let path = wal_path();
std::fs::read_to_string(&path)
@ -70,18 +87,16 @@ pub(crate) fn from_wal_json(json: &str) -> Option<Palette> {
wal.colors.get(k).cloned().unwrap_or_else(|| fallback.into())
};
Some(Palette {
background: wal.special.as_ref().and_then(|s| s.background.clone())
.unwrap_or_else(|| "#1e1e2e".into()),
foreground: wal.special.as_ref().and_then(|s| s.foreground.clone())
.unwrap_or_else(|| "#cdd6f4".into()),
color0: c("color0", "#45475a"),
color1: c("color1", "#f38ba8"),
color2: c("color2", "#a6e3a1"),
color3: c("color3", "#f9e2af"),
color4: c("color4", "#89b4fa"),
color5: c("color5", "#f5c2e7"),
color6: c("color6", "#94e2d5"),
color7: c("color7", "#bac2de"),
background: FIXED_BACKGROUND.into(),
foreground: FIXED_FOREGROUND.into(),
color0: FIXED_SURFACE.into(),
color1: c("color1", DEFAULT_COLOR1),
color2: c("color2", DEFAULT_COLOR2),
color3: c("color3", DEFAULT_COLOR3),
color4: c("color4", DEFAULT_COLOR4),
color5: c("color5", DEFAULT_COLOR5),
color6: c("color6", DEFAULT_COLOR6),
color7: FIXED_OVERLAY.into(),
})
}
@ -105,39 +120,39 @@ mod tests {
}"##;
#[test]
fn default_is_catppuccin_mocha() {
fn default_is_bos_fixed_dark_theme() {
let p = Palette::default();
assert_eq!(p.background, "#1e1e2e");
assert_eq!(p.foreground, "#cdd6f4");
assert_eq!(p.color4, "#89b4fa");
assert_eq!(p.background, "#0c0c0c");
assert_eq!(p.foreground, "#e8e8e8");
assert_eq!(p.color0, "#1a1a1a");
assert_eq!(p.color7, "#d8d8d8");
assert_eq!(p.color4, "#eab672");
}
#[test]
fn wal_json_parses_special() {
fn wal_json_background_and_surface_ignore_pywal() {
// TOKYO_NIGHT's special/color0/color7 must NOT leak through — bg,
// surface, overlay, and fg are always BOS's fixed dark values,
// whatever pywal extracted from the wallpaper.
let p = from_wal_json(TOKYO_NIGHT).unwrap();
assert_eq!(p.background, "#1a1b26");
assert_eq!(p.foreground, "#c0caf5");
assert_eq!(p.background, "#0c0c0c");
assert_eq!(p.foreground, "#e8e8e8");
assert_eq!(p.color0, "#1a1a1a");
assert_eq!(p.color7, "#d8d8d8");
}
#[test]
fn wal_json_parses_colors() {
fn wal_json_parses_accent_colors() {
let p = from_wal_json(TOKYO_NIGHT).unwrap();
assert_eq!(p.color0, "#15161e");
assert_eq!(p.color1, "#f7768e");
assert_eq!(p.color4, "#7aa2f7");
assert_eq!(p.color7, "#a9b1d6");
assert_eq!(p.color6, "#7dcfff");
}
#[test]
fn wal_json_missing_special_uses_catppuccin_fallback() {
fn wal_json_missing_accent_color_uses_bos_default() {
let p = from_wal_json(r#"{"colors":{}}"#).unwrap();
assert_eq!(p.background, "#1e1e2e");
assert_eq!(p.foreground, "#cdd6f4");
}
#[test]
fn wal_json_missing_color_uses_catppuccin_fallback() {
let p = from_wal_json(r##"{"special":{"background":"#ff0000","foreground":"#ffffff"},"colors":{}}"##).unwrap();
assert_eq!(p.color4, "#89b4fa");
assert_eq!(p.color4, "#eab672");
}
#[test]
@ -147,9 +162,10 @@ mod tests {
}
#[test]
fn empty_object_returns_all_defaults() {
fn empty_object_returns_bos_defaults() {
let p = from_wal_json("{}").unwrap();
assert_eq!(p.background, "#1e1e2e");
assert_eq!(p.background, "#0c0c0c");
assert_eq!(p.color4, "#eab672");
}
#[test]

36
bread-utils/Cargo.toml Normal file
View file

@ -0,0 +1,36 @@
[package]
name = "bread-utils"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
description = "Shared plumbing for the bread ecosystem: Hyprland IPC, single-instance toggling, timeout-guarded subprocess execution, atomic file writes, XDG paths, and a GTK4 layer-shell popup scaffold"
repository = "https://git.breadway.dev/Breadway/bread-ecosystem"
keywords = ["hyprland", "wayland", "xdg", "gtk4"]
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
dirs = { workspace = true }
gtk4 = { version = "0.11", features = ["v4_12"], optional = true }
gtk4-layer-shell = { version = "0.8", optional = true }
toml_edit = { version = "0.22", optional = true }
bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.7.0", optional = true }
[features]
# Enable the layer-shell popup scaffold (breadbox, breadclip). Kept optional
# so headless/daemon consumers (breadmon, breadhelp's CLI half, breadcrumbs)
# don't have to pull in GTK4 + layer-shell just for `hypr`/`proc`/`xdg`.
gtk = ["dep:gtk4", "dep:gtk4-layer-shell"]
# Enable the non-destructive TOML doc load/save discipline (bos-settings,
# breadhelp). Optional so consumers that don't edit TOML configs (breadbox,
# breadclip, breadmon, ...) don't pull in toml_edit.
toml = ["dep:toml_edit"]
# Enable BreadClient, a persistent-connection client for breadd's IPC
# socket (emit + subscribe), for sibling bread* app daemons that want to
# integrate with the bread automation fabric. Optional so consumers that
# don't talk to breadd at all aren't forced to pull in bread-shared.
bread-client = ["dep:bread-shared"]
[dev-dependencies]
tempfile = "3"

185
bread-utils/src/atomic.rs Normal file
View file

@ -0,0 +1,185 @@
//! Atomic file writes: write to a sibling temp file, then `rename` over the
//! target so a crash, power loss, or disk-full error mid-write never leaves
//! a truncated/corrupt file behind (a same-filesystem rename is atomic).
//!
//! Two flavors, both extracted from real (and identical) duplication:
//!
//! - [`write_atomic`] — temp-then-rename, with an optional Unix `mode` set
//! up front (so secrets never exist world-readable even briefly). This is
//! `breadcrumbs/src/util.rs::write_atomic`, promoted verbatim.
//! - [`write_atomic_backed_up`] — temp-then-rename *plus* a best-effort
//! `<path>.bak` copy of whatever was there before, so a successful-but-wrong
//! write is always recoverable. This is `bos-settings/src/config/mod.rs`'s
//! `atomic_write`, which `breadhelp/src/config.rs` re-implemented
//! byte-for-byte in the same fix pass that introduced it (its own doc
//! comment says "same discipline as bos-settings/src/config/mod.rs") —
//! exactly the kind of fresh duplication this crate exists to remove.
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
/// Write `contents` to `path` atomically. `mode` (Unix only) is applied to
/// the temp file *before* any data is written, so a file that must stay
/// private (secrets, tokens) is never briefly world-readable.
pub fn write_atomic(path: &Path, contents: &str, mode: Option<u32>) -> io::Result<()> {
write_atomic_bytes(path, contents.as_bytes(), mode)
}
/// Byte-oriented sibling of [`write_atomic`], for binary payloads (e.g. a
/// downloaded ONNX model file — see `bread-onnx`'s downloader).
pub fn write_atomic_bytes(path: &Path, contents: &[u8], mode: Option<u32>) -> io::Result<()> {
let dir = path.parent().unwrap_or_else(|| Path::new("."));
fs::create_dir_all(dir)?;
let tmp = tmp_path(path, dir);
let mut open = fs::OpenOptions::new();
open.write(true).create(true).truncate(true);
#[cfg(unix)]
if let Some(mode) = mode {
use std::os::unix::fs::OpenOptionsExt;
open.mode(mode);
}
#[cfg(not(unix))]
let _ = mode;
let res = (|| {
use std::io::Write;
let mut f = open.open(&tmp)?;
f.write_all(contents)?;
f.sync_all()?;
fs::rename(&tmp, path)
})();
if res.is_err() {
let _ = fs::remove_file(&tmp);
}
res
}
/// Like [`write_atomic`] (no `mode`), but first best-effort copies whatever
/// is currently at `path` to `<path>.bak`. The backup is best-effort — a
/// failure to back up (e.g. read-only source, first-ever write) does not
/// block the write itself.
pub fn write_atomic_backed_up(path: &Path, contents: &str) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
if path.exists() {
let backup = backup_path(path);
let _ = fs::copy(path, &backup);
}
write_atomic(path, contents, None)
}
fn tmp_path(path: &Path, dir: &Path) -> PathBuf {
let stem = path.file_name().and_then(|s| s.to_str()).unwrap_or("bread");
dir.join(format!(".{stem}.tmp.{}", std::process::id()))
}
fn backup_path(path: &Path) -> PathBuf {
backup_path_for(path)
}
/// `<path>.bak` — shared with [`crate::tomlcfg`] so its own backup-before-
/// falling-back-to-defaults logging points at the same file this module
/// would have backed up to on a write.
pub(crate) fn backup_path_for(path: &Path) -> PathBuf {
PathBuf::from(format!("{}.bak", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Read;
fn tmp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("bread-utils-atomic-test-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn write_atomic_creates_file_with_contents() {
let dir = tmp_dir("basic");
let path = dir.join("config.toml");
write_atomic(&path, "hello", None).unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "hello");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn write_atomic_leaves_no_tmp_file_behind() {
let dir = tmp_dir("no-leftover");
let path = dir.join("config.toml");
write_atomic(&path, "hello", None).unwrap();
let leftover: Vec<_> = fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.contains(".tmp."))
.collect();
assert!(leftover.is_empty(), "leftover tmp files: {leftover:?}");
let _ = fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn write_atomic_applies_mode_before_any_data_hits_disk() {
use std::os::unix::fs::PermissionsExt;
let dir = tmp_dir("mode");
let path = dir.join("secret");
write_atomic(&path, "token", Some(0o600)).unwrap();
let perms = fs::metadata(&path).unwrap().permissions();
assert_eq!(perms.mode() & 0o777, 0o600);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn write_atomic_backed_up_backs_up_previous_contents() {
let dir = tmp_dir("backup");
let path = dir.join("state.toml");
let backup = dir.join("state.toml.bak");
write_atomic_backed_up(&path, "first").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "first");
assert!(!backup.exists(), "no backup should exist before the first overwrite");
write_atomic_backed_up(&path, "second").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "second");
assert_eq!(fs::read_to_string(&backup).unwrap(), "first");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn write_atomic_backed_up_leaves_no_tmp_file_behind() {
let dir = tmp_dir("backup-no-leftover");
let path = dir.join("state.toml");
write_atomic_backed_up(&path, "first").unwrap();
write_atomic_backed_up(&path, "second").unwrap();
let leftover: Vec<_> = fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.contains(".tmp."))
.collect();
assert!(leftover.is_empty(), "leftover tmp files: {leftover:?}");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn write_atomic_overwrite_never_leaves_partial_contents_visible() {
// Not a true crash-injection test (hard to do portably), but pins
// down the observable contract: after a successful call, the file
// is either fully old or fully new, never truncated.
let dir = tmp_dir("no-partial");
let path = dir.join("f");
write_atomic(&path, "aaaaaaaaaa", None).unwrap();
write_atomic(&path, "b", None).unwrap();
let mut s = String::new();
fs::File::open(&path).unwrap().read_to_string(&mut s).unwrap();
assert_eq!(s, "b");
let _ = fs::remove_dir_all(&dir);
}
}

View file

@ -0,0 +1,302 @@
//! A persistent-connection client for breadd's IPC socket, for sibling
//! `bread*` app daemons that run continuously.
//!
//! This is deliberately a *second* client alongside `bread-emit` (the
//! fire-and-forget CLI binary in the `bread` repo), not a replacement for
//! it: `bread-emit` skips holding a connection open at all, which is right
//! for occasional/hook-style callers (a git hook, a shell prompt) but wrong
//! for a long-running daemon like breadclipd that wants to publish an
//! event on every clipboard change and subscribe to a command stream —
//! reconnecting from scratch for every single emit would be wasteful, and
//! subscribing needs a held-open connection by nature.
//!
//! # Graceful degradation
//!
//! A sibling app must never crash or block because breadd is down,
//! restarting, or was never installed. Concretely:
//! - [`BreadClient::emit`] is a best-effort, fire-and-forget single-shot
//! connection (mirroring `bread-emit`'s own stance) — if breadd is
//! unreachable, the event is silently dropped, not an error the caller
//! has to handle.
//! - [`BreadClient::subscribe`] runs its read loop on a background thread
//! that reconnects with exponential backoff on any disconnect. The
//! caller's callback simply stops being invoked while disconnected; it
//! resumes automatically once breadd comes back.
//!
//! # Namespace enforcement
//!
//! `emit` refuses locally (no network round trip) to publish an event
//! outside the app's own `bread.<app_id>.*` segment, so a misconfigured
//! caller fails fast instead of discovering the mistake from the daemon's
//! rejection. The daemon enforces the same rule server-side regardless.
use std::io::{BufRead, BufReader, Write};
use std::net::Shutdown;
use std::os::unix::net::UnixStream;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use bread_shared::apps::validate_app_namespace;
use serde_json::{json, Value};
/// A normalized event as delivered by breadd's `events.subscribe` stream.
#[derive(Debug, Clone)]
pub struct BreadEvent {
/// Dotted event name, e.g. `bread.command.clip.clear`.
pub event: String,
/// Unix epoch milliseconds when the daemon observed the originating signal.
pub timestamp: u64,
/// Structured event data; shape depends on the event family.
pub data: Value,
}
/// A client bound to one sibling app's identity, used to `emit` within that
/// app's namespace and `subscribe` to events (typically its own
/// `bread.command.<app_id>.**` verb namespace).
///
/// Cheap to clone (just an `Arc`-free `String`); safe to share across
/// threads by cloning, or to construct fresh per call site.
#[derive(Clone)]
pub struct BreadClient {
app_id: String,
}
impl BreadClient {
/// Bind a client to `app_id` (e.g. `"clip"`). Does not connect yet —
/// there is no persistent connection to "fail" at construction time;
/// `emit` and `subscribe` each connect (or reconnect) as needed. This
/// is itself part of the graceful-degradation story: constructing a
/// `BreadClient` can never fail just because breadd isn't running yet.
pub fn connect(app_id: impl Into<String>) -> Self {
Self {
app_id: app_id.into(),
}
}
/// The app id this client is bound to.
pub fn app_id(&self) -> &str {
&self.app_id
}
/// Publish `event` (must be within `bread.<app_id>.*`) with `data`.
/// Fire-and-forget: a single short-lived connection is opened, the
/// request is written, and the reply is never read (mirroring
/// `bread-emit`). If breadd is unreachable or slow, this silently does
/// nothing — it never blocks or errors the caller.
pub fn emit(&self, event: &str, data: Value) {
if !validate_app_namespace(&self.app_id, event) {
eprintln!(
"bread-client: refusing to emit '{event}' outside the '{}' namespace",
self.app_id
);
return;
}
let request = json!({
"id": "0",
"method": "emit",
"params": {
"event": event,
"source": self.app_id,
"kind": event,
"data": data,
}
});
let Ok(line) = serde_json::to_string(&request) else {
return;
};
let Ok(mut stream) = UnixStream::connect(bread_shared::resolve_socket_path()) else {
return;
};
let _ = stream.set_write_timeout(Some(Duration::from_millis(200)));
let _ = writeln!(stream, "{line}");
}
/// Subscribe to events matching `pattern` (glob: `*`/`**`/`?`), invoking
/// `on_event` for each one on a dedicated background thread. Typically
/// called with `"bread.command.<app_id>.**"` to receive commands
/// addressed to this app.
///
/// Returns a [`Subscription`] handle; drop or call [`Subscription::stop`]
/// to end it. The background thread reconnects with exponential backoff
/// (500ms, capped at ~32s) whenever the connection drops, so a restart
/// of breadd is transparent to the caller — `on_event` simply pauses
/// and resumes.
pub fn subscribe<F>(&self, pattern: impl Into<String>, on_event: F) -> Subscription
where
F: Fn(BreadEvent) + Send + 'static,
{
let pattern = pattern.into();
let stop = Arc::new(AtomicBool::new(false));
let current_stream: Arc<Mutex<Option<UnixStream>>> = Arc::new(Mutex::new(None));
let stop_for_thread = stop.clone();
let stream_for_thread = current_stream.clone();
let handle = thread::spawn(move || {
let mut attempt: u32 = 0;
while !stop_for_thread.load(Ordering::Relaxed) {
match run_subscription_once(&pattern, &on_event, &stream_for_thread) {
Ok(()) => attempt = 0, // clean end (stop() closed the socket)
Err(_) => attempt = attempt.saturating_add(1),
}
*stream_for_thread.lock().unwrap_or_else(|p| p.into_inner()) = None;
if stop_for_thread.load(Ordering::Relaxed) {
break;
}
let backoff_ms = 500u64.saturating_mul(2u64.saturating_pow(attempt.min(6)));
thread::sleep(Duration::from_millis(backoff_ms));
}
});
Subscription {
stop,
current_stream,
handle: Some(handle),
}
}
}
/// Connects once, sends `events.subscribe`, and invokes `on_event` for every
/// matching line until the connection ends (cleanly or with an error).
/// Stores the live stream in `current_stream` so [`Subscription::stop`] can
/// shut it down from another thread to interrupt the blocking read promptly.
fn run_subscription_once(
pattern: &str,
on_event: &impl Fn(BreadEvent),
current_stream: &Mutex<Option<UnixStream>>,
) -> std::io::Result<()> {
let stream = UnixStream::connect(bread_shared::resolve_socket_path())?;
let read_stream = stream.try_clone()?;
*current_stream.lock().unwrap_or_else(|p| p.into_inner()) = Some(stream);
// Re-borrow to write the subscribe request through the stored copy so
// there is exactly one owner performing I/O per direction.
{
let guard = current_stream.lock().unwrap_or_else(|p| p.into_inner());
if let Some(stream) = guard.as_ref() {
let mut writer = stream;
let request = json!({
"id": "sub",
"method": "events.subscribe",
"params": { "filter": pattern }
});
let line = serde_json::to_string(&request).unwrap_or_default();
writeln!(writer, "{line}")?;
}
}
for line in BufReader::new(read_stream).lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let Ok(value) = serde_json::from_str::<Value>(&line) else {
continue;
};
// The first line is the subscribe ack ({"result": {"subscribed": true}});
// only lines with an "event" field are actual BreadEvents.
if let Some(event_name) = value.get("event").and_then(Value::as_str) {
let timestamp = value.get("timestamp").and_then(Value::as_u64).unwrap_or(0);
let data = value.get("data").cloned().unwrap_or(Value::Null);
on_event(BreadEvent {
event: event_name.to_string(),
timestamp,
data,
});
}
}
Ok(())
}
/// Handle to a running [`BreadClient::subscribe`] background thread.
pub struct Subscription {
stop: Arc<AtomicBool>,
current_stream: Arc<Mutex<Option<UnixStream>>>,
handle: Option<thread::JoinHandle<()>>,
}
impl Subscription {
/// Stop the subscription and block until its background thread exits.
/// Shuts down the live socket (if connected) so a thread blocked in a
/// read wakes up immediately, rather than waiting for the next event or
/// a future reconnect attempt to notice the stop flag.
pub fn stop(mut self) {
self.stop.store(true, Ordering::Relaxed);
if let Some(stream) = self
.current_stream
.lock()
.unwrap_or_else(|p| p.into_inner())
.as_ref()
{
let _ = stream.shutdown(Shutdown::Both);
}
if let Some(h) = self.handle.take() {
let _ = h.join();
}
}
}
impl Drop for Subscription {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
if let Some(stream) = self
.current_stream
.lock()
.unwrap_or_else(|p| p.into_inner())
.as_ref()
{
let _ = stream.shutdown(Shutdown::Both);
}
// Best-effort on drop: don't block a caller who simply let the
// handle go out of scope. Explicit `stop()` is what actually waits.
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn connect_never_fails_even_with_no_daemon_present() {
// Constructing a client must not depend on breadd actually running —
// that's the whole point of the graceful-degradation design.
let _client = BreadClient::connect("clip");
}
#[test]
fn emit_is_a_silent_no_op_when_daemon_is_unreachable() {
// Point at a socket path that can't possibly exist by using an
// app id that still passes namespace validation; the daemon being
// absent must not panic or block this call.
let client = BreadClient::connect("clip");
client.emit("bread.clip.copied", json!({ "len": 1 }));
}
#[test]
fn emit_refuses_event_outside_own_namespace_without_connecting() {
// "pad" events are not this client's to publish — this must be
// caught locally (and cheaply) rather than round-tripped to a
// daemon that isn't even running in this test.
let client = BreadClient::connect("clip");
client.emit("bread.pad.reminder.due", json!({}));
// No assertion beyond "did not panic" — there is no daemon to
// observe the (correctly suppressed) call against in a unit test;
// the cross-process behavior is covered by breadd's own
// integration tests for the IPC-side of namespace validation.
}
#[test]
fn subscription_stop_joins_the_background_thread() {
let client = BreadClient::connect("clip");
let sub = client.subscribe("bread.command.clip.**", |_event| {});
// Even with no daemon present (so the thread is spinning on
// connect-refused + backoff), stop() must return promptly rather
// than hanging.
sub.stop();
}
}

View file

@ -0,0 +1,111 @@
//! Shared GTK4 layer-shell popup scaffold: the full-screen transparent
//! overlay window setup, `ListBox` up/down visible-row navigation, and
//! click-outside-to-close gesture were duplicated near-verbatim between
//! `breadbox/src/main.rs` and `breadclip/src/main.rs`:
//!
//! - Layer-shell window setup: `breadbox/src/main.rs:357-365` /
//! `breadclip/src/main.rs:231-239` — identical `init_layer_shell` +
//! namespace + `Layer::Overlay` + `KeyboardMode::Exclusive` + anchor all
//! four edges + zero exclusive zone.
//! - Up/Down navigation loop: `breadbox/src/main.rs:515-546` /
//! `breadclip/src/main.rs:423-454` — byte-for-byte identical "find the
//! next/previous *visible* row" loop (breadclip's own comment even reads
//! `// ---- Keyboard handler (capture phase, same as breadbox) ----`).
//! - Click-outside-close: `breadbox/src/main.rs:566-581` /
//! `breadclip/src/main.rs:474-...` — identical bounds-check against a
//! content widget (breadclip: `// ---- Click outside panel → close (same
//! pattern as breadbox) ----`).
//!
//! Deliberately *not* extracted: the rest of each app's `EventControllerKey`
//! handling (Enter/Delete semantics, filter chips, search) — those differ
//! per app (`do_launch` vs `do_copy`+`Delete`-to-remove) and forcing them
//! into one callback-owning "scaffold" struct would be a leakier
//! abstraction than the ~5 free functions below.
//!
//! Requires the `gtk` feature.
use gtk4::prelude::*;
use gtk4::{ApplicationWindow, GestureClick};
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
/// Build the full-screen transparent overlay window every layer-shell popup
/// in this ecosystem starts from: layered above normal windows, keyboard-
/// exclusive (so Escape/Enter/arrow keys reach the popup instead of the
/// focused client behind it), anchored to all four edges with zero
/// exclusive zone (so it doesn't reserve screen space or push other layer
/// clients around).
pub fn new_overlay_window(app: &gtk4::Application, namespace: &str) -> ApplicationWindow {
let window = ApplicationWindow::builder().application(app).build();
window.init_layer_shell();
window.set_namespace(Some(namespace));
window.set_layer(Layer::Overlay);
window.set_keyboard_mode(KeyboardMode::Exclusive);
for edge in [Edge::Top, Edge::Bottom, Edge::Left, Edge::Right] {
window.set_anchor(edge, true);
}
window.set_exclusive_zone(0);
window
}
/// Select the next *visible* row after the current selection (rows can be
/// hidden by a live search filter — a plain "select index + 1" would land
/// on a filtered-out row). No-op if there is no next visible row.
pub fn select_next_visible(list: &gtk4::ListBox) {
let cur = list.selected_row().map(|r| r.index()).unwrap_or(-1);
let mut i = cur + 1;
loop {
match list.row_at_index(i) {
Some(r) if r.is_visible() => {
list.select_row(Some(&r));
break;
}
Some(_) => i += 1,
None => break,
}
}
}
/// Select the previous *visible* row before the current selection. No-op if
/// there is no previous visible row.
pub fn select_prev_visible(list: &gtk4::ListBox) {
let cur = list.selected_row().map(|r| r.index()).unwrap_or(0);
let mut i = cur - 1;
loop {
if i < 0 {
break;
}
match list.row_at_index(i) {
Some(r) if r.is_visible() => {
list.select_row(Some(&r));
break;
}
Some(_) => i -= 1,
None => break,
}
}
}
/// Attach a click gesture to `window` that calls `on_outside` whenever a
/// click lands outside `content`'s bounds (e.g. clicking the transparent
/// full-screen backdrop around a centered launcher/panel widget).
pub fn close_on_outside_click(
window: &ApplicationWindow,
content: &impl IsA<gtk4::Widget>,
on_outside: impl Fn() + 'static,
) {
let content = content.clone().upcast::<gtk4::Widget>();
let win_ref = window.clone();
let gesture = GestureClick::new();
gesture.connect_pressed(move |_, _, x, y| {
if let Some(b) = content.compute_bounds(&win_ref) {
if x < b.x() as f64
|| x > (b.x() + b.width()) as f64
|| y < b.y() as f64
|| y > (b.y() + b.height()) as f64
{
on_outside();
}
}
});
window.add_controller(gesture);
}

252
bread-utils/src/hypr.rs Normal file
View file

@ -0,0 +1,252 @@
//! Hyprland IPC client: socket1 request/response (JSON) and socket2 path
//! resolution.
//!
//! The socket-path resolution + raw request/response round trip was
//! duplicated near-verbatim in `breadbox/src/main.rs` (`get_active_workspace`,
//! lines 26-42) and `breadclip/src/position.rs` (`hyprctl_json`, lines
//! 58-71) — same `HYPRLAND_INSTANCE_SIGNATURE`/`XDG_RUNTIME_DIR` env lookup,
//! same `.socket.sock` path format, same connect/write/shutdown-write/
//! read-to-string sequence. `breadmon/src/main.rs`'s `hyprland_socket2_path`
//! duplicates just the path-resolution half for the event socket.
//!
//! `active_window`'s `fullscreen` field deserializes leniently as either a
//! JSON bool or integer: Hyprland has changed this field's type across
//! versions (older releases emit a bool, `0`/`1`; newer ones emit an
//! integer fullscreen *mode* — `0` none, `1` maximized, `2` fullscreen), and
//! a client hard-coded to one shape silently misreads the other instead of
//! erroring. `breadclip`'s own version (`as_i64().unwrap_or(0) != 0`) only
//! handles the integer shape; a bool `true` would `.as_i64()` to `None` and
//! silently read as "not fullscreen".
use serde::Deserialize;
use std::env;
use std::io::{Read, Write};
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
/// Which of Hyprland's two IPC sockets: `.socket.sock` (request/response) or
/// `.socket2.sock` (event stream).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Socket {
Request,
Events,
}
/// Resolve the path to one of Hyprland's IPC sockets from
/// `HYPRLAND_INSTANCE_SIGNATURE` + `XDG_RUNTIME_DIR`. Returns `None` if
/// `HYPRLAND_INSTANCE_SIGNATURE` isn't set (Hyprland isn't running, or we're
/// not inside a Hyprland session) — `XDG_RUNTIME_DIR` falls back to
/// `/run/user/1000` if unset, matching `breadmon`'s existing fallback.
pub fn socket_path(kind: Socket) -> Option<PathBuf> {
let sig = env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?;
let rt = env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".to_string());
let file = match kind {
Socket::Request => ".socket.sock",
Socket::Events => ".socket2.sock",
};
Some(PathBuf::from(format!("{rt}/hypr/{sig}/{file}")))
}
/// Send `request` (e.g. `"j/activewindow"`, `"j/monitors"`) to the socket1
/// IPC socket and return the raw response body. Blocking/synchronous — this
/// matches every current consumer (breadbox, breadclip), which call it from
/// non-async GTK app code.
///
/// Read/write timeouts are set on the socket (both original hand-rolled
/// implementations this replaces — breadbox's `get_active_workspace`,
/// breadclip's `hyprctl_json` — had none): a Hyprland instance that's
/// wedged or mid-reload could otherwise hang this call, and every current
/// caller runs it on the GTK main thread, so a hang here freezes the whole
/// UI, not just this query.
const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
pub fn request(request: &str) -> Option<String> {
let socket = socket_path(Socket::Request)?;
let mut stream = UnixStream::connect(&socket).ok()?;
stream.set_read_timeout(Some(REQUEST_TIMEOUT)).ok()?;
stream.set_write_timeout(Some(REQUEST_TIMEOUT)).ok()?;
stream.write_all(request.as_bytes()).ok()?;
stream.shutdown(std::net::Shutdown::Write).ok()?;
let mut buf = String::new();
stream.read_to_string(&mut buf).ok()?;
Some(buf)
}
/// Like [`request`], parsed as JSON. `request` should already carry the `j/`
/// prefix Hyprland expects for JSON responses (e.g. `"j/activewindow"`).
pub fn request_json(request_str: &str) -> Option<serde_json::Value> {
serde_json::from_str(&request(request_str)?).ok()
}
/// Connect to the socket2 event stream. Callers read newline-delimited
/// `EVENT>>DATA` lines from the returned stream themselves — event framing
/// and reconnect/backoff policy are genuinely per-consumer (see
/// `breadmon`'s hotplug listener), so this only replaces the duplicated
/// path-resolution + connect boilerplate, not a full event-loop
/// abstraction.
pub fn connect_events() -> Option<UnixStream> {
let socket = socket_path(Socket::Events)?;
UnixStream::connect(&socket).ok()
}
/// Hyprland's `fullscreen` field, tolerant of either representation it has
/// shipped across versions: a plain bool, or an integer fullscreen mode
/// (`0` = none, nonzero = some fullscreen mode).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct FullscreenState(bool);
impl FullscreenState {
pub fn is_fullscreen(self) -> bool {
self.0
}
}
impl<'de> Deserialize<'de> for FullscreenState {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Repr {
Bool(bool),
Int(i64),
}
Ok(match Repr::deserialize(deserializer)? {
Repr::Bool(b) => FullscreenState(b),
Repr::Int(i) => FullscreenState(i != 0),
})
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct ActiveWindow {
#[serde(default)]
pub class: String,
#[serde(default)]
pub fullscreen: FullscreenState,
pub at: (i32, i32),
pub size: (i32, i32),
}
impl ActiveWindow {
pub fn x(&self) -> i32 {
self.at.0
}
pub fn y(&self) -> i32 {
self.at.1
}
pub fn width(&self) -> i32 {
self.size.0
}
pub fn height(&self) -> i32 {
self.size.1
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct Monitor {
pub name: String,
pub x: i32,
pub y: i32,
pub width: i32,
pub height: i32,
#[serde(default)]
pub focused: bool,
}
/// Query the currently active (focused) window. Returns `None` if the
/// window is fullscreen or no window is focused — same "centre the popup
/// instead" contract `breadclip`'s original `get_active_window` had.
pub fn active_window() -> Option<ActiveWindow> {
let win: ActiveWindow = serde_json::from_value(request_json("j/activewindow")?).ok()?;
if win.fullscreen.is_fullscreen() || win.class.is_empty() {
return None;
}
Some(win)
}
/// Query all monitors and return the focused one (or the first, if none
/// report as focused).
pub fn focused_monitor() -> Option<Monitor> {
let monitors: Vec<Monitor> = serde_json::from_value(request_json("j/monitors")?).ok()?;
monitors
.iter()
.find(|m| m.focused)
.or_else(|| monitors.first())
.cloned()
}
/// The active workspace's name (e.g. `"1"`, `"special:scratch"`).
pub fn active_workspace_name() -> Option<String> {
request_json("j/activeworkspace")?
.get("name")
.and_then(|v| v.as_str())
.map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fullscreen_state_deserializes_from_bool() {
let s: FullscreenState = serde_json::from_str("true").unwrap();
assert!(s.is_fullscreen());
let s: FullscreenState = serde_json::from_str("false").unwrap();
assert!(!s.is_fullscreen());
}
#[test]
fn fullscreen_state_deserializes_from_int() {
let s: FullscreenState = serde_json::from_str("0").unwrap();
assert!(!s.is_fullscreen());
let s: FullscreenState = serde_json::from_str("2").unwrap();
assert!(s.is_fullscreen());
}
#[test]
fn active_window_parses_bool_fullscreen_shape() {
let json = r#"{"class":"kitty","fullscreen":true,"at":[10,20],"size":[300,400]}"#;
let win: ActiveWindow = serde_json::from_str(json).unwrap();
assert!(win.fullscreen.is_fullscreen());
assert_eq!(win.x(), 10);
assert_eq!(win.height(), 400);
}
#[test]
fn active_window_parses_int_fullscreen_shape() {
let json = r#"{"class":"kitty","fullscreen":1,"at":[0,0],"size":[100,100]}"#;
let win: ActiveWindow = serde_json::from_str(json).unwrap();
assert!(win.fullscreen.is_fullscreen());
}
// Both env-var-dependent cases share one test function: `set_var`/
// `remove_var` are process-global, and cargo runs tests in parallel
// threads by default, so two separate #[test] fns racing on the same
// vars would be flaky.
#[test]
fn socket_path_env_var_behavior() {
let _lock = crate::env_test_lock().lock().unwrap_or_else(|e| e.into_inner());
unsafe { env::remove_var("HYPRLAND_INSTANCE_SIGNATURE") };
assert!(socket_path(Socket::Request).is_none());
unsafe {
env::set_var("HYPRLAND_INSTANCE_SIGNATURE", "test-sig");
env::set_var("XDG_RUNTIME_DIR", "/run/user/9999");
}
assert_eq!(
socket_path(Socket::Request).unwrap(),
PathBuf::from("/run/user/9999/hypr/test-sig/.socket.sock")
);
assert_eq!(
socket_path(Socket::Events).unwrap(),
PathBuf::from("/run/user/9999/hypr/test-sig/.socket2.sock")
);
unsafe {
env::remove_var("HYPRLAND_INSTANCE_SIGNATURE");
env::remove_var("XDG_RUNTIME_DIR");
}
}
}

50
bread-utils/src/lib.rs Normal file
View file

@ -0,0 +1,50 @@
//! Shared plumbing for the bread desktop-automation ecosystem.
//!
//! Extracted from genuine, verified duplication across breadbox, breadclip,
//! breadmon, breadcrumbs, bos-settings, and breadhelp during the 2026-07-16
//! ecosystem-wide utility audit. Each module's doc comment cites the
//! original file:line locations the code was extracted from.
//!
//! - [`hypr`] — Hyprland IPC: socket path resolution, socket1
//! request/response, typed `activewindow`/`monitors` queries with
//! version-tolerant `fullscreen` field parsing.
//! - [`singleton`] — correct, TOCTOU-free single-instance/PID-toggle.
//! - [`proc`] — timeout-guarded subprocess execution.
//! - [`atomic`] — atomic (temp-then-rename) file writes, with an optional
//! `.bak`-before-overwrite variant.
//! - [`xdg`] — XDG base directory helpers with a real (never literal-tilde)
//! `$HOME` fallback.
//! - [`tomlcfg`] (feature `toml`) — non-destructive TOML document
//! load/save discipline built on [`atomic`].
//! - [`gtk_popup`] (feature `gtk`) — shared layer-shell popup window setup,
//! list navigation, and click-outside-to-close.
//! - [`bread_client`] (feature `bread-client`) — a persistent-connection
//! client for breadd's IPC socket (emit + subscribe), for sibling
//! `bread*` app daemons integrating with the bread automation fabric.
pub mod atomic;
pub mod hypr;
pub mod proc;
pub mod singleton;
pub mod xdg;
/// Serializes tests that read or mutate process-global env vars
/// (`XDG_RUNTIME_DIR`, `HYPRLAND_INSTANCE_SIGNATURE`) — `cargo test` runs
/// tests in parallel threads within one process by default, and
/// `std::env::set_var` is process-wide, so a `hypr` test temporarily
/// pointing `XDG_RUNTIME_DIR` at a nonexistent path can otherwise race a
/// concurrently-running `singleton` or `xdg` test that expects the real one.
#[cfg(test)]
pub(crate) fn env_test_lock() -> &'static std::sync::Mutex<()> {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| std::sync::Mutex::new(()))
}
#[cfg(feature = "toml")]
pub mod tomlcfg;
#[cfg(feature = "gtk")]
pub mod gtk_popup;
#[cfg(feature = "bread-client")]
pub mod bread_client;

174
bread-utils/src/proc.rs Normal file
View file

@ -0,0 +1,174 @@
//! Timeout-guarded subprocess execution.
//!
//! Promoted verbatim from `breadcrumbs/src/util.rs` (the one implementation
//! in the ecosystem that already got this right — see the audit note in
//! `bread-utils`'s crate root). Several other repos shell out to
//! Wayland/Hyprland tools (`hyprctl`, `grim`, `wl-paste`, ...) via bare
//! `std::process::Command` with no timeout at all, so a hung child can wedge
//! the whole caller indefinitely. `run`/`run_with_stdin` below kill the
//! child and return a failed [`Output`] once `timeout` elapses instead.
use std::io::{Read, Write};
use std::process::{Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};
#[derive(Debug, Clone)]
pub struct Output {
pub success: bool,
pub stdout: String,
pub stderr: String,
}
impl Output {
pub fn failed() -> Output {
Output {
success: false,
stdout: String::new(),
stderr: String::new(),
}
}
}
/// Run a command with a hard timeout. The child is killed if it overruns so
/// a hung subprocess can never wedge the caller.
pub fn run(prog: &str, args: &[&str], timeout: Duration) -> Output {
run_with_stdin(prog, args, None, timeout)
}
/// Like [`run`], but feeds `stdin` to the child's standard input. Useful for
/// handing secrets (e.g. Wi-Fi PSKs, API tokens) to a CLI without exposing
/// them in argv, where any local user could read them via `ps`.
pub fn run_with_stdin(prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration) -> Output {
let stdin_cfg = if stdin.is_some() {
Stdio::piped()
} else {
Stdio::null()
};
let mut child = match Command::new(prog)
.args(args)
// Pin the C locale so message text callers parse (hyprctl JSON keys,
// status output, ...) is stable regardless of the user's LANG.
.env("LC_ALL", "C")
.env("LANG", "C")
.stdin(stdin_cfg)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(_) => return Output::failed(),
};
let mut stdout_pipe = child.stdout.take();
let mut stderr_pipe = child.stderr.take();
let out_handle = thread::spawn(move || {
let mut buf = String::new();
if let Some(ref mut p) = stdout_pipe {
let _ = p.read_to_string(&mut buf);
}
buf
});
let err_handle = thread::spawn(move || {
let mut buf = String::new();
if let Some(ref mut p) = stderr_pipe {
let _ = p.read_to_string(&mut buf);
}
buf
});
// Feed stdin only after the reader threads are draining stdout/stderr, so
// a child that writes more than a pipe buffer before consuming stdin
// can't deadlock against our blocking write.
if let Some(data) = stdin {
if let Some(mut sink) = child.stdin.take() {
let _ = sink.write_all(data.as_bytes());
// Drop closes the pipe so the child's read sees EOF.
}
}
let start = Instant::now();
let status = loop {
match child.try_wait() {
Ok(Some(s)) => break Some(s),
Ok(None) => {
if start.elapsed() >= timeout {
let _ = child.kill();
let _ = child.wait();
break None;
}
thread::sleep(Duration::from_millis(50));
}
Err(_) => break None,
}
};
let stdout = out_handle.join().unwrap_or_default();
let stderr = err_handle.join().unwrap_or_default();
Output {
success: status.map(|s| s.success()).unwrap_or(false),
stdout,
stderr,
}
}
pub fn run_ok(prog: &str, args: &[&str], timeout: Duration) -> bool {
run(prog, args, timeout).success
}
/// Run a command and parse its stdout as JSON on success. Convenience for the
/// very common `hyprctl -j <subcommand>` / `<tool> --json` pattern.
pub fn run_json(prog: &str, args: &[&str], timeout: Duration) -> Option<serde_json::Value> {
let out = run(prog, args, timeout);
if !out.success {
return None;
}
serde_json::from_str(&out.stdout).ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn run_captures_stdout() {
let out = run("printf", &["hello"], Duration::from_secs(2));
assert!(out.success);
assert_eq!(out.stdout, "hello");
}
#[test]
fn run_reports_failure_for_nonzero_exit() {
let out = run("sh", &["-c", "exit 3"], Duration::from_secs(2));
assert!(!out.success);
}
#[test]
fn run_kills_hung_child_after_timeout() {
let start = Instant::now();
let out = run("sleep", &["30"], Duration::from_millis(200));
assert!(!out.success);
assert!(start.elapsed() < Duration::from_secs(5), "child was not killed promptly");
}
#[test]
fn run_with_stdin_feeds_child_input() {
let out = run_with_stdin("cat", &[], Some("secret-data"), Duration::from_secs(2));
assert!(out.success);
assert_eq!(out.stdout, "secret-data");
}
#[test]
fn run_json_parses_stdout() {
let out = run_json("printf", &["{\"a\":1}"], Duration::from_secs(2));
assert_eq!(out.unwrap()["a"], 1);
}
#[test]
fn run_json_returns_none_on_failure() {
let out = run_json("sh", &["-c", "exit 1"], Duration::from_secs(2));
assert!(out.is_none());
}
}

View file

@ -0,0 +1,209 @@
//! Correct single-instance / PID-toggle, replacing the TOCTOU-prone pattern
//! duplicated in `breadbox/src/main.rs` (`toggle_or_continue`/`pid_file`,
//! ~30 lines) and `breadclip/src/main.rs` (same function names, whose own
//! comment reads `// ---- PID file toggle (single-instance, matches breadbox
//! pattern) ----`).
//!
//! The old pattern: read the PID file, `/proc/<pid>/comm`-check whether it's
//! still this app, `kill` it if so, otherwise `fs::write` our own PID over
//! it. That's three separate, non-atomic steps — two instances launched at
//! once can both read "no valid PID" and both proceed as the "first"
//! instance; a stale PID file left by a crash can also collide with an
//! unrelated process that was later assigned the same PID by the kernel,
//! sending it a `kill` it never asked for.
//!
//! This module instead holds an exclusive, kernel-atomic advisory lock
//! (`std::fs::File::try_lock`, i.e. `flock(2)`) on the PID file for the
//! entire lifetime of the process that acquires it. Lock ownership itself
//! *is* the liveness check — there is no window where two processes can
//! both believe they're the sole instance, and a crashed process's lock is
//! released by the kernel the instant it dies, so there's no stale-lock
//! case to reason about at all.
//!
//! [`try_acquire`] is the side-effect-free primitive (no signals sent);
//! [`toggle_or_kill`] layers breadbox/breadclip's actual desired behavior
//! (kill whoever's running, then exit) on top of it.
use std::fs::{File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::PathBuf;
/// Held for the lifetime of the running instance. Dropping it releases the
/// flock and removes the PID file. Keep this alive (e.g. in a `let _guard =
/// ...` bound in `main`) for as long as the app should be considered "the"
/// running instance.
pub struct Guard {
_file: File,
path: PathBuf,
}
impl Drop for Guard {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
pub enum Acquire {
/// No other instance was running; we now hold the lock.
Acquired(Guard),
/// Another instance already holds the lock and is therefore alive right
/// now. Carries whatever PID it last recorded, if the file contents
/// parsed as one.
HeldByOther(Option<u32>),
}
pub enum Toggle {
/// No other instance was running; we now hold the lock. Keep the guard
/// alive for the process's lifetime.
Started(Guard),
/// Another instance was already running and (if a PID could be read
/// from the file) has been sent `SIGTERM`. The caller should exit
/// immediately without starting.
KilledExisting,
}
/// `$XDG_RUNTIME_DIR/<app>.pid` (falling back to `/tmp`, matching every
/// existing consumer's own fallback) — same location `breadbox`/`breadclip`
/// already used.
pub fn pid_file_path(app: &str) -> PathBuf {
crate::xdg::runtime_dir().join(format!("{app}.pid"))
}
/// Try to become the single instance of `app`, with no side effects beyond
/// the lock/file itself — in particular, unlike [`toggle_or_kill`], this
/// never signals another process. Prefer this if your app wants different
/// behavior than "kill the existing instance" (e.g. just refuse to start a
/// second copy).
pub fn try_acquire(app: &str) -> std::io::Result<Acquire> {
let path = pid_file_path(app);
let mut file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.open(&path)?;
match file.try_lock() {
Ok(()) => {
file.set_len(0)?;
file.seek(SeekFrom::Start(0))?;
write!(file, "{}", std::process::id())?;
file.sync_all()?;
Ok(Acquire::Acquired(Guard { _file: file, path }))
}
Err(_) => {
let mut contents = String::new();
let _ = file.read_to_string(&mut contents);
Ok(Acquire::HeldByOther(contents.trim().parse::<u32>().ok()))
}
}
}
/// Toggle behavior: acquire the single-instance lock for `app`. If already
/// held by another live process, signal it to quit (`SIGTERM` via `kill`)
/// and return [`Toggle::KilledExisting`] — the caller should exit. Otherwise
/// take the lock and return [`Toggle::Started`] — the caller should proceed
/// and keep the guard alive.
pub fn toggle_or_kill(app: &str) -> std::io::Result<Toggle> {
Ok(match try_acquire(app)? {
Acquire::Acquired(guard) => Toggle::Started(guard),
Acquire::HeldByOther(Some(pid)) => {
kill(pid);
Toggle::KilledExisting
}
Acquire::HeldByOther(None) => Toggle::KilledExisting,
})
}
#[cfg(unix)]
fn kill(pid: u32) {
// Shells out rather than binding libc directly, matching how every
// existing consumer already did this (`Command::new("kill")`) — no new
// dependency for a one-shot signal.
let _ = std::process::Command::new("kill")
.arg(pid.to_string())
.status();
}
#[cfg(not(unix))]
fn kill(_pid: u32) {}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn unique_app(name: &str) -> String {
format!("bread-utils-singleton-test-{name}-{}", std::process::id())
}
#[test]
fn first_acquire_succeeds_and_releases_on_drop() {
// Guards against `hypr`'s env-var test concurrently pointing
// XDG_RUNTIME_DIR at a nonexistent path mid-test — see `env_test_lock`.
let _lock = crate::env_test_lock().lock().unwrap_or_else(|e| e.into_inner());
let app = unique_app("first");
match try_acquire(&app).unwrap() {
Acquire::Acquired(_guard) => {}
Acquire::HeldByOther(_) => panic!("expected to be the first instance"),
}
// Guard dropped at end of scope; pid file should be gone.
std::thread::sleep(Duration::from_millis(10));
assert!(!pid_file_path(&app).exists());
}
#[test]
fn second_acquire_while_first_is_held_reports_held_by_other_with_our_pid() {
let _lock = crate::env_test_lock().lock().unwrap_or_else(|e| e.into_inner());
let app = unique_app("second");
let guard = match try_acquire(&app).unwrap() {
Acquire::Acquired(g) => g,
Acquire::HeldByOther(_) => panic!("expected to be the first instance"),
};
// A second attempt while the first guard is still held must not be
// able to acquire the lock too — that's the whole point. No signal
// is sent by `try_acquire` itself (that's `toggle_or_kill`'s job),
// so this is safe to assert without affecting the test process.
match try_acquire(&app).unwrap() {
Acquire::HeldByOther(pid) => assert_eq!(pid, Some(std::process::id())),
Acquire::Acquired(_) => panic!("second acquire succeeded while the first still holds the lock"),
}
drop(guard);
}
#[test]
fn lock_is_released_after_guard_drop_so_a_later_instance_can_acquire() {
let _lock = crate::env_test_lock().lock().unwrap_or_else(|e| e.into_inner());
let app = unique_app("release");
let guard = match try_acquire(&app).unwrap() {
Acquire::Acquired(g) => g,
Acquire::HeldByOther(_) => panic!("expected to be the first instance"),
};
drop(guard);
match try_acquire(&app).unwrap() {
Acquire::Acquired(_g) => {}
Acquire::HeldByOther(_) => panic!("lock should have been released when the guard was dropped"),
}
}
#[test]
fn toggle_or_kill_starts_when_nothing_else_is_running() {
let _lock = crate::env_test_lock().lock().unwrap_or_else(|e| e.into_inner());
let app = unique_app("toggle-start");
match toggle_or_kill(&app).unwrap() {
Toggle::Started(_guard) => {}
Toggle::KilledExisting => panic!("expected to start as the first instance"),
}
}
// Deliberately not unit-tested: `toggle_or_kill`'s kill-the-existing-
// instance branch. Exercising it for real means sending a real SIGTERM
// to a real process; the only PID a test process can safely target is
// its own (as a stand-in "other instance" via a shared PID file), and
// doing that would SIGTERM the test binary itself. The branch is a
// two-line, directly-inspectable call to `kill()` gated on
// `HeldByOther(Some(pid))`, which the `second_acquire_...` test above
// already exercises up to (and excluding) the signal send.
}

100
bread-utils/src/tomlcfg.rs Normal file
View file

@ -0,0 +1,100 @@
//! Non-destructive TOML config editing discipline.
//!
//! Extracted from `bos-settings/src/config/mod.rs` (`load_doc`/`save_doc`)
//! and `breadhelp/src/config.rs`, which re-implemented the exact same
//! function bodies in the same fix pass that introduced `bos-settings`'s
//! version — right down to the eprintln wording template. Both parse into a
//! `toml_edit::DocumentMut` (preserving keys/comments/formatting this app
//! doesn't model) and back up a file that exists but fails to parse, once,
//! before falling back to an empty document — so a bad edit is always
//! recoverable from `<path>.bak` instead of silently destroying whatever the
//! file used to hold.
//!
//! Requires the `toml` feature.
use std::path::Path;
use toml_edit::DocumentMut;
/// Load a TOML file into an editable document. A missing file yields an
/// empty document (normal for a fresh install). A file that *exists* but
/// fails to parse is backed up to `<path>.bak` once before falling back to
/// an empty document, so the next [`save_doc`] doesn't silently overwrite an
/// unparseable-but-recoverable file with only the caller's modelled keys.
///
/// `app` is used only to prefix the parse-failure log line (e.g.
/// `"breadhelp"`, `"bos-settings"`).
pub fn load_doc(app: &str, path: &Path) -> DocumentMut {
let Ok(text) = std::fs::read_to_string(path) else {
return DocumentMut::default();
};
match text.parse::<DocumentMut>() {
Ok(doc) => doc,
Err(e) => {
let backup = super::atomic::backup_path_for(path);
eprintln!(
"{app}: {} failed to parse ({e}); backed up to {} before falling back to defaults",
path.display(),
backup.display()
);
let _ = std::fs::write(&backup, &text);
DocumentMut::default()
}
}
}
/// Write the document back to disk atomically (temp-then-rename), backing up
/// whatever was there before overwriting it — see
/// [`crate::atomic::write_atomic_backed_up`].
pub fn save_doc(path: &Path, doc: &DocumentMut) -> std::io::Result<()> {
super::atomic::write_atomic_backed_up(path, &doc.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use toml_edit::value;
fn tmp_dir(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("bread-utils-tomlcfg-test-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn missing_file_yields_empty_document() {
let dir = tmp_dir("missing");
let doc = load_doc("test", &dir.join("nope.toml"));
assert!(doc.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn save_then_load_round_trips() {
let dir = tmp_dir("roundtrip");
let path = dir.join("state.toml");
let mut doc = DocumentMut::default();
doc["general"]["mode"] = value("dad");
save_doc(&path, &doc).unwrap();
let loaded = load_doc("test", &path);
assert_eq!(
loaded.get("general").and_then(|t| t.get("mode")).and_then(|v| v.as_str()),
Some("dad")
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn unparseable_existing_file_is_backed_up_before_falling_back() {
let dir = tmp_dir("bad-parse");
let path = dir.join("state.toml");
std::fs::write(&path, "this is not [ valid toml").unwrap();
let doc = load_doc("test", &path);
assert!(doc.is_empty());
let backup = dir.join("state.toml.bak");
assert_eq!(std::fs::read_to_string(&backup).unwrap(), "this is not [ valid toml");
let _ = std::fs::remove_dir_all(&dir);
}
}

123
bread-utils/src/xdg.rs Normal file
View file

@ -0,0 +1,123 @@
//! XDG base directory helpers.
//!
//! Several repos independently rolled `dirs::data_local_dir().unwrap_or_else(||
//! PathBuf::from("~/.local/share"))`-shaped fallbacks. The literal-tilde
//! string is the bug: `PathBuf`/`std::fs` never expand `~`, so on the rare
//! box where `dirs` can't resolve a home directory (no `HOME` env var, e.g.
//! some container/systemd-service contexts) the fallback silently resolves
//! to a directory literally named `~` in the process's current working
//! directory instead of the user's actual home. Confirmed present in:
//! - `breadclip-core/src/lib.rs:171-175` (`data_dir`)
//! - `breadpad-shared/src/classifier.rs:34-39` (`model_dir`)
//! - `breadpad-shared/src/config.rs:214-219` and `:221-226`
//! (`config_path`, `style_css_path`)
//! - `breadmon/src/profile.rs:31-35` (`profiles_dir`)
//! - `breadarr-shared/src/config.rs:316-321`'s own `expand_home` helper,
//! which had the same bug in a different shape: its *own* fallback (when
//! `HOME` itself isn't set) returned the literal, unexpanded input string
//! rather than a real path.
//!
//! The helpers here resolve a real `$HOME` (via `dirs::home_dir()`, which
//! itself falls back to reading `HOME` directly) before ever falling back,
//! so the fallback path is always an absolute, expanded path.
use std::path::PathBuf;
/// A real, absolute home directory — `dirs::home_dir()`, falling back to
/// `/root` only if that itself fails (no `HOME` env var *and* no passwd-db
/// entry, e.g. some minimal container contexts). Never a literal `"~"`.
pub fn home_dir() -> PathBuf {
home_or_root()
}
fn home_or_root() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/root"))
}
/// `$XDG_CONFIG_HOME` (only if it's set to an absolute path) or `~/.config`,
/// joined with `app`.
pub fn config_dir(app: &str) -> PathBuf {
config_home().join(app)
}
/// The bare `$XDG_CONFIG_HOME` (or `~/.config`) directory, with no app name
/// joined on — for callers that build up multiple sub-paths themselves
/// (e.g. `bos-settings`, which joins a different bread* app's name per
/// config file it edits).
pub fn config_home() -> PathBuf {
base_config_dir()
}
/// `$XDG_DATA_HOME` (only if absolute) or `~/.local/share`, joined with `app`.
pub fn data_dir(app: &str) -> PathBuf {
dirs::data_local_dir()
.unwrap_or_else(|| home_or_root().join(".local/share"))
.join(app)
}
/// `$XDG_CACHE_HOME` (only if absolute) or `~/.cache`, joined with `app`.
pub fn cache_dir(app: &str) -> PathBuf {
dirs::cache_dir()
.unwrap_or_else(|| home_or_root().join(".cache"))
.join(app)
}
/// `$XDG_RUNTIME_DIR`, falling back to `/tmp` — matches the fallback every
/// consumer (breadbox, breadclip, breadmon) already used for PID/socket
/// scratch files, which don't need to survive a reboot.
pub fn runtime_dir() -> PathBuf {
std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/tmp"))
}
fn base_config_dir() -> PathBuf {
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
let p = PathBuf::from(xdg);
if p.is_absolute() {
return p;
}
}
dirs::config_dir().unwrap_or_else(|| home_or_root().join(".config"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_dir_joins_app_name() {
let d = config_dir("breadpad");
assert!(d.ends_with("breadpad"));
assert!(d.is_absolute());
}
#[test]
fn home_dir_is_absolute_and_never_a_literal_tilde() {
let d = home_dir();
assert!(d.is_absolute());
assert!(!d.components().any(|c| c.as_os_str() == "~"));
}
#[test]
fn data_dir_never_contains_literal_tilde() {
// Regression guard for the exact bug this module replaces: the
// fallback must never be a literal "~/..." path component.
let d = data_dir("breadclip");
assert!(!d.components().any(|c| c.as_os_str() == "~"));
assert!(d.is_absolute());
}
#[test]
fn cache_dir_is_absolute() {
assert!(cache_dir("breadsearch").is_absolute());
}
#[test]
fn runtime_dir_falls_back_to_tmp() {
let _lock = crate::env_test_lock().lock().unwrap_or_else(|e| e.into_inner());
// We don't unset XDG_RUNTIME_DIR here (test isolation), just confirm
// the function returns *something* absolute either way.
assert!(runtime_dir().is_absolute());
}
}

133
docs/release-channels.md Normal file
View file

@ -0,0 +1,133 @@
# Release channel policy
There are two independent distribution channels in the bread ecosystem, plus
a third "neither" state for repos that aren't distributed yet. Every repo
under `Breadway/` should sit in exactly one of these three buckets, and its
`.forgejo/workflows/` directory + packaging metadata should match that
bucket exactly — no more files, no fewer.
## The two channels
**bakery channel** (`bakery install <name>`, `curl .../get | sh`, or a raw
binary download from dl.breadway.dev / the GitHub release page). A repo is on
this channel if and only if **all** of the following are true:
1. It has a `bakery.toml` at the root (or, for a multi-product repo like
bread-ecosystem, one per product directory).
2. It has an entry in `bread-ecosystem`'s `registry/bread-ecosystem.toml`.
`scripts/gen-index.sh` only ever looks at repos listed there — a
`bakery.toml` that isn't backed by a registry entry is inert.
3. It has a `.forgejo/workflows/release.yml` (or a product-specific name
like `release-bread-theme.yml` / `release-bakery.yml` for multi-product
repos) that builds the binary, drops it under `/srv/breadway-dl/<name>/`,
copies `bakery.toml` alongside it, regenerates `index.json` via
`bread-ecosystem/scripts/gen-index.sh`, and uploads the same artifacts to
a GitHub release as a fallback mirror.
All three must be present together. Two out of three is a bug, not a
partial rollout — either finish the third piece or remove the other two.
**pacman channel** (`pacman -S <name>` from the self-hosted `[breadway]`
repo, built via AUR-style `PKGBUILD`s). A repo is on this channel if and
only if:
1. It has a `PKGBUILD` under `packaging/` (either `packaging/PKGBUILD` or
`packaging/arch/PKGBUILD` — both patterns exist in the wild, pick
whichever a sibling repo of the same shape already uses).
2. It has a `.forgejo/workflows/package.yml` that builds the package in an
`archlinux:latest` container and `curl -X PUT`s the resulting
`.pkg.tar.zst` to `https://git.breadway.dev/api/packages/Breadway/arch/os`.
A repo can be on **both** channels (most GUI/daemon apps are — see
breadbar, breadbox, breadcrumbs, bread, breadpad, breadpaper), **bakery
only** (breadclip, breadmon, breadsearch, breadshot, bread-theme, bakery
itself), **pacman only** (breadlock, breadhelp — both are OS-integration
pieces where package-manager rigor matters more than a curl-script), or
**neither** (dev-only / not yet released; no bakery.toml, no PKGBUILD, no
release or package workflow — just the repo itself, e.g. breadarr today).
`bos` is a fourth, deliberately special case: it ships as an ISO, not a
binary, via its own `release-iso.yml`. It is never on either channel and
should never carry a `bakery.toml` or `PKGBUILD`.
## 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/<pkg>/<ver>/` | push tag `v*` |
| beta | `dl.breadway.dev/beta/index.json` | `/srv/breadway-dl/beta/<pkg>/<ver>/` | push tag `beta-v*` |
| dev | `dl.breadway.dev/dev/index.json` | `/srv/breadway-dl/dev/<pkg>/<ver>/` | 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 <stable|beta|dev>`
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
a `git clone --mirror` + push to GitHub with a per-repo `MIRROR_TOKEN`
secret. That pattern is being replaced ecosystem-wide by Forgejo's native
Push Mirror feature, provisioned centrally by
`bread-ecosystem/scripts/setup-push-mirrors.sh` against the live repo list
— see that script and `scripts/cleanup-old-mirror-workflows.sh`. Once the
migration is confirmed working, no repo should have a `mirror.yml` and this
document doesn't require one. Don't add `mirror.yml` to a repo that's
missing it; that gap is intentional and about to be moot everywhere.
## Checklist for adding a repo to a channel
- **Bakery**: write `bakery.toml`, add a `[[products]]` entry to
`bread-ecosystem/registry/bread-ecosystem.toml`, copy a sibling's
`release.yml` (prefer one with the same shape: single binary vs. binary +
systemd service — compare against `bread/release.yml` if there's a
service to install, `breadmon/release.yml` if not) and swap the repo
name / binary name / `PKG_DIR`.
- **Pacman**: write `packaging/PKGBUILD` (or `packaging/arch/PKGBUILD`),
copy a sibling's `package.yml` and swap the repo/package name and
`system_deps``pacman -Syu` package list.
- Never add either file type "just in case." An unused `bakery.toml` or
`PKGBUILD` is exactly the kind of drift this document exists to prevent
(see the breadlock/breadarr/bos-settings history in the audit that
produced this doc — two of those had a stray `bakery.toml` nothing
served, one was missing the registry entry + release.yml that would have
made an existing `bakery.toml` real).
## Current state (as of this pass)
| Repo | bakery | pacman | 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 |

View file

@ -1,11 +1,11 @@
# Maintainer: Breadway <rileyhorsham@gmail.com>
# Maintainer: Breadway <plasticbread849@gmail.com>
pkgname=bakery
pkgver=0.2.3
pkgrel=1
pkgdesc="Package manager for the bread ecosystem"
arch=('x86_64')
url="https://github.com/Breadway/bread-ecosystem"
url="https://git.breadway.dev/Breadway/bread-ecosystem"
license=('MIT')
# Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's
# default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read,

View file

@ -12,6 +12,11 @@ name = "bakery"
repo = "Breadway/bread-ecosystem"
description = "Bread ecosystem package manager"
[[products]]
name = "bread-theme"
repo = "Breadway/bread-ecosystem"
description = "Shared pywal-accented, fixed-dark-base theming CLI for the bread ecosystem"
[[products]]
name = "bread"
repo = "Breadway/bread"
@ -41,3 +46,28 @@ description = "Quick-capture scratchpad and note viewer with AI classification"
name = "breadpaper"
repo = "Breadway/breadpaper"
description = "Wallpaper manager for the bread desktop"
[[products]]
name = "breadmon"
repo = "Breadway/breadmon"
description = "Terminal UI monitor manager for Hyprland"
[[products]]
name = "breadsearch"
repo = "Breadway/breadsearch"
description = "Semantic system-wide search for BOS"
[[products]]
name = "breadclip"
repo = "Breadway/breadclip"
description = "Wayland clipboard history manager for Hyprland"
[[products]]
name = "breadshot"
repo = "Breadway/breadshot"
description = "Screenshot utility for the bread ecosystem"
[[products]]
name = "bos-settings"
repo = "Breadway/bos-settings"
description = "System settings app for Bread OS"

View file

@ -0,0 +1,190 @@
#!/usr/bin/env bash
# cleanup-old-mirror-workflows.sh — retire the per-repo GitHub mirroring
# pattern now that Forgejo native Push Mirrors (see setup-push-mirrors.sh)
# do the same job centrally.
#
# THIS SCRIPT IS DESTRUCTIVE AND TOUCHES LIVE, RUNNING INFRASTRUCTURE:
# 1. Deletes .forgejo/workflows/mirror.yml from the DEFAULT BRANCH of every
# repo returned by the Forgejo API that has one (via the contents API —
# this is a real commit to each repo's default branch, not a local/
# worktree change).
# 2. Deletes the MIRROR_TOKEN Actions secret from every repo that has one.
#
# Do not run this until you have confirmed, for real, that push mirrors
# created by setup-push-mirrors.sh are actually syncing to GitHub (check
# a repo's Settings > Push Mirrors in the Forgejo web UI, or GET
# /repos/{owner}/{repo}/push_mirrors and look at last_update / last_error,
# and confirm commits are actually landing on the GitHub side). Until then,
# removing mirror.yml would silently kill the only thing currently keeping
# GitHub in sync.
#
# As a guardrail, this script refuses to do anything unless invoked with
# --i-have-verified-push-mirrors-work. There is no way around that flag
# short of editing this script, which is the point.
#
# Requires: bash, curl, jq
#
# Reads the same token file as setup-push-mirrors.sh:
# FORGEJO_TOKEN_FILE default ~/.config/forgejo/token
#
# Env vars:
# FORGEJO_BASE https://git.breadway.dev
# FORGEJO_OWNER Breadway
#
# Flags:
# --i-have-verified-push-mirrors-work required, see above
# --dry-run print what would be deleted, make
# no changes (combine with the
# confirmation flag or this refuses
# to run at all — even dry-run mode
# is gated, so nobody can quietly
# drop the guardrail out of the
# invocation by force of habit)
# --only repo1,repo2 comma-separated allowlist
#
# Usage (once verified):
# scripts/cleanup-old-mirror-workflows.sh --i-have-verified-push-mirrors-work --dry-run
# scripts/cleanup-old-mirror-workflows.sh --i-have-verified-push-mirrors-work
set -euo pipefail
FORGEJO_BASE="${FORGEJO_BASE:-https://git.breadway.dev}"
FORGEJO_OWNER="${FORGEJO_OWNER:-Breadway}"
FORGEJO_TOKEN_FILE="${FORGEJO_TOKEN_FILE:-${HOME}/.config/forgejo/token}"
CONFIRMED=0
DRY_RUN=0
ONLY_REPOS=""
while [[ $# -gt 0 ]]; do
case "$1" in
--i-have-verified-push-mirrors-work) CONFIRMED=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
--only) ONLY_REPOS="$2"; shift 2 ;;
-h|--help)
sed -n '2,42p' "$0"
exit 0
;;
*)
echo "error: unknown argument: $1" >&2
exit 2
;;
esac
done
if [[ "${CONFIRMED}" != 1 ]]; then
cat >&2 <<'EOF'
error: refusing to run.
This script deletes mirror.yml from the default branch of every mirrored
repo and removes the MIRROR_TOKEN secret. That's a real, immediate change
to production CI on every one of those repos, and it also permanently
disables the *old* mirroring path.
Before running this:
1. Run setup-push-mirrors.sh for real (not --dry-run).
2. Confirm, for at least one repo, that the push mirror actually synced
(Forgejo web UI: repo Settings > Push Mirrors > check "Last Update"
and that there's no "Last Error"; and check the GitHub side directly).
3. Only then re-run this script with:
--i-have-verified-push-mirrors-work
Add --dry-run (in addition to the flag above) to preview without changing
anything.
EOF
exit 1
fi
for bin in curl jq; do
command -v "${bin}" >/dev/null 2>&1 || { echo "error: ${bin} is required" >&2; exit 2; }
done
[[ -f "${FORGEJO_TOKEN_FILE}" ]] || { echo "error: Forgejo token file not found at ${FORGEJO_TOKEN_FILE}" >&2; exit 2; }
FORGEJO_TOKEN="$(<"${FORGEJO_TOKEN_FILE}")"
api() {
# api METHOD PATH [JSON_BODY] -> prints response body, exits nonzero on HTTP error
local method="$1" path="$2" body="${3:-}"
if [[ -n "${body}" ]]; then
curl -fsS -X "${method}" \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \
-d "${body}" \
"${FORGEJO_BASE}/api/v1${path}"
else
curl -fsS -X "${method}" \
-H "Authorization: token ${FORGEJO_TOKEN}" \
"${FORGEJO_BASE}/api/v1${path}"
fi
}
owner_kind="org"
if ! curl -fsS -o /dev/null -H "Authorization: token ${FORGEJO_TOKEN}" \
"${FORGEJO_BASE}/api/v1/orgs/${FORGEJO_OWNER}" 2>/dev/null; then
owner_kind="user"
fi
if [[ "${owner_kind}" == "org" ]]; then
repos_json="$(api GET "/orgs/${FORGEJO_OWNER}/repos?limit=50")"
else
repos_json="$(api GET "/users/${FORGEJO_OWNER}/repos?limit=50")"
fi
mapfile -t repo_names < <(echo "${repos_json}" | jq -r '.[].name')
if [[ "${DRY_RUN}" == 1 ]]; then
echo "# --dry-run: no deletions will be made"
fi
echo
for name in "${repo_names[@]}"; do
if [[ -n "${ONLY_REPOS}" ]]; then
IFS=',' read -ra allow <<< "${ONLY_REPOS}"
match=0
for a in "${allow[@]}"; do [[ "${a}" == "${name}" ]] && match=1; done
[[ "${match}" == 1 ]] || continue
fi
default_branch="$(echo "${repos_json}" | jq -r --arg n "${name}" '.[] | select(.name==$n) | .default_branch')"
# Contents API: GET returns the file's sha, which the DELETE call needs.
file_info="$(curl -fsS -o /tmp/cleanup_probe.json -w '%{http_code}' \
-H "Authorization: token ${FORGEJO_TOKEN}" \
"${FORGEJO_BASE}/api/v1/repos/${FORGEJO_OWNER}/${name}/contents/.forgejo/workflows/mirror.yml?ref=${default_branch}" || true)"
if [[ "${file_info}" == "200" ]]; then
sha="$(jq -r '.sha' /tmp/cleanup_probe.json)"
if [[ "${DRY_RUN}" == 1 ]]; then
echo "WOULD-DELETE ${name}: .forgejo/workflows/mirror.yml (sha ${sha}) from ${default_branch}"
else
echo "DELETE ${name}: .forgejo/workflows/mirror.yml from ${default_branch}"
del_body="$(jq -n --arg msg "ci: remove mirror.yml, superseded by native push mirror" \
--arg sha "${sha}" --arg branch "${default_branch}" \
'{message: $msg, sha: $sha, branch: $branch}')"
api DELETE "/repos/${FORGEJO_OWNER}/${name}/contents/.forgejo/workflows/mirror.yml" "${del_body}" >/dev/null
fi
else
echo "SKIP ${name}: no .forgejo/workflows/mirror.yml on ${default_branch}"
fi
secret_check="$(curl -fsS -o /dev/null -w '%{http_code}' \
-H "Authorization: token ${FORGEJO_TOKEN}" \
"${FORGEJO_BASE}/api/v1/repos/${FORGEJO_OWNER}/${name}/actions/secrets" || true)"
has_mirror_token="$(curl -fsS -H "Authorization: token ${FORGEJO_TOKEN}" \
"${FORGEJO_BASE}/api/v1/repos/${FORGEJO_OWNER}/${name}/actions/secrets" \
| jq -r '[.[] | select(.name=="MIRROR_TOKEN")] | length')"
if [[ "${has_mirror_token}" -gt 0 ]]; then
if [[ "${DRY_RUN}" == 1 ]]; then
echo "WOULD-DELETE ${name}: MIRROR_TOKEN secret"
else
echo "DELETE ${name}: MIRROR_TOKEN secret"
curl -fsS -X DELETE -H "Authorization: token ${FORGEJO_TOKEN}" \
"${FORGEJO_BASE}/api/v1/repos/${FORGEJO_OWNER}/${name}/actions/secrets/MIRROR_TOKEN" >/dev/null
fi
else
echo "SKIP ${name}: no MIRROR_TOKEN secret"
fi
done
rm -f /tmp/cleanup_probe.json

133
scripts/doctor-channels.sh Executable file
View file

@ -0,0 +1,133 @@
#!/usr/bin/env bash
# doctor-channels.sh — detect drift between a repo's declared distribution
# channel(s) and its actual .forgejo/workflows/ + packaging metadata.
#
# See docs/release-channels.md for the policy this checks against.
#
# Usage:
# scripts/doctor-channels.sh [BASE_DIR]
#
# BASE_DIR defaults to the parent of this repo checkout (i.e. run from a
# normal ~/Projects/bread-ecosystem checkout, it scans sibling ~/Projects/*
# repos). Point it at a directory of worktrees (e.g. ~/Projects, which is
# also where *-fix-worktree checkouts live) to check those instead:
#
# scripts/doctor-channels.sh ~/Projects
#
# Exits 0 if no drift found, 1 if any repo has drift (so it's CI-friendly).
#
# Requires: python3 (tomllib, stdlib since 3.11)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BASE_DIR="${1:-$(dirname "${SCRIPT_DIR}")}"
REGISTRY="${SCRIPT_DIR}/registry/bread-ecosystem.toml"
if [[ ! -f "${REGISTRY}" ]]; then
echo "error: registry not found at ${REGISTRY}" >&2
exit 2
fi
# repo (last path segment of registry `repo = "Breadway/x"`) -> 1
mapfile -t registry_repos < <(python3 -c "
import tomllib
with open('${REGISTRY}', 'rb') as f:
d = tomllib.load(f)
for p in d['products']:
print(p['repo'].split('/')[-1])
")
is_in_registry() {
local name="$1"
for r in "${registry_repos[@]}"; do
[[ "${r}" == "${name}" ]] && return 0
done
return 1
}
# Repos with a deliberately non-standard packaging shape that the
# single-PKGBUILD/single-package.yml heuristic below doesn't fit. Extend
# this if another repo grows a legitimately special-cased layout.
PACKAGE_CHECK_EXEMPT=("bos") # ships an ISO via release-iso.yml; its PKGBUILDs
# under packaging/*/ build bundled AUR deps
# (bibata, calamares, ...), each with its own
# dedicated workflow — not a pacman-channel package.
is_package_check_exempt() {
local name="$1"
for r in "${PACKAGE_CHECK_EXEMPT[@]}"; do
[[ "${r}" == "${name}" ]] && return 0
done
return 1
}
drift=0
checked=0
for dir in "${BASE_DIR}"/*/; do
name="$(basename "${dir}")"
name="${name%-fix-worktree}" # normalize worktree checkouts back to the repo name
[[ -d "${dir}/.git" || -f "${dir}/.git" ]] || continue
# Skip bread-ecosystem itself — it's a multi-product repo the registry
# membership check above doesn't map 1:1, and it's already reviewed by
# hand above (bakery + bread-theme products).
[[ "${name}" == "bread-ecosystem" ]] && continue
checked=$((checked + 1))
has_bakery_toml=0
[[ -f "${dir}/bakery.toml" ]] && has_bakery_toml=1
has_release_wf=0
compgen -G "${dir}/.forgejo/workflows/release*.yml" >/dev/null 2>&1 && has_release_wf=1
in_registry=0
is_in_registry "${name}" && in_registry=1
has_pkgbuild=0
find "${dir}" -maxdepth 3 -iname 'PKGBUILD' -not -path '*/.git/*' 2>/dev/null \
| grep -q . && has_pkgbuild=1
has_package_wf=0
[[ -f "${dir}/.forgejo/workflows/package.yml" ]] && has_package_wf=1
issues=()
if [[ "${has_bakery_toml}" == 1 && "${in_registry}" == 0 ]]; then
issues+=("has bakery.toml but no registry/bread-ecosystem.toml entry")
fi
if [[ "${in_registry}" == 1 && "${has_bakery_toml}" == 0 ]]; then
issues+=("registered in bread-ecosystem.toml but has no bakery.toml")
fi
if [[ "${in_registry}" == 1 && "${has_release_wf}" == 0 ]]; then
issues+=("registered + has bakery.toml but no release*.yml workflow")
fi
if [[ "${has_bakery_toml}" == 1 && "${in_registry}" == 0 && "${has_release_wf}" == 1 ]]; then
issues+=("has a release workflow for a product not in the registry (index.json will never include it)")
fi
if ! is_package_check_exempt "${name}"; then
if [[ "${has_pkgbuild}" == 1 && "${has_package_wf}" == 0 ]]; then
issues+=("has a PKGBUILD but no package.yml workflow")
fi
if [[ "${has_package_wf}" == 1 && "${has_pkgbuild}" == 0 ]]; then
issues+=("has package.yml but no PKGBUILD")
fi
fi
if [[ ${#issues[@]} -gt 0 ]]; then
drift=1
echo "${name}:"
for i in "${issues[@]}"; do
echo " - ${i}"
done
fi
done
echo
echo "checked ${checked} repos under ${BASE_DIR}"
if [[ "${drift}" == 0 ]]; then
echo "no channel drift found"
else
echo "drift found — see docs/release-channels.md for the policy"
fi
exit "${drift}"

View file

@ -1,19 +1,38 @@
#!/usr/bin/env bash
# Generate dl.breadway.dev/index.json from:
# - registry/bread-ecosystem.toml (product list)
# - <DL_DIR>/<name>/bakery.toml (per-product metadata, uploaded by release.yml)
# - <DL_DIR>/ (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)
# - <PKG_ROOT>/<name>/bakery.toml (per-product metadata, uploaded by release.yml)
# - <PKG_ROOT>/ (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/<track>/ 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/<name>/
local pkg_dir="${DL_DIR}/${name}"
# Find the latest version dir under PKG_ROOT/<name>/
local pkg_dir="${PKG_ROOT}/${name}"
if [[ ! -d "${pkg_dir}" ]]; then
echo " warning: no release dir for ${name} at ${pkg_dir}" >&2
return 1
@ -56,6 +75,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}")"
@ -64,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 \
@ -85,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
@ -119,8 +148,12 @@ with open('${bakery_toml}', 'rb') as f:
print(json.dumps(d.get('bread_deps', [])))
" 2>/dev/null || echo "[]")"
# [[service]] entries → [{unit, enable}]
services="$(python3 -c "
# [[service]] entries → [{unit, enable, sha256}]. sha256 comes from the
# actual unit file shipped in this version dir — the same
# artifact-integrity guarantee binaries already get. A missing unit file
# gets an empty sha256; install.rs refuses to install an unverified
# download rather than silently skipping the check.
service_units="$(python3 -c "
import tomllib, json
with open('${bakery_toml}', 'rb') as f:
d = tomllib.load(f)
@ -128,7 +161,24 @@ svcs = d.get('service', [])
print(json.dumps([{'unit': s['unit'], 'enable': s.get('enable', False)} for s in svcs]))
" 2>/dev/null || echo "[]")"
# [config] → {dir, example?} or null
services="[]"
while IFS= read -r svc_entry; do
[[ -z "${svc_entry}" ]] && continue
unit_name="$(echo "${svc_entry}" | jq -r '.unit')"
enable="$(echo "${svc_entry}" | jq -r '.enable')"
unit_path="${version_dir}/${unit_name}"
unit_sha256=""
if [[ -f "${unit_path}" ]]; then
unit_sha256="$(sha256sum "${unit_path}" | awk '{print $1}')"
else
echo " warning: service unit '${unit_name}' not found at ${unit_path}" >&2
fi
svc_json="$(jq -n --arg unit "${unit_name}" --argjson enable "${enable}" --arg sha256 "${unit_sha256}" \
'{unit: $unit, enable: $enable, sha256: $sha256}')"
services="$(jq -n --argjson arr "${services}" --argjson e "${svc_json}" '$arr + [$e]')"
done < <(echo "${service_units}" | jq -c '.[]')
# [config] → {dir, example?, example_sha256?} or null
config="$(python3 -c "
import tomllib, json
with open('${bakery_toml}', 'rb') as f:
@ -142,6 +192,19 @@ if cfg:
else:
print('null')
" 2>/dev/null || echo "null")"
if [[ "${config}" != "null" ]]; then
example_name="$(echo "${config}" | jq -r '.example // empty')"
if [[ -n "${example_name}" ]]; then
example_path="${version_dir}/${example_name}"
example_sha256=""
if [[ -f "${example_path}" ]]; then
example_sha256="$(sha256sum "${example_path}" | awk '{print $1}')"
else
echo " warning: config.example '${example_name}' not found at ${example_path}" >&2
fi
config="$(echo "${config}" | jq -c --arg sha "${example_sha256}" '. + {example_sha256: $sha}')"
fi
fi
post_install="$(python3 -c "
import tomllib, json
@ -194,3 +257,42 @@ jq -n \
> "${OUT}"
echo "wrote ${OUT}"
# Sign the index so `bakery` can verify it before trusting a single byte.
# Every artifact sha256 and post_install hook string lives inside index.json,
# so a valid signature over these raw bytes transitively covers all of it —
# no separate per-artifact signing is needed.
#
# MINISIGN_SEC_KEY must point at the *secret* key file generated with
# `minisign -G`. It is intentionally never read from inside either git repo;
# point it at wherever the key actually lives on the machine that runs this
# script (e.g. a root-only path on hestia), and set MINISIGN_SEC_KEY_PASSWORD
# too if the key was generated with a password.
#
# This step is a no-op (with a loud warning) if the key isn't configured, so
# existing unsigned publishing flows don't break until the key is actually
# wired up — see the handoff note in the fix commit for this repo.
if [[ -n "${MINISIGN_SEC_KEY:-}" ]]; then
if [[ ! -f "${MINISIGN_SEC_KEY}" ]]; then
echo "ERROR: MINISIGN_SEC_KEY=${MINISIGN_SEC_KEY} does not exist" >&2
exit 1
fi
if ! command -v minisign >/dev/null 2>&1; then
echo "ERROR: MINISIGN_SEC_KEY is set but the 'minisign' binary is not installed" >&2
exit 1
fi
sign_args=(-S -s "${MINISIGN_SEC_KEY}" -m "${OUT}" -x "${OUT}.minisig")
if [[ -n "${MINISIGN_SEC_KEY_PASSWORD:-}" ]]; then
MINISIGN_PASSWORD="${MINISIGN_SEC_KEY_PASSWORD}" minisign "${sign_args[@]}" </dev/null
else
# -W: the key has no password (matches how CI-facing signing keys are
# normally generated, since there's no human to type a passphrase).
minisign -W "${sign_args[@]}" </dev/null
fi
echo "signed ${OUT} -> ${OUT}.minisig"
else
echo "WARNING: MINISIGN_SEC_KEY not set — index.json was NOT signed." >&2
echo " bakery clients built with signature verification will reject" >&2
echo " this index. Set MINISIGN_SEC_KEY before running this in" >&2
echo " production once the signing key has been provisioned." >&2
fi

View file

@ -4,6 +4,13 @@
# Or: curl -sSfL https://breadway.dev/get | sh
set -eu
# Pinned minisign public key for the bakery release binary. Matches the
# PUBKEY constant in bakery/src/manifest.rs (same keypair signs both
# index.json and the bakery binary itself). Do not source this from the
# network — it must be baked into this script so a compromised dl server
# can't swap it out along with a malicious binary.
BAKERY_MINISIGN_PUBKEY="RWTBR8w/IJ+jaylOv80b52DzekKbSR2CvOVGvzB0ipGBaMhJPAOiEWq8"
BAKERY_VERSION="${BAKERY_VERSION:-latest}"
BIN_DIR="${BAKERY_BIN_DIR:-$HOME/.local/bin}"
@ -19,12 +26,16 @@ if [ "${BAKERY_VERSION}" = "latest" ]; then
DL_PRIMARY="https://dl.breadway.dev/bakery/latest/bakery-x86_64"
DL_FALLBACK="https://github.com/Breadway/bread-ecosystem/releases/latest/download/bakery-x86_64"
SHA256_URL="https://dl.breadway.dev/bakery/latest/bakery-x86_64.sha256"
SIG_URL="https://dl.breadway.dev/bakery/latest/bakery-x86_64.minisig"
SIG_FALLBACK="https://github.com/Breadway/bread-ecosystem/releases/latest/download/bakery-x86_64.minisig"
else
# Strip a leading 'v' if the caller included it, then add it back consistently.
ver="${BAKERY_VERSION#v}"
DL_PRIMARY="https://dl.breadway.dev/bakery/${ver}/bakery-x86_64"
DL_FALLBACK="https://github.com/Breadway/bread-ecosystem/releases/download/v${ver}/bakery-x86_64"
SHA256_URL="https://dl.breadway.dev/bakery/${ver}/bakery-x86_64.sha256"
SIG_URL="https://dl.breadway.dev/bakery/${ver}/bakery-x86_64.minisig"
SIG_FALLBACK="https://github.com/Breadway/bread-ecosystem/releases/download/v${ver}/bakery-x86_64.minisig"
fi
# Pick a download tool.
@ -38,30 +49,63 @@ fi
mkdir -p "${BIN_DIR}"
TMP="$(mktemp)"
trap 'rm -f "${TMP}" "${TMP}.sha256"' EXIT
trap 'rm -f "${TMP}" "${TMP}.sha256" "${TMP}.minisig"' EXIT
echo "downloading bakery…"
if fetch "${DL_PRIMARY}" "${TMP}" 2>/dev/null; then
echo " from dl.breadway.dev"
# Verify checksum when available from primary.
if fetch "${SHA256_URL}" "${TMP}.sha256" 2>/dev/null; then
expected="$(awk '{print $1}' "${TMP}.sha256")"
actual="$(sha256sum "${TMP}" | awk '{print $1}')"
if [ "${expected}" != "${actual}" ]; then
die "SHA-256 checksum mismatch (expected ${expected}, got ${actual})"
fi
echo " checksum verified"
else
echo " warning: could not fetch checksum — skipping verification"
fi
sig_url="${SIG_URL}"
checksum_only_fallback_note=" warning: could not fetch checksum — skipping verification"
elif fetch "${DL_FALLBACK}" "${TMP}" 2>/dev/null; then
echo " from GitHub (fallback)"
# No .sha256 on the GitHub fallback path; proceed without verification.
echo " warning: checksum not verified for GitHub fallback download"
sig_url="${SIG_FALLBACK}"
checksum_only_fallback_note=" warning: no checksum available for GitHub fallback download"
else
die "failed to download bakery from both primary and fallback URLs"
fi
# Signature verification is the authoritative check: it proves the binary
# was produced by whoever holds the bakery signing key, not just that bytes
# match whatever the same (possibly compromised) server also reports as the
# checksum. Prefer it whenever both a .minisig is published and a minisign
# verifier is available on this machine.
sig_verified=0
if fetch "${sig_url}" "${TMP}.minisig" 2>/dev/null; then
if command -v minisign >/dev/null 2>&1; then
if minisign -V -q -m "${TMP}" -x "${TMP}.minisig" -P "${BAKERY_MINISIGN_PUBKEY}"; then
echo " signature verified (minisign)"
sig_verified=1
else
die "minisign signature verification FAILED — refusing to install a binary that doesn't match the pinned bakery key"
fi
else
echo " warning: 'minisign' is not installed — cannot verify the binary's" >&2
echo " warning: signature, only its checksum. Install minisign for the" >&2
echo " warning: strongest guarantee: pacman -S minisign / apt install minisign" >&2
fi
else
echo " warning: no .minisig published for this release yet — signature not verified" >&2
fi
# Checksum is a secondary, best-effort check (kept for defense in depth and
# for the case where minisign isn't installed). It is not a substitute for
# signature verification: both the binary and its checksum typically come
# from the same server, so a compromised server can serve a matching pair.
if fetch "${SHA256_URL}" "${TMP}.sha256" 2>/dev/null; then
expected="$(awk '{print $1}' "${TMP}.sha256")"
actual="$(sha256sum "${TMP}" | awk '{print $1}')"
if [ "${expected}" != "${actual}" ]; then
die "SHA-256 checksum mismatch (expected ${expected}, got ${actual})"
fi
echo " checksum verified"
else
echo "${checksum_only_fallback_note}"
fi
if [ "${sig_verified}" -ne 1 ]; then
echo " warning: proceeding WITHOUT a verified signature on the bakery binary" >&2
fi
chmod +x "${TMP}"
cp "${TMP}" "${BIN_DIR}/bakery"
echo "installed bakery to ${BIN_DIR}/bakery"

201
scripts/setup-push-mirrors.sh Executable file
View file

@ -0,0 +1,201 @@
#!/usr/bin/env bash
# setup-push-mirrors.sh — provision Forgejo native Push Mirrors to GitHub for
# every repo under the Breadway account, replacing the old per-repo
# .forgejo/workflows/mirror.yml + MIRROR_TOKEN pattern.
#
# THIS SCRIPT MUTATES LIVE FORGEJO STATE WHEN RUN WITHOUT --dry-run.
# Always run with --dry-run first and review the output before running for
# real. Nothing in this script deletes anything — see the separate
# cleanup-old-mirror-workflows.sh for removing the old mirror.yml files,
# which should only be run after confirming push mirrors are syncing.
#
# What it does, per repo returned by the Forgejo API:
# 1. GET /repos/{owner}/{repo}/push_mirrors — list existing push mirrors
# 2. If one already targets https://github.com/<gh-owner>/<repo>.git, skip
# (idempotent — safe to re-run).
# 3. Otherwise POST /repos/{owner}/{repo}/push_mirrors to create one, with
# sync_on_commit=true and a periodic interval as belt-and-suspenders.
#
# Requires: bash, curl, jq
#
# Reads (never prints the contents of either):
# - A Forgejo API token from FORGEJO_TOKEN_FILE (default:
# ~/.config/forgejo/token). Needs at least write access to repository
# settings for every repo under the target account.
# - A GitHub PAT from a dotenv-style `GH_TOKEN=...` line in
# MIRROR_ENV_FILE (default: ~/.config/bread/mirror.env). The token needs
# `repo` scope (classic PAT) or Contents: Read & Write (fine-grained) on
# every target GitHub repo, since it's what actually pushes commits.
#
# Env vars (all optional, shown with defaults):
# FORGEJO_BASE https://git.breadway.dev
# FORGEJO_OWNER Breadway # Forgejo account that owns the repos
# GITHUB_OWNER same as FORGEJO_OWNER # GitHub account/org to mirror into
# FORGEJO_TOKEN_FILE ~/.config/forgejo/token
# MIRROR_ENV_FILE ~/.config/bread/mirror.env
# SYNC_INTERVAL 8h0m0s # Forgejo duration string; periodic resync
# # on top of sync_on_commit
#
# Flags:
# --dry-run Print every GET/POST this script would make
# (including full request bodies except the
# GitHub token, which is redacted) without
# actually issuing any POST. GETs (listing repos,
# listing existing push mirrors) always happen —
# they're read-only and needed to print accurate
# dry-run output.
# --include-private By default, private Forgejo repos are SKIPPED
# and reported, not mirrored — pushing a private
# repo's history to a public GitHub repo is a
# one-way disclosure decision this script should
# never make silently. Pass this flag to include
# them anyway, after you've confirmed the target
# GitHub repo is also private (this script does
# not create or check GitHub-side repos or their
# visibility).
# --only repo1,repo2 Comma-separated allowlist of repo names.
# Default: every repo the Forgejo API returns.
#
# Usage:
# scripts/setup-push-mirrors.sh --dry-run
# scripts/setup-push-mirrors.sh --dry-run --include-private
# scripts/setup-push-mirrors.sh # the real thing
set -euo pipefail
FORGEJO_BASE="${FORGEJO_BASE:-https://git.breadway.dev}"
FORGEJO_OWNER="${FORGEJO_OWNER:-Breadway}"
GITHUB_OWNER="${GITHUB_OWNER:-${FORGEJO_OWNER}}"
FORGEJO_TOKEN_FILE="${FORGEJO_TOKEN_FILE:-${HOME}/.config/forgejo/token}"
MIRROR_ENV_FILE="${MIRROR_ENV_FILE:-${HOME}/.config/bread/mirror.env}"
SYNC_INTERVAL="${SYNC_INTERVAL:-8h0m0s}"
DRY_RUN=0
INCLUDE_PRIVATE=0
ONLY_REPOS=""
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
--include-private) INCLUDE_PRIVATE=1; shift ;;
--only) ONLY_REPOS="$2"; shift 2 ;;
-h|--help)
sed -n '2,55p' "$0"
exit 0
;;
*)
echo "error: unknown argument: $1" >&2
exit 2
;;
esac
done
for bin in curl jq; do
command -v "${bin}" >/dev/null 2>&1 || { echo "error: ${bin} is required" >&2; exit 2; }
done
[[ -f "${FORGEJO_TOKEN_FILE}" ]] || { echo "error: Forgejo token file not found at ${FORGEJO_TOKEN_FILE}" >&2; exit 2; }
[[ -f "${MIRROR_ENV_FILE}" ]] || { echo "error: mirror env file not found at ${MIRROR_ENV_FILE}" >&2; exit 2; }
FORGEJO_TOKEN="$(<"${FORGEJO_TOKEN_FILE}")"
GH_TOKEN="$(grep -m1 '^GH_TOKEN=' "${MIRROR_ENV_FILE}" | cut -d= -f2-)"
[[ -n "${GH_TOKEN}" ]] || { echo "error: no GH_TOKEN= line found in ${MIRROR_ENV_FILE}" >&2; exit 2; }
api() {
# api METHOD PATH [JSON_BODY]
local method="$1" path="$2" body="${3:-}"
if [[ -n "${body}" ]]; then
curl -fsS -X "${method}" \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \
-d "${body}" \
"${FORGEJO_BASE}/api/v1${path}"
else
curl -fsS -X "${method}" \
-H "Authorization: token ${FORGEJO_TOKEN}" \
"${FORGEJO_BASE}/api/v1${path}"
fi
}
# Determine whether FORGEJO_OWNER is an org or a user — orgs and users use
# different list-repos endpoints.
owner_kind="org"
if ! curl -fsS -o /dev/null -H "Authorization: token ${FORGEJO_TOKEN}" \
"${FORGEJO_BASE}/api/v1/orgs/${FORGEJO_OWNER}" 2>/dev/null; then
owner_kind="user"
fi
echo "# ${FORGEJO_OWNER} is a Forgejo ${owner_kind} account"
if [[ "${owner_kind}" == "org" ]]; then
repos_json="$(api GET "/orgs/${FORGEJO_OWNER}/repos?limit=50")"
else
repos_json="$(api GET "/users/${FORGEJO_OWNER}/repos?limit=50")"
fi
mapfile -t repo_names < <(echo "${repos_json}" | jq -r '.[].name')
echo "# ${#repo_names[@]} repos found under ${FORGEJO_OWNER}"
echo
if [[ "${DRY_RUN}" == 1 ]]; then
echo "# --dry-run: no POST requests will be made. GETs below are real, live reads."
echo
fi
skipped_private=()
would_create=()
already_present=()
for name in "${repo_names[@]}"; do
if [[ -n "${ONLY_REPOS}" ]]; then
IFS=',' read -ra allow <<< "${ONLY_REPOS}"
match=0
for a in "${allow[@]}"; do [[ "${a}" == "${name}" ]] && match=1; done
[[ "${match}" == 1 ]] || continue
fi
is_private="$(echo "${repos_json}" | jq -r --arg n "${name}" '.[] | select(.name==$n) | .private')"
if [[ "${is_private}" == "true" && "${INCLUDE_PRIVATE}" == 0 ]]; then
skipped_private+=("${name}")
echo "SKIP ${name}: private repo, pass --include-private to mirror it anyway"
continue
fi
target_url="https://github.com/${GITHUB_OWNER}/${name}.git"
existing="$(api GET "/repos/${FORGEJO_OWNER}/${name}/push_mirrors")"
already="$(echo "${existing}" | jq -r --arg u "${target_url}" '[.[] | select(.remote_address==$u)] | length')"
if [[ "${already}" -gt 0 ]]; then
already_present+=("${name}")
echo "OK ${name}: push mirror to ${target_url} already exists, skipping"
continue
fi
would_create+=("${name}")
body="$(jq -n \
--arg addr "${target_url}" \
--arg user "x-access-token" \
--arg pass "${GH_TOKEN}" \
--arg interval "${SYNC_INTERVAL}" \
'{remote_address: $addr, remote_username: $user, remote_password: $pass,
sync_on_commit: true, interval: $interval, use_ssh: false}')"
if [[ "${DRY_RUN}" == 1 ]]; then
redacted="$(echo "${body}" | jq '.remote_password = "***REDACTED***"')"
echo "WOULD-POST ${name}: /repos/${FORGEJO_OWNER}/${name}/push_mirrors"
echo "${redacted}" | sed 's/^/ /'
else
echo "CREATE ${name}: push mirror -> ${target_url}"
api POST "/repos/${FORGEJO_OWNER}/${name}/push_mirrors" "${body}" >/dev/null
fi
done
echo
echo "# summary"
echo "# already had a matching push mirror: ${#already_present[@]}"
echo "# private, skipped (--include-private to override): ${#skipped_private[@]}"
if [[ "${DRY_RUN}" == 1 ]]; then
echo "# would create: ${#would_create[@]}"
else
echo "# created: ${#would_create[@]}"
fi

View file

@ -110,4 +110,12 @@ check "post_install[0]" \
"echo installed" \
"$(jq -r '.packages.fakepkg.post_install[0]' "${OUT}")"
check "services[0].sha256" \
"$(sha256sum "${PKG_VER_DIR}/fakepkg.service" | awk '{print $1}')" \
"$(jq -r '.packages.fakepkg.services[0].sha256' "${OUT}")"
check "config.example_sha256" \
"$(sha256sum "${PKG_VER_DIR}/fakepkg.example.toml" | awk '{print $1}')" \
"$(jq -r '.packages.fakepkg.config.example_sha256' "${OUT}")"
echo "OK: all gen-index assertions passed"