Compare commits

...

77 commits
v0.3.0 ... main

Author SHA1 Message Date
Breadway
347f356b1d bread-polkit: add bakery.toml so the agent can be published later
All checks were successful
dev bakery / build (push) Successful in 1m1s
dev bread-theme / build (push) Successful in 14s
Not added to registry/bread-ecosystem.toml: that would put it on the
bakery index (and risk the BOS ISO) without a lockfile update. bakery.toml
declares the binary and contrib desktop file; README/CONTRIBUTING note
that it stays unpublished.
2026-08-23 14:38:04 +08:00
Breadway
f5a47490f7 bread-theme: quote only the named font so sans-serif stays a fallback
FONT_FAMILY is "Varela Round, sans-serif" but emission wrapped the whole
string in quotes, so CSS looked up one family named that string. Emit
'Varela Round', sans-serif instead and lock that in the tests.
2026-08-23 14:38:04 +08:00
Breadway
4f8859527d bread-utils: pin bread-shared to v0.8.0
The bread-client feature still resolved bread-shared from tag v0.7.0.
Point the git dependency at v0.8.0 and refresh Cargo.lock.
2026-08-23 14:38:04 +08:00
Breadway
e2c6452e4f ci: export VERSION for rc sign steps and drop unsafe tag path filters
RC sign steps read ${VERSION} from the environment, but prepare only set
it locally. Export it via GITHUB_ENV like dev-bakery.yml. Drop paths:
filters on tag-triggered rc workflows (they pointed at the old beta-*.yml
names and can skip an RC publish when the tag diff misses those paths);
the job if: contains -rc. is the real gate. Skip -rc. tags in package.yml
because PKGBUILD pkgver cannot contain a hyphen. Rebuild bakery on
bread-utils changes (path dependency).
2026-08-23 14:37:08 +08:00
Breadway
a9754d90ed Lock workspace packages at 0.7.4 so --locked release builds match the tag
Some checks failed
dev bakery / build (push) Successful in 2m9s
dev bread-theme / build (push) Successful in 25s
beta (rc) bakery / build (push) Has been skipped
beta (rc) bread-theme / build (push) Has been skipped
Build and publish package / package (push) Successful in 2m41s
release bakery / build (push) Failing after 1m39s
release bread-theme / build (push) Failing after 30s
2026-08-16 13:28:18 +08:00
Breadway
fcba376038 Add per-output palettes and window-scoped theme binding
Some checks failed
dev bakery / build (push) Failing after 1s
dev bread-theme / build (push) Failing after 1s
beta (rc) bakery / build (push) Has been skipped
beta (rc) bread-theme / build (push) Has been skipped
release bakery / build (push) Failing after 1s
release bread-theme / build (push) Failing after 1s
Build and publish package / package (push) Failing after 39s
Each Hyprland/GDK connector can have its own palette and stylesheet
under $XDG_RUNTIME_DIR/bread/{palettes,themes}/. GTK apps bind a
widget-level provider so two windows in one process can follow
different wallpapers. Bump workspace version to 0.7.4 for the tag.
2026-08-16 13:20:00 +08:00
Breadway
11c0e844e5 workspace: add bread-app crate and first-cut bread-polkit agent
Some checks failed
dev bread-theme / build (push) Successful in 17s
dev bakery / build (push) Successful in 43s
beta (rc) bakery / build (push) Has been skipped
beta (rc) bread-theme / build (push) Has been skipped
Build and publish package / package (push) Successful in 1m46s
release bakery / build (push) Failing after 52s
release bread-theme / build (push) Failing after 14s
bread-app is the GTK bootstrap new tools should use instead of another
copied main.rs: com.breadway.* app id, singleton lock, optional
gtk_popup re-export, optional bread.command.<app>.** listen loop.
Tests cover app-id helpers and command-verb parse. Existing apps are
not migrated.

bread-polkit is an own PolicyKit1 session authentication agent with a
bread-theme GTK4 password prompt (not a polkit-gnome wrapper).
Autostart via contrib/bread-polkit.desktop or exec-once. Not a bakery
product; not added to the BOS ISO lockfile.
2026-08-16 00:34:12 +08:00
Breadway
c296d26408 bakery: add system prefix installs for BOS
All checks were successful
dev bread-theme / build (push) Successful in 20s
dev bakery / build (push) Successful in 51s
Default remains ~/.local. Setting prefix=/usr/local (via
/etc/bakery/config.toml or BAKERY_PREFIX) installs bins and share
under that prefix and systemd user units under /usr/lib/systemd/user.
Writes that need root use sudo -n, then pkexec. State stays per-user.
2026-08-16 00:00:19 +08:00
Breadway
b20e4bcec1 Merge feature/bakery-ui: restyle CLI output
All checks were successful
dev bakery / build (push) Successful in 48s
2026-08-15 23:50:31 +08:00
Breadway
3f1caa99f5 bakery: restyle CLI output with headers, columns, and progress
Catalog views use aligned columns and two-line entries so long -dev
versions no longer smash the old 10-char pad. Install/update/remove get
action banners and a verb column; downloads >= 256 KB show a real
progress bar; clap help matches the same palette. NO_COLOR and non-TTY
still strip color.
2026-08-15 23:50:27 +08:00
Breadway
30517f1617 workspace: bump bakery version to 0.7.2 and document tag honesty
Some checks failed
dev bakery / build (push) Successful in 1m11s
dev bread-theme / build (push) Successful in 14s
beta (rc) bakery / build (push) Has been skipped
beta (rc) bread-theme / build (push) Has been skipped
Build and publish package / package (push) Successful in 1m38s
release bread-theme / build (push) Failing after 16s
release bakery / build (push) Failing after 46s
bakery --version is compiled from workspace.package.version; bakery list
reports the git tag. Those must match at tag time. 0.7.2 is unreleased
work after v0.7.1 — no tag in this commit.
2026-08-15 22:13:38 +08:00
Breadway
25fde8e4f0 Remove CLAUDE.md (renamed to AGENTS.md) 2026-08-15 22:04:00 +08:00
Breadway
f3d946f325 Rename CLAUDE.md to AGENTS.md 2026-08-15 22:03:30 +08:00
Breadway
08b71262da platform: BreadClient command/health, fail-closed get.sh, registry README
Add BreadClient::command (unsourced bread.command.<app>.<verb> emit) plus
health/api_version, and a clap-free screenshot_cli helper for the next pin.
get.sh now dies if minisign or .minisig is missing — checksum-only is not
enough to install. Generate the README products table from the registry,
and refresh release-channels/CONTRIBUTING/CLAUDE.md to match.
2026-08-15 21:39:08 +08:00
Breadway
69ce2d67a8 bakery: recognize system deps on Debian/Ubuntu hosts, not just Arch
All checks were successful
dev bakery / build (push) Successful in 37s
doctor::dep_present only checked pacman (exact Arch package name) or a
literal PATH-binary-name match, so e.g. mkvtoolnix-cli — Debian package
mkvtoolnix, binaries mkvmerge/mkvextract/... — always reported missing
on a non-Arch bakery host, blocking install even when the real tooling
was present. Adds a dpkg fallback with an explicit Arch->Debian name
map, and makes the 'install with: ...' hint pick pacman/apt/generic
based on what's actually on the host instead of always suggesting
pacman.
2026-08-12 09:41:32 +08:00
Breadway
1322fc31ac bakery: fix confirm() test hang when stdin is a real tty
All checks were successful
dev bakery / build (push) Successful in 39s
confirm() checked the actual process stdin's is_terminal() state, which
is true when cargo test is run from an interactive shell rather than
CI/piped input — the two confirm-dependent tests then blocked on a
read_line nobody was there to answer. Force stdin_is_terminal() to
false in test builds so the tests never touch real stdin at all.
2026-08-12 09:23:37 +08:00
Breadway
2a23d81e39 registry: onboard breadarr onto the bakery channel 2026-08-12 09:14:55 +08:00
6057ed39b0 Merge pull request 'bread-onnx: fix workspace test build with load-dynamic dev-dependency' (#3) from fix/bread-onnx-workspace-test-link into main
Some checks failed
dev bread-theme / build (push) Successful in 18s
dev bakery / build (push) Has been cancelled
Reviewed-on: #3
2026-08-07 11:20:41 +08:00
Breadway
b907b92faf bread-onnx: fix workspace test build with load-dynamic dev-dependency
cargo test --workspace failed to link bread-onnx's own unit tests
(undefined symbol OrtGetApiBase) because nothing in this workspace
supplies an ort backend — that's deliberately left to each downstream
consumer app (breadarr, breadmill, breadpad) in their own repos. None
of bread-onnx's unit tests actually open an ONNX session, so enabling
ort's load-dynamic feature as a dev-dependency (unifies into this
crate's own test builds only, never into downstream consumers) is
enough to satisfy the linker without requiring a real onnxruntime.
2026-08-07 10:51:24 +08:00
Breadway
147cfbbf96 Merge feature/bakery-cli-features: search, completions, rollback, verify, purge, dry-run, self-update
Some checks failed
dev bread-theme / build (push) Successful in 19s
dev bakery / build (push) Has been cancelled
2026-08-05 19:04:49 +08:00
Breadway
a4f0c96b90 scripts: add onboard-product.sh, teach doctor-channels.sh to check signing secrets
breadcast shipped with a full bakery.toml + CI workflows but was missing
from registry/bread-ecosystem.toml and had zero Forgejo Actions secrets
configured, so its release workflows would have failed closed (or worse,
published unsigned on an older workflow shape) the first time they ran.
Neither gap was visible until checked by hand.

doctor-channels.sh now also flags any registry product's repo missing the
BAKERY_MINISIGN_SEC_KEY_PATH secret (soft-skipped without a local Forgejo
token). onboard-product.sh handles the one genuine write step — adding a
[[products]] entry — then runs doctor-channels.sh so nothing else gets
missed silently again. Also fixes a pre-existing false positive where the
local-checkout drift scan didn't recognize worktree checkouts of
bread-ecosystem itself beyond the one literal "-fix-worktree" suffix it
special-cased.
2026-08-05 18:54:25 +08:00
Breadway
eba8cb6c44 bakery: add search, completions, rollback, verify, purge, dry-run, self-update
New CLI surface, approved for review before merge:

- search <query>: case-insensitive name/description substring match
- completions <shell>: bash/zsh/fish/elvish/powershell via clap_complete
- rollback <pkg>: restore the previously installed version from a local
  pre-update binary backup (not a network re-fetch — index.json's minisign
  signature only covers the current published version, so pinning an old
  version from the server would only be checkable against its unsigned
  per-version .sha256 sidecar, a materially weaker trust path)
- verify [pkg]: recompute installed binaries' sha256 and compare against
  the hash recorded at install time, not a fresh index lookup (the index
  only has the latest release's checksum, which may not match what's
  actually installed)
- remove --purge: additionally remove the license dir, desktop entry, and
  data dir, each gated through the existing confirm() prompt; config is
  still deliberately left alone
- self-update: documented entry point for updating bakery itself
- --dry-run: global flag, short-circuits right before install::
  install_package in both the install and update paths
- download progress: chunked read loop in manifest::fetch_bytes prints
  periodic \r progress on stderr when Content-Length is present and stderr
  is a tty
- update --all output: "already at X" is now DIM with a neutral glyph
  instead of GREEN, plus a bold one-line summary count, so unchanged
  packages don't visually compete with ones that actually changed

InstalledPackage gained previous_version and binary_sha256 (both
#[serde(default)]) to back rollback/verify. fetch_and_place now returns the
verified sha256 instead of discarding it.

Also fixes a handful of pre-existing clippy lints in files this touches
(manual split_once, &PathBuf-vs-&Path, derivable Default, unnecessary
unwrap) surfaced by a clippy version newer than when that code was last
touched — confirmed via git stash that they predate this branch. bread-
utils has one more of these (suspicious_open_options in singleton.rs) left
alone: the mechanical fix would truncate the PID file before the
lock-held-by-another-process branch reads its contents, which would break
toggle_or_kill's PID lookup, so cargo clippy -p bakery needs --no-deps
until that one's fixed with actual thought.
2026-08-05 17:10:31 +08:00
Breadway
227247907b registry: add breadcast to the product list
breadcast already ships its own bakery.toml and CI workflows that publish
to dl.breadway.dev — it was just missing from the registry gen-index.sh
reads to know what to include in index.json.
2026-08-05 14:15:11 +08:00
Breadway
d45fc422f2 bakery: fix correctness, reliability, and security issues from audit
Some checks failed
dev bread-theme / build (push) Successful in 17s
dev bakery / build (push) Has been cancelled
Track switches now always take effect on `update --all` instead of
silently no-op'ing or permanently refusing on strict semver comparison.
`remove` no longer aborts cleanup on the first failed binary removal,
orphaning the systemd unit. State reads/writes are now lock-protected
and go through fsync'd atomic writes (also fixes a temp-path collision
in binary installs). The index loader falls back to a stale-but-signed
cache instead of hard-failing offline. systemd units now re-fetch on
every update instead of freezing after first install. `doctor` now
flags missing recorded binaries.

Security hardening: path-traversal guard on all index-controlled
filenames, archive extraction now rejects symlink/traversal entries
before tar touches disk, archive temp files use secure unique paths,
post_install hooks are gated behind --no-hooks/confirmation, response
buffering is capped, empty-checksum downloads get a clear error, and
both stable-track CI workflows now hard-fail on a missing signing key
(matching the existing dev/rc guard) instead of silently publishing an
index next to a stale signature. gen-index.sh now publishes the index
and its signature atomically.

Also: bakery install on an already-installed package no longer
silently reinstalls/downgrades, cmd_update exits non-zero for unknown
packages, and the unused toml dependency is removed.
2026-08-05 13:55:57 +08:00
Breadway
620c5a1317 Merge fix/ci-explicit-product-name: avoid CI image/cache collisions across products 2026-08-05 09:03:37 +08:00
Breadway
69c24f6d04 ci: take product name explicitly instead of deriving it from checkout dir
Every consuming product's CI checks out into a directory literally
named `src` (see e.g. breadpad's checkout step), so basename(repo_root)
resolved to "src" for every product in real CI runs — not the actual
product name, which only looked right in local testing because that
happened to run from a directory actually named after the product.

In production this meant every product sharing the runner would have
collided on the same image tag (bread-ci:src) and the same cargo-target
cache volume, silently mixing compiled artifacts across unrelated
repos. Caught before a second product (breadmon/breadclip/breadshot)
started using this and made the collision real.
2026-08-05 09:03:22 +08:00
Breadway
cd5da468b3 Merge fix/shared-product-ci: shared Arch CI image/script for GTK4 products 2026-08-04 18:06:32 +08:00
Breadway
3f5f241985 ci: add shared Arch build image/script for GTK4 product repos
breadpad's CI used to rebuild libadwaita from source in an uncached
Fedora container on every push and broke repeatedly on version drift.
The fix there was a pinned Arch container (current gtk4/libadwaita/
gtk4-layer-shell/graphene are prebuilt pacman packages, no source
build needed) — this centralizes that image/script here so every
GTK4 layer-shell product in the ecosystem can share it instead of
each repo carrying its own copy.

ci/build.sh takes a product repo root + cargo command, and reads an
optional ci/deps.txt from that repo for product-specific extra pacman
packages (EXTRA_PKGS build-arg) without forking the Containerfile.

Product repos should pin this to a commit sha, not track main — an
unrelated change here would otherwise silently affect every product's
next release build.
2026-08-04 18:06:20 +08:00
Breadway
f86e299f4a CLAUDE.md: update repo-hygiene notes for single-trunk + RC-tag model
All checks were successful
dev bread-theme / build (push) Successful in 50s
dev bakery / build (push) Successful in 3m3s
2026-07-31 11:11:54 +08:00
Breadway
036f270b07 docs: update release-channels.md for single-trunk + RC-tag model 2026-07-31 11:11:17 +08:00
Breadway
4f0fe2571d CONTRIBUTING.md: document single-trunk + RC-tag release model 2026-07-31 11:08:41 +08:00
Breadway
3caad809a3 CI: single-trunk model — dev triggers on main, beta becomes RC-tag-triggered
Replaces the dev/beta branch split with one trunk (main): dev-track
builds still publish on every push, but the beta track now publishes
from a vX.Y.Z-rc.N prerelease tag instead of a separately-maintained
beta branch. Removes the branch nobody reliably kept in sync.
2026-07-31 11:05:17 +08:00
Breadway
f8f69f4ae5 Merge feature/adw-theme-fixes into dev
All checks were successful
dev bread-theme / build (push) Successful in 37s
2026-07-31 09:07:58 +08:00
Breadway
594c18bf1b bread-theme: hardcode destructive-action red instead of pywal @red
pywal derives @red from the wallpaper and can hand it any hue - on a
blue-toned wallpaper the "red" slot is itself blue, making destructive
buttons indistinguishable from normal accent/confirm buttons. GNOME's
own destructive-action style is a fixed red for the same reason; this
is now the one button in the shared stylesheet that intentionally
ignores the palette.
2026-07-31 07:08:00 +08:00
Breadway
9b097218fd bread-theme: fix libadwaita class collisions and restore boxed-list styling
Two bare class selectors (.title, .subtitle) were colliding with
libadwaita's own internal row/window-title label classes of the same
name, causing every AdwActionRow/AdwSwitchRow/AdwSpinRow title to
inherit the 1.4em heading size meant for app view-titles - the root
cause of breadman settings' ~24px row-title bug found in design review.
Renamed to .page-title/.page-subtitle (breadhelp, the only caller,
updated separately).

Also scoped a .boxed-list override so AdwPreferencesGroup's boxed-list
GtkListBox gets its surface fill + radius back - the shared
`list, listbox { background-color: transparent }` rule (needed for
plain GTK4 sidebars) was stripping it with equal specificity.
2026-07-30 20:01:47 +08:00
Breadway
4e76bc7077 Merge feature/adwaita-components: libadwaita components + slider/chip theming fixes
All checks were successful
dev bread-theme / build (push) Successful in 27s
dev bakery / build (push) Successful in 1m10s
2026-07-29 22:45:30 +08:00
Breadway
e898535bb4 bread-theme: add libadwaita components + fix slider/chip theming gaps
New `adw` feature (gated separately from `gtk`, since AdwApplicationWindow
isn't compatible with gtk4-layer-shell — the five panel/launcher apps stay
on plain `gtk`, only breadman/breadhelp-style plain-window apps want this):
preferences_group/toggle_row/spin_row/action_row/preferences_page, wrapping
libadwaita's PreferencesGroup/SwitchRow/SpinRow/ActionRow/PreferencesPage.
adw::init() also forces dark color-scheme, since bread-theme's whole design
is a fixed dark base regardless of system GTK preference.

These directly target defects a design critique found: hand-rolled
switch+label rows with no intrinsic width (breadman/settings' ~1400px
stretched toggles) and spinners stranded far from their label — both just
don't happen when the row is a real AdwSwitchRow/AdwSpinRow instead of a
box assembled from scratch.

Also, two shared-stylesheet fixes usable by every app immediately, gtk
feature only:
- `scale` (slider) had no rule at all, so every volume/brightness slider
  showed GTK's own default blue instead of the palette accent — the same
  critique flagged breadbar's control-panel sliders contradicting its own
  on-brand OSD fill two clicks away.
- A new `chip()`/`set_chip_active()` helper in gtk.rs uses the existing
  (already-tokenized, already-defined) `.chip`/`.pill` stylesheet rule
  instead of each app hand-rolling its own filter-chip CSS — which is how
  breadclip/breadpad/breadman ended up with three different, mutually
  disagreeing pill fills for what's supposed to be one shared component.
2026-07-29 22:45:18 +08:00
Breadway
6eb3479529 Merge feature/capture-per-app-subfolders 2026-07-29 22:02:54 +08:00
Breadway
7c7881ddf4 bread-capture: write each app's captures into its own subfolder
Drops the app-name filename prefix (redundant with the folder name) —
output is now <out-dir>/<app>/<view>.png instead of a flat
<out-dir>/<app>-<view>.png. Makes browsing a multi-app run's output
directory clearer, and is what a real screenshots-folder deliverable
should look like.
2026-07-29 22:02:54 +08:00
Breadway
79d4d737cf Merge feature/capture-breadbar-full-views: full view coverage + multi-app CLI 2026-07-29 21:57:32 +08:00
Breadway
7ec232b86d bread-capture: one command for every app, flags for a single one
Plain `bread-capture` with no flags now captures every known app's every
view in one run — each binary resolved by its own bare name via $PATH,
same as invoking it directly by name would (so an installed bread
ecosystem needs nothing but `bread-capture` to regenerate every
screenshot). Previously --app-path was required, so there was no way to
run more than one app per invocation.

--app <name> restricts to a single app (resolved via $PATH, no path
needed); --app-path still works alone too, inferring which app by its
file stem exactly as before. --view <name> further restricts to one
view — apps without a matching view are silently skipped rather than
treated as an error, since view names naturally don't overlap across
apps in a multi-app run, but an unmatched --view in a single-app run (or
one that matches nothing across every selected app) is still a real
error.
2026-07-29 21:54:46 +08:00
Breadway
94feaa6f9b bread-capture: add bos-settings' 24 sidebar-section views to target registry 2026-07-29 21:45:18 +08:00
Breadway
669ca64284 bread-capture: add breadhelp's troubleshoot-wizard view to target registry 2026-07-29 21:37:33 +08:00
Breadway
271555fb80 bread-capture: add breadpad's reminder + reminder-snooze views to target registry 2026-07-29 21:32:44 +08:00
Breadway
c0b489fa67 bread-capture: add breadman's 11 additional views to target registry 2026-07-29 17:27:12 +08:00
Breadway
64cc17905d bread-capture: add breadbar's 8 new views to target registry 2026-07-29 17:17:35 +08:00
Breadway
e402bb3cb7 Merge feature/capture-rainbow-background: rainbow-gradient isolation background
All checks were successful
dev bread-theme / build (push) Successful in 21s
dev bakery / build (push) Successful in 52s
2026-07-29 17:02:23 +08:00
Breadway
e3df426996 bread-capture: rainbow-gradient isolation background instead of flat color
A solid background can't reveal whether a surface that's supposed to be
translucent (breadbox/breadclip/breadsearch's full-screen overlay
windows, breadbar's alpha-blended notification/OSD surfaces) is actually
compositing as translucent — a flat color showing through a flat color
still just looks flat. Generates a diagonal rainbow gradient (full hue
sweep, via the `image` crate) sized exactly to the capture canvas and
sets it as Sway's output background instead.

Needed installing swaybg (the external helper Sway's `output ... bg`
config directive shells out to) — without it the bg command silently
no-ops and the canvas stays black, which is why the first attempt at
this looked identical to the old flat-color version.
2026-07-29 17:02:09 +08:00
Breadway
ff17a028a3 Merge feature/capture-bos-settings-target: register bos-settings capture target 2026-07-29 16:50:30 +08:00
Breadway
cdcb931f37 bread-capture: add bos-settings to target registry 2026-07-29 16:48:01 +08:00
Breadway
c93f4cf4d0 Merge feature/capture-app-targets: register breadbox/breadclip/breadsearch/breadpad/breadhelp/breadman capture targets 2026-07-29 16:38:27 +08:00
Breadway
af796253e9 bread-capture: add breadman to target registry 2026-07-29 16:37:14 +08:00
Breadway
1ce5514939 bread-capture: add breadhelp to target registry 2026-07-29 11:49:33 +08:00
Breadway
03749787c5 bread-capture: add breadpad to target registry 2026-07-29 11:44:00 +08:00
Breadway
f06ba904b7 bread-capture: add breadsearch to target registry 2026-07-29 11:40:28 +08:00
Breadway
231f71e586 bread-capture: add breadclip to target registry 2026-07-29 11:37:17 +08:00
Breadway
1a3475bd23 Merge feature/capture-multi-app-registry: generalize bread-capture targets 2026-07-29 11:33:31 +08:00
Breadway
21099065c4 bread-capture: generalize target registry, fix Drop-skipped-on-exit leak
Replaces the breadbar-only hardcoded view list with a registry keyed by
app name (--app-name, defaulting to --app-path's file stem) so wiring up
each new app just means adding one entry, not touching the CLI shape.

Also fixes a real leak found while testing breadbox against this:
std::process::exit() on the failure path skipped every destructor,
including Isolation's Drop — so a failed capture run (or, more subtly,
*any* run against an app whose view list didn't match yet, which is
exactly what happened testing this) permanently orphaned the headless
Sway process and its wayland-N/.lock socket pair. main() now returns
ExitCode instead of calling process::exit directly, so Drop always runs.
2026-07-29 11:33:06 +08:00
Breadway
27b3b17c58 Merge fix/capture-headless-sway: switch capture isolation to headless Sway 2026-07-29 11:18:54 +08:00
Breadway
d059d99437 bread-capture: switch capture isolation to headless Sway
Nested Hyprland worked but had real limits: the outer compositor decided
the nested window's pixel size (needing an outer-session float+resize
dispatch per capture), occluded surfaces got no frame callbacks (so grim
hung unless the nested window was also focused/raised), and there was no
way to fully suppress a brief real, visible flash of that window on the
operator's desktop.

wlroots' WLR_BACKENDS=headless (Sway, not Hyprland, is built on wlroots
directly) has a genuine headless backend: no seat/DRM-master claim, no
window anywhere, ever. Confirmed empirically: zero visible footprint,
both zwlr_layer_shell_v1 and zwlr_screencopy_manager_v1 present, grim
completes instantly with no focus dance needed.

This drops the Hyprland-specific plumbing that no longer applies:
- bread-screenshots now exposes one compositor-agnostic capture_region
  primitive instead of capture_layer/capture_output, since the isolated
  canvas size is always known up front rather than queried via hyprctl.
- bread-utils::hypr loses the Monitor scale/transform/logical_size and
  Layer/find_layer additions that only existed to support that querying.
- bread-capture's isolation module spawns headless Sway instead of a
  nested Hyprland instance, and passes --width/--height through to the
  target app so it knows the canvas size without asking anyone.

Also fixes a socket leak in isolation teardown: killing the compositor
(Hyprland or Sway) doesn't unlink the wayland-N/.lock files it created,
so every capture run was orphaning a socket pair in the runtime dir.
Drop now removes them explicitly.
2026-07-29 11:15:41 +08:00
Breadway
686af0d3dc Merge feature/capture-isolation: nested-Hyprland isolation for bread-capture 2026-07-23 16:23:57 +08:00
Breadway
bcd57b7b54 bread-capture: isolate captures in a throwaway nested Hyprland instance
Captures now run inside a dedicated nested Hyprland session by default
(--no-isolate to opt out), so nothing on the operator's live desktop can
leak into a screenshot and the capture never flashes across their screen
either. The nested instance nests as a Wayland client of the outer session
(true headless was ruled out empirically: this machine's real GPU/output is
already claimed by the live session, and only one process can hold logind's
seat at a time), gets floated/exact-resized/focused via one-shot outer-session
hyprctl dispatches targeted by pid, and has Hyprland's default background/
logo and startup warning overlays disabled via config so captures come out
clean. Focusing turned out to be load-bearing, not cosmetic: an occluded
nested window never gets frame callbacks from the outer compositor, so grim
run inside it hangs forever waiting on a ready event that never comes.
2026-07-23 16:22:24 +08:00
Breadway
eb090b198f Merge feature/bread-screenshots: bread-screenshots + bread-capture foundation
All checks were successful
dev bread-theme / build (push) Successful in 16s
dev bakery / build (push) Successful in 39s
2026-07-23 14:17:40 +08:00
Breadway
007082374d Add bread-screenshots + bread-capture: foundation for UI screenshot tooling
New bread-screenshots crate captures a layer-shell surface (by namespace+pid,
to disambiguate from an already-running instance) or the whole focused
output via grim, using bread-utils::hypr/proc. bread-utils::Monitor gains a
scale field and logical_size() so output geometry accounts for HiDPI/
transform, matching breadshot's proven math. bread-utils::hypr gains
find_layer() over hyprctl layers -j.

bread-capture is a small orchestrator that drives an app's --screenshot mode
and collects the resulting PNGs; hardcoded to breadbar's two views for now.
2026-07-23 14:15:49 +08:00
Breadway
77bca8a1cf Will change this commit message to mean something later 2026-07-23 11:12:55 +08:00
Breadway
c7abfae630 bakery: add license_file/desktop_file/data_archive manifest fields
All checks were successful
dev bakery / build (push) Successful in 38s
Closes the packaging gap found while moving bread-ecosystem apps off
pacman onto bakery-only distribution: pacman's package() typically installs
a LICENSE file and, for GUI/onboarding apps, a .desktop entry and sometimes
a data directory (e.g. breadhelp's guide content). All three follow the
same download-verify-place pattern ConfigScaffold.example already
established:

- license_file -> ~/.local/share/licenses/<name>/LICENSE
- desktop_file -> ~/.local/share/applications/<name>.desktop
- data_archive -> a .tar.gz extracted to ~/.local/share/<name>/ (for
  arbitrary data too big/structured for a single file, via `tar`)

gen-index.sh parses all three from bakery.toml, hashes the artifact, and
now excludes them from the binaries-collection loop (previously undetected
gap: they'd have been swept in as fake "binaries" with no checksum, same
class of bug the existing .toml/.service/etc exclusions guard against).

Also registers breadhelp as a bakery-channel product.
2026-07-23 10:15:13 +08:00
Breadway
5afe12d70f Will change this commit message to mean something later 2026-07-22 19:52:56 +08:00
Breadway
02aeb0406a docs: add CONTRIBUTING.md, point CLAUDE.md at it, fix stale track table
CLAUDE.md now points to CONTRIBUTING.md as the canonical workflow doc
instead of duplicating the release lifecycle inline. docs/release-channels.md's
current-state table was stale — it still said beta/dev wasn't rolled out
to the sibling repos, which is now done.
2026-07-22 19:39:31 +08:00
Breadway
0425c64214 docs: document the dev/beta/main release lifecycle
CLAUDE.md and docs/release-channels.md now describe the full cycle: work
lands on feature/fix branches merged into dev, dev auto-publishes on every
push, beta is cut from dev as a frozen stabilization branch (also
auto-publishing on every push, fixes forwarded from fix/<issue> branches),
and after a quiet freeze period beta merges to main and gets tagged for
the actual stable release. Also fixes README's stale reference to
.github/workflows (actual CI lives under .forgejo/workflows) and links
out to the new CONTRIBUTING.md.
2026-07-22 18:53:16 +08:00
Breadway
2b05a4f6c2 ci: make beta a branch-triggered freeze track, not a one-off tag
All checks were successful
beta bread-theme / build (push) Successful in 9s
beta bakery / build (push) Successful in 47s
Beta is now a real stabilization branch: publishes on every push to
`beta` (mirroring dev's model, auto-versioned X.Y.Z-beta.<ts>+<sha>,
base version from the latest published tag) instead of a manual
beta-v* tag. Fixes made during the freeze land via fix/<issue> branches
merged into `beta` directly. The gen-index.sh clone for beta pulls
bread-ecosystem's default branch (main) rather than pinning to dev,
since beta is the more stable track and main now carries the
TRACK-aware script.
2026-07-22 18:37:07 +08:00
Breadway
afa3686e9a ci: base dev version on the latest published tag, not Cargo.toml
All checks were successful
dev bread-theme / build (push) Successful in 14s
dev bakery / build (push) Successful in 56s
beta bread-theme / build (push) Successful in 14s
beta bakery / build (push) Successful in 37s
Cargo.toml can drift stale relative to the actual last release (observed
on breadbox/breadpad/breadcrumbs/breadpaper), which made the auto-bumped
dev version sort as OLDER than what's already installed — bakery's semver
check correctly refused those "updates". Deriving the base version from
git ls-remote --tags instead is self-healing regardless of Cargo.toml
drift, with a Cargo.toml fallback only for a repo with no tags yet.
2026-07-22 13:51:23 +08:00
Breadway
d6e20a082a random commit message, read it yourself
All checks were successful
dev bread-theme / build (push) Successful in 13s
2026-07-22 11:48:21 +08:00
Breadway
86e712d726 ci: fail fast on missing signing key in dev/beta index regeneration
All checks were successful
dev bread-theme / build (push) Successful in 17s
dev bakery / build (push) Successful in 48s
Matches the same guard just added to bread's dev/beta workflows: a missing
BAKERY_MINISIGN_SEC_KEY_PATH secret should fail the job loudly rather than
silently publish an unsigned index.
2026-07-22 09:50:54 +08:00
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
69 changed files with 9287 additions and 597 deletions

View file

@ -0,0 +1,99 @@
name: dev bakery
# Publishes a dev-track build on every push to `main` (the trunk branch —
# there is no separate `dev` branch). See docs/release-channels.md for the
# release-track policy this is part of.
on:
push:
branches: ['main']
paths:
- 'bakery/**'
- 'bread-utils/**'
- '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 main --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
# Base the dev version off the latest published stable tag,
# not Cargo.toml — Cargo.toml can go stale relative to the last
# real release (seen in practice: breadbox/breadpad/breadcrumbs/
# breadpaper), which would make a dev build sort as OLDER than
# what's already installed and bakery would correctly refuse it.
LATEST_TAG="$(git ls-remote --tags --refs \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \
| awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)"
if [ -n "${LATEST_TAG}" ]; then
CUR="${LATEST_TAG}"
else
CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')"
fi
IFS='.' read -r MA MI PA <<< "${CUR}"
SHA="$(git rev-parse --short HEAD)"
TS="$(date -u +%Y%m%d%H%M%S)"
echo "VERSION=${MA}.${MI}.$((PA + 1))-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: |
set -euo pipefail
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate dev index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the dev track)"
exit 1
fi
cd src && bash scripts/gen-index.sh

View file

@ -0,0 +1,87 @@
name: dev bread-theme
# Publishes a dev-track build on every push to `main` (the trunk branch —
# there is no separate `dev` branch). See docs/release-channels.md for the
# release-track policy this is part of.
on:
push:
branches: ['main']
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 main --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
# Base the dev version off the latest published stable tag,
# not Cargo.toml — Cargo.toml can go stale relative to the last
# real release (seen in practice: breadbox/breadpad/breadcrumbs/
# breadpaper), which would make a dev build sort as OLDER than
# what's already installed and bakery would correctly refuse it.
LATEST_TAG="$(git ls-remote --tags --refs \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \
| awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)"
if [ -n "${LATEST_TAG}" ]; then
CUR="${LATEST_TAG}"
else
CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')"
fi
IFS='.' read -r MA MI PA <<< "${CUR}"
SHA="$(git rev-parse --short HEAD)"
TS="$(date -u +%Y%m%d%H%M%S)"
echo "VERSION=${MA}.${MI}.$((PA + 1))-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: |
set -euo pipefail
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate dev index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the dev track)"
exit 1
fi
cd src && bash scripts/gen-index.sh

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

@ -6,6 +6,9 @@ on:
jobs:
package:
# PKGBUILD pkgver cannot contain `-`; skip RC tags the same way
# release-bakery.yml does.
if: ${{ !contains(github.ref_name, '-rc.') }}
runs-on: [self-hosted, hestia]
container:
image: archlinux:latest

View file

@ -0,0 +1,73 @@
name: beta (rc) bakery
# Publishes a beta-track build for any `vX.Y.Z-rc.N` prerelease tag pushed
# to `main` — there is no separate `beta` branch; "freezing" is just
# pausing pushes to main while an RC gets tested. See
# docs/release-channels.md for the release-track policy.
on:
push:
tags: ['v*']
# No paths: filter. Tag pushes compare against an unrelated commit and
# would skip the RC publish if bakery/** wasn't in that diff; the job
# `if: contains -rc.` is the real gate.
jobs:
build:
if: ${{ contains(github.ref_name, '-rc.') }}
runs-on: [self-hosted, hestia]
steps:
- name: checkout
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch "${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}"
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
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
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 step here, unlike release-bakery.yml — beta
# builds happen on every push while the branch is frozen for testing,
# so dl.breadway.dev/beta/ is the only distribution point for this track.
- name: regenerate beta index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
TRACK: beta
run: |
set -euo pipefail
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate beta index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the beta track)"
exit 1
fi
cd src && bash scripts/gen-index.sh

View file

@ -0,0 +1,67 @@
name: beta (rc) bread-theme
# Publishes a beta-track build for any `vX.Y.Z-rc.N` prerelease tag pushed
# to `main` — there is no separate `beta` branch; "freezing" is just
# pausing pushes to main while an RC gets tested. See
# docs/release-channels.md for the release-track policy.
on:
push:
tags: ['v*']
# No paths: filter. Tag pushes compare against an unrelated commit and
# would skip the RC publish if bread-theme/** wasn't in that diff; the
# job `if: contains -rc.` is the real gate.
jobs:
build:
if: ${{ contains(github.ref_name, '-rc.') }}
runs-on: [self-hosted, hestia]
steps:
- name: checkout
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch "${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}"
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
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
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: |
set -euo pipefail
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate beta index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the beta track)"
exit 1
fi
cd src && bash scripts/gen-index.sh

View file

@ -6,6 +6,7 @@ on:
jobs:
build:
if: ${{ !contains(github.ref_name, '-rc.') }}
runs-on: [self-hosted, hestia]
steps:
- name: checkout
@ -58,7 +59,13 @@ jobs:
- name: regenerate index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: cd src && bash scripts/gen-index.sh
run: |
set -euo pipefail
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate stable index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the stable track)"
exit 1
fi
cd src && bash scripts/gen-index.sh
- name: upload to GitHub Release
env:

View file

@ -6,6 +6,7 @@ on:
jobs:
build:
if: ${{ !contains(github.ref_name, '-rc.') }}
runs-on: [self-hosted, hestia]
steps:
- name: checkout
@ -56,7 +57,13 @@ jobs:
- name: regenerate index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: cd src && bash scripts/gen-index.sh
run: |
set -euo pipefail
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate stable index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the stable track)"
exit 1
fi
cd src && bash scripts/gen-index.sh
- name: upload to GitHub Release
env:

24
AGENTS.md Normal file
View file

@ -0,0 +1,24 @@
# AGENTS.md — Repo hygiene
Scope: this file covers *repo hygiene* — branching, remotes, CI, cleanup. It is not project documentation.
Follow [`CONTRIBUTING.md`](CONTRIBUTING.md) for any git, branch, or release work. Channel/track policy lives in [`docs/release-channels.md`](docs/release-channels.md). The product list is [`registry/bread-ecosystem.toml`](registry/bread-ecosystem.toml) — regenerate the README table with `scripts/gen-readme-products.sh` after editing it. Don't invent a second long-lived branch; there is only `main`. Bakery's package version **must** match `[workspace.package] version` in the root `Cargo.toml` at tag time (`bakery --version` is compiled from that field; `bakery list` reports the git tag) — never push a `v*` tag without bumping Cargo.toml to the same `X.Y.Z`.
## Remotes
- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative.
- `github` — GitHub mirror. Push both when publishing.
## CI
- `.forgejo/workflows/package.yml`, `release-bakery.yml`, `release-bread-theme.yml` all trigger on `push: tags: ['v*']`, gated to skip any tag containing `-rc.` — pushing to `main` doesn't run these. Tag a release to trigger packaging.
- `dev-bakery.yml` / `dev-bread-theme.yml` trigger on `push: branches: ['main']`; `rc-bakery.yml` / `rc-bread-theme.yml` trigger on `push: tags: ['v*']` gated to *only* run for `-rc.` tags — both auto-publish a signed, auto-versioned build to `dl.breadway.dev/{dev,beta}/`. See `docs/release-channels.md` for the full track (stable/beta/dev) policy.
- No build/lint/test CI runs on ordinary commits or PRs to `main` beyond the dev-track workflow above — there's no separate lint/PR-check pipeline.
## Cleanup
- Delete feature/fix branches (local + remote) once merged. Check with `git branch --merged main`.
- A `fix/audit-findings` branch and a merged `copilot/create-readme-md` branch (both local and on `origin`/`github`) were found stale and fully merged here on 2026-07-21 and removed.
## Don't
- Don't embed credentials in remote URLs — SSH or a credential helper only.
- Don't flip bakery's default install prefix. System prefix (`/usr/local` via
`/etc/bakery/config.toml` or `BAKERY_PREFIX`) is for BOS; hermes and
`get.sh` stay on `~/.local`. See [`bakery/README.md`](bakery/README.md).

120
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,120 @@
# Contributing
This repo is a Cargo workspace. Bakery-channel products shipped from here
are `bakery` (the ecosystem package manager) and `bread-theme` (the shared
theming crate). Shared crates that sibling apps pin — not bakery packages
of their own — are `bread-utils`, `bread-app`, `bread-onnx`,
`bread-screenshots`, and `bread-capture`. `bread-polkit` is an in-tree
session agent: it has `bread-polkit/bakery.toml` so it *can* be published,
but it is not in `registry/bread-ecosystem.toml` (unpublished — not on
the bakery index, not on the BOS ISO). Other ecosystem products
(`bread`, `breadbar`, `breadbox`, …) live in their own repos under
`Breadway/` but follow the same workflow described here. The product list
is `registry/bread-ecosystem.toml`. New GTK tools should depend on
`bread-app` instead of copying another app's bootstrap.
## Branches
There is one long-lived branch: **`main`**. All day-to-day work lands here.
Every push to `main` automatically builds and publishes a **dev-track**
build for both products (see Tracks below) — use this to test your change
in a real install before cutting anything more formal.
New work — features and bug fixes alike — goes on a short-lived branch:
```
feature/<short-name>
fix/<issue-number-or-short-name>
```
Branch off `main`, open a PR/push back into `main` when ready. Short-lived
branches get deleted on merge — they never accumulate the kind of drift a
second long-lived branch does.
## The release cycle
There's no separate `beta` or release branch — "stable" and "beta" are both
just **tags** on `main`, not branches that need to be kept in sync:
1. Work accumulates on `main` via `feature/x` / `fix/x` branches. Each push
auto-publishes a dev build for both `bakery` and `bread-theme` — install
with `bakery track set dev` and `bakery update --all`, then fix anything
broken with another push.
2. When you want to stabilize before a real release, tag a release
candidate: `git tag vX.Y.Z-rc.1 && git push origin vX.Y.Z-rc.1` (push to
both remotes). That tag alone triggers a beta-track build —
"freezing" is just pausing pushes to `main` while you test it, not a
branch operation. Cut `-rc.2`, `-rc.3`, etc. for further fixes.
3. Once an RC has gone without issues, tag the real release:
`git tag vX.Y.Z && git push origin vX.Y.Z` — that's what triggers the
signed stable release build.
**Version honesty**: bakery's compiled `--version` is
`[workspace.package] version` in the root `Cargo.toml`. The bakery
package version in the index (what `bakery list` shows) is the git tag.
Those must match at tag time — bump `workspace.package.version` to
`X.Y.Z` *before* pushing `vX.Y.Z` or `vX.Y.Z-rc.N`. Never jump a tag
(e.g. `v0.3.1``v0.7.1`) without that Cargo.toml bump; the resulting
binary will report the old workspace version while the index claims the
new tag.
**Note**: `bakery` and `bread-theme` share the same `v*` tag pattern
(both `release-bakery.yml` and `release-bread-theme.yml` trigger on
`tags: ['v*']`, pre-existing behavior this doc isn't changing) — a single
tag push builds and publishes a release for *both* products at once. If
you ever need to release one independently of the other, that's a real gap
worth fixing in the workflow files themselves, not something to work around
by hand.
## Tracks, from a user's perspective
```
bakery track show # what you're currently on (defaults to stable)
bakery track set dev # or beta, or stable
bakery update --all # pull the latest build on your current track
```
| Track | What it is | Published from |
|--------|-----------|-----------------|
| `stable` | The last tagged release | a `vX.Y.Z` tag |
| `beta` | Latest release candidate | a `vX.Y.Z-rc.N` tag |
| `dev` | Bleeding edge | `main`, on every push |
Dev versions are auto-computed (`X.Y.Z-dev.<timestamp>+<sha>`) from the
latest published stable tag, so they always sort as newer than what you
have installed — no manual version bumping needed. Beta versions are just
the RC tag itself (already valid semver, already sorts below the real
release it's a candidate for).
## Local development
```sh
cargo build --release -p bakery
cargo test --release -p bakery
```
`bakery`, `bread-theme`, `bread-utils`, `bread-app`, `bread-polkit`,
`bread-onnx`, `bread-screenshots`, and `bread-capture` are all workspace
members. Run the same commands with `-p bread-theme --bin bread-theme`
for that crate, `-p bread-utils --features bread-client` for the IPC
client, or `-p bread-app --features bread-client` for the GTK bootstrap
helpers.
## CI
- `dev-bakery.yml` / `dev-bread-theme.yml` — triggered on push to `main`.
- `rc-bakery.yml` / `rc-bread-theme.yml` — triggered on any `vX.Y.Z-rc.N`
tag push.
- `release-bakery.yml` / `release-bread-theme.yml` — triggered on any other
`v*` tag push, cuts the actual stable release.
- `package.yml` — publishes `bakery` to the `[breadway]` pacman repo, also
tag-triggered.
All CI runs on a self-hosted runner; nothing runs automatically on plain
commits or PRs beyond the track builds above. See
[`docs/release-channels.md`](docs/release-channels.md) for the full policy,
including how a new product gets wired onto these tracks.
## Questions
Open an issue on this repo's Forgejo tracker.

669
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,9 @@
[workspace]
members = ["bakery", "bread-theme", "bread-utils", "bread-onnx"]
members = ["bakery", "bread-theme", "bread-utils", "bread-onnx", "bread-screenshots", "bread-capture", "bread-app", "bread-polkit"]
resolver = "2"
[workspace.package]
version = "0.3.0"
version = "0.7.4"
edition = "2021"
license = "MIT"
authors = ["Breadway <plasticbread849@gmail.com>"]
@ -21,6 +21,7 @@ clap = { version = "4", features = ["derive", "env"] }
chrono = "0.4"
minisign-verify = "0.2"
tracing = "0.1"
semver = "1"
[profile.release]
lto = "thin"

118
README.md
View file

@ -4,20 +4,36 @@ A collection of Rust tools for the Linux desktop (Hyprland / Wayland / Arch).
Install any product with a single command — no Rust toolchain required.
```sh
curl https://breadway.dev/get | sh
curl -fsSL https://get.breadway.dev | sh
bakery install breadbar
```
## Products
The table below is generated from [`registry/bread-ecosystem.toml`](registry/bread-ecosystem.toml). Regenerate with `scripts/gen-readme-products.sh`.
<!-- gen-readme-products:start -->
| Package | Description |
|---------|-------------|
| `bread` | Reactive automation daemon (`breadd`) + CLI — Lua scripting over Hyprland, udev, power, network, and Bluetooth events |
| `breadbar` | GTK4 status bar (workspaces, clock, CPU/RAM/battery/WiFi/Bluetooth) and D-Bus notification daemon for Hyprland |
| `breadbox` | GTK4 fuzzy app launcher for Hyprland with context-aware sorting; ships an icon-sync daemon (`breadbox-sync`) |
| `breadcrumbs` | Profile-aware Wi-Fi state machine with Tailscale exit-node management and a self-healing watch daemon |
| `breadpad` | Quick-capture scratchpad popup with AI-powered note classification, reminders, recurrence, and a full note viewer (`breadman`) |
| `bakery` | Bread ecosystem package manager |
| `bread-theme` | Shared pywal-accented, fixed-dark-base theming CLI for the bread ecosystem |
| `bread` | Reactive automation daemon and CLI for Linux desktops |
| `breadbar` | Minimal status bar and notification daemon for Hyprland |
| `breadbox` | App launcher for Hyprland / Wayland |
| `breadcrumbs` | Profile-aware Wi-Fi state machine with Tailscale integration |
| `breadpad` | Quick-capture scratchpad and note viewer with AI classification |
| `breadpaper` | Wallpaper manager for the bread desktop |
| `breadmon` | Terminal UI monitor manager for Hyprland |
| `breadsearch` | Semantic system-wide search for BOS |
| `breadclip` | Wayland clipboard history manager for Hyprland |
| `breadshot` | Screenshot utility for the bread ecosystem |
| `bos-settings` | System settings app for Bread OS |
| `breadhelp` | Onboarding and help center for Bread OS |
| `breadcast` | Cast your screen to any Chromecast/Google TV or DLNA renderer — daemon + GTK4 popup — Bakery product; not included in the BOS ISO |
| `breadarr` | Single-daemon Sonarr+Radarr+Prowlarr replacement — release watching, matching, grabbing, importing, and a terminal UI, no web UI — Homelab, not shipped on BOS |
<!-- gen-readme-products:end -->
## Recommended keybinds
@ -68,9 +84,7 @@ spacing, radii, colour roles) the stylesheet is built from.
`bakery` is the package manager for the ecosystem. Install it with the bootstrap script:
```sh
curl https://breadway.dev/get | sh
# or
curl -sSfL https://get.breadway.dev | sh
curl -fsSL https://get.breadway.dev | sh
```
The script downloads the prebuilt `bakery` binary to `~/.local/bin/bakery` and prints a note if that directory isn't on your `PATH` yet.
@ -92,6 +106,26 @@ bakery remove <pkg> # remove a package (data files are never deleted)
`bakery install` runs `doctor` first and bails with a clear message if any system dependency is missing. Binaries land in `~/.local/bin` (override with `BAKERY_BIN_DIR`).
## System prefix (BOS)
Default install root is `~/.local`. BOS sets a system prefix so bakery-managed
desktop apps live on the `@` root subvolume and ride along with
snapper/grub-btrfs snapshots:
```toml
# /etc/bakery/config.toml
prefix = "/usr/local"
```
`BAKERY_PREFIX` overrides the config file. A non-home prefix installs bins to
`$prefix/bin`, share/data/desktop/licenses to `$prefix/share/...`, and systemd
user units to `/usr/lib/systemd/user`. Per-user state (`installed.json`,
update backups) stays in `~/.local/state/bakery`. Writes that need root use
`sudo -n`, then `pkexec`. `bakery doctor` prints the active prefix.
Hermes and `get.sh` are unchanged — they keep the user-local default. See
[`bakery/README.md`](bakery/README.md).
## System dependencies by product
`bakery doctor` checks these automatically before any install. Required deps block installation; optional deps generate a warning but never block.
@ -109,22 +143,66 @@ Install all required deps with `sudo pacman -S <packages>`. Use `pacman -Q <pkg>
## Workspace
This repo is a Cargo workspace:
This repo is a Cargo workspace. Bakery-channel products shipped from here
are `bakery` and `bread-theme`; the other members are shared crates sibling
apps pin, or in-tree tools that are not bakery packages of their own.
```
bread-ecosystem/
├── bakery/ # package manager binary
├── bread-theme/ # shared pywal + fixed-dark-base theming crate
├── registry/ # bread-ecosystem.toml — product registry
├── bakery/ # package manager binary
├── bread-theme/ # shared pywal + fixed-dark-base theming crate
├── bread-utils/ # shared plumbing (Hyprland IPC, singleton, XDG, BreadClient, …)
├── bread-app/ # GTK bootstrap new tools should use (app id, singleton, overlay, command listen)
├── bread-polkit/ # themed PolicyKit agent (bakery.toml present; unpublished)
├── bread-onnx/ # shared ONNX runtime helpers
├── bread-screenshots/ # grim capture primitive used by app `--screenshot` modes
├── bread-capture/ # orchestrator that drives those `--screenshot` modes
├── registry/ # bread-ecosystem.toml — product registry
└── scripts/
├── get.sh # curl | sh bootstrap
└── gen-index.sh # generates dl.breadway.dev/index.json from release artifacts
├── get.sh # curl | sh bootstrap
├── gen-index.sh # generates dl.breadway.dev/index.json from release artifacts
└── gen-readme-products.sh # rewrites the Products table from the registry
```
### New GTK tools
Do not copy another app's `main.rs`. Depend on `bread-app`:
- `bread_app::application_id` / `try_acquire` / `toggle_or_kill` for the
`com.breadway.*` application id and single-instance lock
- feature `gtk` re-exports `bread_utils::gtk_popup` (layer-shell overlay)
- feature `bread-client` for `listen_commands` on `bread.command.<app>.**`
See the `bread-app` crate docs. Existing apps are not migrated in this
tree; `bread-polkit` is the first in-tree consumer.
### bread-polkit
A session PolicyKit authentication agent (password prompt, cancel,
identity). Not a wrapper around `polkit-gnome`. `bread-polkit/bakery.toml`
exists so it can be published via bakery; it is not in
`registry/bread-ecosystem.toml` and is therefore unpublished — not on the
bakery index and not on the BOS ISO lockfile.
```sh
cargo run -p bread-polkit
```
Autostart — pick one:
```sh
cp bread-polkit/contrib/bread-polkit.desktop ~/.config/autostart/
```
```
# hyprland.conf
exec-once = bread-polkit
```
## Release pipeline
Each product repo (`Breadway/bread`, `Breadway/breadbar`, …) has a
`.github/workflows/release.yml` that triggers on `v*` tags. The workflow
Each product repo (`Breadway/bread`, `Breadway/breadbar`, …) has
`.forgejo/workflows/release-*.yml` that triggers on `v*` tags. The workflow
runs on a self-hosted runner on hestia, builds a stripped x86_64 binary,
deposits it at `dl.breadway.dev/<pkg>/<version>/`, updates `index.json`,
and mirrors the binary to GitHub Releases as a fallback.
@ -132,6 +210,12 @@ and mirrors the binary to GitHub Releases as a fallback.
`bakery` always tries `dl.breadway.dev` first and transparently falls back
to the GitHub Release URL recorded in the manifest.
Beyond stable releases, most products also publish **dev** and **beta**
tracks — continuous builds off `main` (dev) and `vX.Y.Z-rc.N` tags (beta).
See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the branch/release workflow and
[`docs/release-channels.md`](docs/release-channels.md) for the full track
policy. Switch tracks with `bakery track set <stable|beta|dev>`.
### Release artifact contract
Each product's `release.yml` **must** upload the following files alongside

View file

@ -17,8 +17,10 @@ ureq = { workspace = true }
sha2 = { workspace = true }
hex = { workspace = true }
clap = { workspace = true }
clap_complete = "4"
chrono = { workspace = true }
minisign-verify = { workspace = true }
[dev-dependencies]
semver = { workspace = true }
bread-utils = { path = "../bread-utils" }
fs4 = { version = "0.8", features = ["sync"] }
tempfile = "3"

30
bakery/README.md Normal file
View file

@ -0,0 +1,30 @@
# bakery
Package manager for the bread ecosystem. Usage lives in the
[repo README](../README.md).
## Install prefix
Default root is `~/.local` (bins in `~/.local/bin`, data in
`~/.local/share`). That is the hermes / `get.sh` path and must stay the
default.
BOS sets a system prefix so bakery-managed desktop apps live on the `@`
root subvolume and are included in snapper/grub-btrfs snapshots:
```toml
# /etc/bakery/config.toml
prefix = "/usr/local"
```
`BAKERY_PREFIX` overrides the config file. A non-home prefix installs:
| Thing | Path |
|-------|------|
| bins | `$prefix/bin` |
| share / desktop / licenses / data | `$prefix/share/...` |
| systemd user units | `/usr/lib/systemd/user` |
Per-user state (`installed.json` and pre-update backups) stays in
`~/.local/state/bakery`. Writes that need root use `sudo -n`, then
`pkexec`. `bakery doctor` prints the active prefix.

View file

@ -1,3 +1,4 @@
use crate::ui;
use anyhow::Result;
use std::process::Command;
@ -10,18 +11,48 @@ pub struct DepReport {
pub fn check_deps(required: &[String], optional: &[String]) -> Result<DepReport> {
Ok(DepReport {
missing: required.iter().filter(|d| !dep_present(d)).cloned().collect(),
warnings: optional.iter().filter(|d| !dep_present(d)).cloned().collect(),
missing: required
.iter()
.filter(|d| !dep_present(d))
.cloned()
.collect(),
warnings: optional
.iter()
.filter(|d| !dep_present(d))
.cloned()
.collect(),
})
}
/// Arch package name -> Debian/Ubuntu package name, for the few cases where
/// they differ *and* the Debian package's own binaries don't share a name
/// with either package (so `path_has` can't bridge the gap the way it
/// already does for e.g. `ffmpeg`/`openssl`, whose package name matches
/// their own binary name on both distros). `system_deps` in `bakery.toml`
/// is always written as the Arch name — this is what makes that same
/// declaration also resolve correctly on a Debian-family bakery host like
/// hestia.
const ARCH_TO_DEBIAN_PKG: &[(&str, &str)] = &[("mkvtoolnix-cli", "mkvtoolnix")];
fn debian_name(pkg: &str) -> &str {
ARCH_TO_DEBIAN_PKG
.iter()
.find(|(arch, _)| *arch == pkg)
.map(|(_, debian)| *debian)
.unwrap_or(pkg)
}
fn dep_present(pkg: &str) -> bool {
// Primary: `pacman -Q` uses the exact Arch package name — no name mapping needed.
if pacman_installed(pkg) {
return true;
}
// Fallback for environments without pacman: native PATH search then pkg-config.
path_has(pkg) || pkg_config_exists(pkg)
if path_has(pkg) || pkg_config_exists(pkg) {
return true;
}
// Further fallback for Debian/Ubuntu hosts: dpkg, via the name map above.
dpkg_installed(debian_name(pkg))
}
fn pacman_installed(pkg: &str) -> bool {
@ -32,6 +63,17 @@ fn pacman_installed(pkg: &str) -> bool {
.unwrap_or(false)
}
fn dpkg_installed(pkg: &str) -> bool {
Command::new("dpkg-query")
.args(["-W", "-f=${Status}", pkg])
.output()
.map(|o| {
o.status.success()
&& String::from_utf8_lossy(&o.stdout).contains("install ok installed")
})
.unwrap_or(false)
}
/// Check PATH without shelling out to `which` (avoids the external dependency).
fn path_has(bin: &str) -> bool {
std::env::var_os("PATH")
@ -48,34 +90,75 @@ fn pkg_config_exists(lib: &str) -> bool {
.unwrap_or(false)
}
/// Builds the "install with: ..." hint for a list of missing Arch package
/// names, picking the command for whichever package manager is actually on
/// this host — `sudo pacman -S ...` is meaningless advice on a Debian-family
/// bakery host like hestia, which has neither `pacman` nor the Arch names.
pub fn install_hint(missing: &[String]) -> String {
if path_has("pacman") {
format!("sudo pacman -S {}", missing.join(" "))
} else if path_has("apt") {
let names: Vec<&str> = missing.iter().map(|p| debian_name(p)).collect();
format!("sudo apt install {}", names.join(" "))
} else {
format!("install: {}", missing.join(", "))
}
}
/// Print a formatted doctor report for a package's system deps.
/// Returns true if all *required* deps are satisfied.
pub fn report(package_name: &str, required: &[String], optional: &[String]) -> bool {
pub fn report(
package_name: &str,
required: &[String],
optional: &[String],
name_width: usize,
) -> bool {
if required.is_empty() && optional.is_empty() {
println!(" {package_name}: no system deps required");
ui::check_row(true, package_name, name_width, "no system deps required");
return true;
}
match check_deps(required, optional) {
Err(e) => {
eprintln!(" error running doctor for {package_name}: {e}");
ui::check_row(
false,
package_name,
name_width,
&format!("error running doctor: {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");
ui::check_row(
true,
package_name,
name_width,
"all required system deps satisfied",
);
true
} else {
eprintln!(
" {package_name}: missing system deps: {}",
rep.missing.join(", ")
ui::check_row(
false,
package_name,
name_width,
&format!("missing: {}", rep.missing.join(", ")),
);
eprintln!(
" {}",
ui::dim(&format!("install with: {}", install_hint(&rep.missing)))
);
eprintln!(" install with: sudo pacman -S {}", rep.missing.join(" "));
false
}
}
@ -105,24 +188,38 @@ mod tests {
assert!(path_has("sh"));
}
#[test]
fn debian_name_maps_known_alias() {
assert_eq!(debian_name("mkvtoolnix-cli"), "mkvtoolnix");
}
#[test]
fn debian_name_passes_through_unmapped() {
assert_eq!(debian_name("ffmpeg"), "ffmpeg");
}
// This test only runs on systems with dpkg (Debian/Ubuntu).
#[test]
#[ignore]
fn dpkg_finds_dpkg_itself() {
assert!(dpkg_installed("dpkg"));
}
#[test]
fn dpkg_missing_package_not_present() {
assert!(!dpkg_installed("this-package-does-not-exist-xyzzy42"));
}
#[test]
fn missing_required_dep_detected() {
let rep = check_deps(
&["this-package-does-not-exist-xyzzy42".to_string()],
&[],
)
.unwrap();
let rep = check_deps(&["this-package-does-not-exist-xyzzy42".to_string()], &[]).unwrap();
assert_eq!(rep.missing.len(), 1);
assert!(rep.warnings.is_empty());
}
#[test]
fn missing_optional_dep_becomes_warning_not_error() {
let rep = check_deps(
&[],
&["this-package-does-not-exist-xyzzy42".to_string()],
)
.unwrap();
let rep = check_deps(&[], &["this-package-does-not-exist-xyzzy42".to_string()]).unwrap();
assert!(rep.missing.is_empty());
assert_eq!(rep.warnings.len(), 1);
}

View file

@ -3,33 +3,27 @@ use sha2::{Digest, Sha256};
use std::path::Path;
use crate::manifest::{fetch_binary, Binary};
use crate::ui;
/// Download a binary to a temp path, verify its SHA-256, then atomically move
/// it into place. Bails before touching `dest` if the checksum fails.
pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result<()> {
println!(" downloading {}", binary.name);
/// Download a binary, verify its SHA-256, then atomically write it into
/// place (fsynced, temp-in-same-dir-with-unique-name then rename — see
/// `bread_utils::atomic`). Bails before touching `dest` if the checksum
/// fails. Returns the verified hex sha256 so callers (`install::
/// install_package`) can record it for `bakery verify` without hashing the
/// bytes a second time — `verify_sha256` already confirmed `bytes` matches
/// `binary.sha256`, so that's the value to return.
pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result<String> {
ui::step("downloading", &binary.name);
let bytes = fetch_binary(&binary.dl_url, &binary.github_url)
.with_context(|| format!("downloading {}", binary.name))?;
verify_sha256(&bytes, &binary.sha256)
.with_context(|| format!("checksum mismatch for {}", binary.name))?;
if let Some(dir) = dest.parent() {
std::fs::create_dir_all(dir)?;
}
let tmp = dest.with_extension("tmp");
std::fs::write(&tmp, &bytes).context("writing binary to tmp")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))?;
}
std::fs::rename(&tmp, dest).context("placing binary")?;
println!(" installed {}", dest.display());
Ok(())
crate::prefix::write_bytes(dest, &bytes, 0o755)
.with_context(|| format!("placing binary at {}", dest.display()))?;
ui::step("placed", &dest.display().to_string());
Ok(binary.sha256.clone())
}
/// Verify that `bytes` hashes to `expected_hex` under SHA-256.
@ -38,6 +32,9 @@ pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result<()> {
/// [`fetch_and_place`]), and config-example / systemd-unit downloads in
/// `install.rs` — so all downloaded artifacts get the same integrity check.
pub fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<()> {
if expected_hex.is_empty() {
bail!("index entry has no sha256 recorded — refusing to trust an unverifiable download");
}
let mut hasher = Sha256::new();
hasher.update(bytes);
let actual = hex::encode(hasher.finalize());
@ -80,4 +77,14 @@ mod tests {
let hash = sha256_hex(bytes);
assert!(verify_sha256(bytes, &hash).is_ok());
}
#[test]
fn verify_missing_sha256_gives_a_clear_error() {
// gen-index.sh emits an empty sha256 string when a .sha256 sidecar
// is missing — must not fall through to the generic mismatch
// message ("expected: \n actual: <hex>"), which is confusing about
// what actually went wrong.
let err = verify_sha256(b"anything", "").unwrap_err();
assert!(err.to_string().contains("no sha256 recorded"));
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,13 +1,34 @@
use crate::track::Track;
use anyhow::{bail, Context, Result};
use minisign_verify::{PublicKey, Signature};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
const PRIMARY_URL: &str = "https://dl.breadway.dev/index.json";
const SIG_URL: &str = "https://dl.breadway.dev/index.json.minisig";
const DEFAULT_BASE_URL: &str = "https://dl.breadway.dev";
const CACHE_MAX_AGE: Duration = Duration::from_secs(24 * 3600);
/// The `https://dl.breadway.dev` base can be overridden for local/staging
/// testing (e.g. serving a fake index from `python3 -m http.server`) without
/// rebuilding bakery — same pattern as `main.rs`'s `BAKERY_BIN_DIR` override.
fn base_url() -> String {
std::env::var("BAKERY_INDEX_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string())
}
/// Index URL for `track`. `Stable` keeps the exact pre-track path
/// (`{base}/index.json`) so existing infra and warm caches are unaffected;
/// `Beta`/`Dev` live under a track-prefixed subpath.
fn primary_url(track: Track) -> String {
match track {
Track::Stable => format!("{}/index.json", base_url()),
Track::Beta | Track::Dev => format!("{}/{}/index.json", base_url(), track.as_str()),
}
}
fn sig_url(track: Track) -> String {
format!("{}.minisig", primary_url(track))
}
/// The bakery index-signing public key.
///
/// The matching secret key is used offline (never on this machine, never in
@ -17,7 +38,7 @@ const CACHE_MAX_AGE: Duration = Duration::from_secs(24 * 3600);
/// bytes are trusted or parsed. This is the single control point: the
/// per-artifact `sha256` fields and `post_install` hook strings all live
/// inside `index.json` itself, so a valid signature transitively covers them.
const PUBKEY: &str = "RWRh2Zr5SUinvVFCtD7S7HwGjfrye6j31Xq2mYXRdkGFDWe3yHF7W11K";
const PUBKEY: &str = "RWTBR8w/IJ+jaylOv80b52DzekKbSR2CvOVGvzB0ipGBaMhJPAOiEWq8";
/// Verify `bytes` against `sig_text` (the contents of an `index.json.minisig`
/// file) using the pinned [`PUBKEY`]. Returns an error on any failure —
@ -31,8 +52,7 @@ fn verify_index_signature(bytes: &[u8], sig_text: &str) -> Result<()> {
/// 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 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
@ -86,6 +106,30 @@ pub struct Package {
pub config: Option<ConfigScaffold>,
#[serde(default)]
pub post_install: Vec<String>,
/// License artifact filename (e.g. "LICENSE"), installed to
/// `$prefix/share/licenses/<name>/LICENSE` (`~/.local/share/...` by
/// default) — the bakery equivalent of a PKGBUILD's `package()` step.
#[serde(default)]
pub license_file: Option<String>,
#[serde(default)]
pub license_file_sha256: Option<String>,
/// Desktop entry artifact filename (e.g. "breadhelp.desktop"),
/// installed to `$prefix/share/applications/<name>.desktop` so the
/// app shows up in any XDG-compliant launcher.
#[serde(default)]
pub desktop_file: Option<String>,
#[serde(default)]
pub desktop_file_sha256: Option<String>,
/// Data archive artifact filename (e.g. "content.tar.gz") — a `.tar.gz`
/// in the release dir, extracted to `$prefix/share/<name>/` on
/// install. For arbitrary data a package needs at runtime beyond a
/// config example (e.g. breadhelp's guide content), where a single
/// downloadable file + `tar` extraction is simpler than teaching
/// bakery to mirror a whole directory tree file-by-file.
#[serde(default)]
pub data_archive: Option<String>,
#[serde(default)]
pub data_archive_sha256: Option<String>,
}
impl Package {
@ -120,8 +164,8 @@ impl Index {
}
}
/// Load the manifest, using the on-disk cache when it is fresh enough.
/// Always fetches if `force_refresh` is true.
/// Load the manifest for `track`, using the on-disk cache when it is fresh
/// enough. Always fetches if `force_refresh` is true.
///
/// Every path — fresh fetch or cached read — verifies the minisign
/// signature over the raw `index.json` bytes before the JSON is parsed or
@ -130,52 +174,73 @@ impl Index {
/// (possibly tampered, possibly just stale-format) cache and triggers one
/// re-fetch from the network rather than bricking the CLI outright; if the
/// freshly fetched copy also fails to verify, that's a hard error.
pub fn load(force_refresh: bool) -> Result<Index> {
let cache_path = cache_path();
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) {
match read_and_verify_cache(&cache_path, &sig_cache_path) {
match read_and_verify_cache(&cache_path, &sig_cache_path, track) {
Ok(index) => return Ok(index),
Err(err) => {
eprintln!(
" warning: cached index.json failed verification ({err}), re-fetching…"
);
eprintln!(" warning: cached index.json failed verification ({err}), re-fetching…");
}
}
}
fetch_and_cache(&cache_path, &sig_cache_path)
match fetch_and_cache(&cache_path, &sig_cache_path, track) {
Ok(index) => Ok(index),
Err(fetch_err) => {
// A network error shouldn't be a hard failure when a valid
// signed cache is sitting right there on disk, even if it's
// stale (or freshness was never checked because force_refresh
// was set) — fall back to it rather than bricking the CLI.
match read_and_verify_cache(&cache_path, &sig_cache_path, track) {
Ok(index) => {
eprintln!(
" warning: could not refresh {track} index ({fetch_err}) — \
using possibly-stale cached index"
);
Ok(index)
}
Err(_) => Err(fetch_err),
}
}
}
}
fn read_and_verify_cache(cache_path: &PathBuf, sig_cache_path: &PathBuf) -> Result<Index> {
fn read_and_verify_cache(cache_path: &Path, sig_cache_path: &Path, 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)?;
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 {
fn cache_is_fresh(path: &Path) -> bool {
std::fs::metadata(path)
.and_then(|m| m.modified())
.map(|t| SystemTime::now().duration_since(t).unwrap_or(CACHE_MAX_AGE) < CACHE_MAX_AGE)
.unwrap_or(false)
}
fn fetch_and_cache(cache_path: &PathBuf, sig_cache_path: &PathBuf) -> Result<Index> {
let bytes = fetch_bytes(PRIMARY_URL)?;
let sig_text = fetch_text(SIG_URL).context(
fn fetch_and_cache(cache_path: &Path, sig_cache_path: &Path, 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)
.context("freshly fetched index.json failed signature verification")?;
.with_context(|| format!("freshly fetched {track} index failed signature verification"))?;
if let Some(dir) = cache_path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(cache_path, &bytes)?;
std::fs::write(sig_cache_path, &sig_text)?;
bread_utils::atomic::write_atomic_bytes(cache_path, &bytes, None)
.with_context(|| format!("writing cached {track} index"))?;
bread_utils::atomic::write_atomic_bytes(sig_cache_path, sig_text.as_bytes(), None)
.with_context(|| format!("writing cached {track} index signature"))?;
serde_json::from_slice(&bytes).context("parsing index.json")
}
@ -186,17 +251,23 @@ fn sig_cache_path(cache_path: &Path) -> PathBuf {
}
fn fetch_text(url: &str) -> Result<String> {
ureq::get(url)
.call()
.map_err(|e| anyhow::anyhow!("{e}"))?
.into_string()
.context("reading response body")
let bytes = fetch_bytes(url)?;
String::from_utf8(bytes).context("response is not valid UTF-8")
}
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`
@ -206,27 +277,69 @@ pub fn fetch_binary(primary_url: &str, fallback_url: &str) -> Result<Vec<u8>> {
Ok(bytes) => Ok(bytes),
Err(primary_err) => {
eprintln!(
" primary URL failed ({}), trying GitHub fallback…",
primary_err
" {}",
crate::ui::note(&format!(
"primary URL failed ({primary_err}), trying GitHub fallback…"
))
);
fetch_bytes(fallback_url).context("both primary and GitHub fallback failed")
}
}
}
/// Comfortably above any real bakery artifact — caps how much of a response
/// gets buffered into memory before any trust check runs on it.
const MAX_RESPONSE_BYTES: u64 = 256 * 1024 * 1024;
/// How often (at most) the `\r`-overwritten progress line refreshes — a
/// LAN-speed download can push way more than one chunk per 100ms, and
/// printing on every chunk would flood the terminal instead of reassuring it.
const PROGRESS_THROTTLE: Duration = Duration::from_millis(100);
const CHUNK_SIZE: usize = 64 * 1024;
fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
use std::io::Read;
let resp = ureq::get(url)
.call()
.map_err(|e| anyhow::anyhow!("{e}"))?;
use std::io::{IsTerminal, Read};
let resp = ureq::get(url).call().map_err(|e| anyhow::anyhow!("{e}"))?;
let status = resp.status();
if status != 200 {
bail!("HTTP {status} from {url}");
}
// Progress feedback only when there's a Content-Length to show progress
// against and stderr is an actual terminal — a multi-MB binary with no
// feedback at all looks like a hang, but piped/CI output shouldn't get
// `\r` noise. A manual chunked read loop (instead of one `read_to_end`)
// is what makes printing partway through the download possible, without
// pulling in a progress-bar crate for what's meant to just be reassurance.
let content_length: Option<u64> = resp.header("Content-Length").and_then(|v| v.parse().ok());
// Progress is reassurance for multi-MB binaries. A 4 KB index fetch
// drawing a 100% / 0.0 MB bar is noise, not feedback.
const MIN_PROGRESS_BYTES: u64 = 256 * 1024;
let show_progress =
content_length.is_some_and(|n| n >= MIN_PROGRESS_BYTES) && std::io::stderr().is_terminal();
let mut buf = Vec::new();
resp.into_reader()
.read_to_end(&mut buf)
.context("reading response")?;
let mut reader = resp.into_reader();
let mut chunk = [0u8; CHUNK_SIZE];
let mut last_print = std::time::Instant::now();
loop {
let n = reader.read(&mut chunk).context("reading response")?;
if n == 0 {
break;
}
buf.extend_from_slice(&chunk[..n]);
if buf.len() as u64 > MAX_RESPONSE_BYTES {
bail!("response from {url} exceeds the {MAX_RESPONSE_BYTES}-byte limit");
}
if show_progress && last_print.elapsed() >= PROGRESS_THROTTLE {
crate::ui::print_progress(buf.len() as u64, content_length.unwrap());
last_print = std::time::Instant::now();
}
}
if show_progress {
crate::ui::print_progress(buf.len() as u64, content_length.unwrap());
crate::ui::finish_progress();
}
Ok(buf)
}
@ -276,4 +389,80 @@ znmVfINB4jFDR2a4wuY8rOKlUBeSDOFjMkHYDXV3vxvAjK+r4V12ae9ZRQkfVtQ1YIEmFXbnJfbxywg+
// it must at least parse as a valid minisign public key.
PublicKey::from_base64(PUBKEY).expect("PUBKEY must be a valid minisign public key");
}
#[test]
fn stable_cache_path_matches_pre_track_filename() {
// Must stay exactly "index.json" so an existing warm cache from a
// pre-track bakery binary is still used after an upgrade.
assert_eq!(cache_path(Track::Stable).file_name().unwrap(), "index.json");
}
#[test]
fn beta_and_dev_cache_paths_are_distinct_siblings() {
let stable = cache_path(Track::Stable);
let beta = cache_path(Track::Beta);
let dev = cache_path(Track::Dev);
assert_ne!(stable, beta);
assert_ne!(stable, dev);
assert_ne!(beta, dev);
assert_eq!(beta.parent(), stable.parent());
assert_eq!(dev.parent(), stable.parent());
}
#[test]
fn stable_url_has_no_track_prefix() {
assert_eq!(
primary_url(Track::Stable),
format!("{}/index.json", base_url())
);
}
#[test]
fn beta_and_dev_urls_are_track_prefixed() {
assert_eq!(
primary_url(Track::Beta),
format!("{}/beta/index.json", base_url())
);
assert_eq!(
primary_url(Track::Dev),
format!("{}/dev/index.json", base_url())
);
}
fn minimal_package_json() -> &'static str {
r#"{
"name": "breadhelp",
"description": "test",
"version": "1.0.0",
"binaries": [],
"config": null
}"#
}
#[test]
fn license_and_desktop_fields_default_to_none_on_old_shape_json() {
// Simulates an index.json produced before license_file/desktop_file
// existed — must not fail to parse.
let pkg: Package = serde_json::from_str(minimal_package_json()).unwrap();
assert!(pkg.license_file.is_none());
assert!(pkg.license_file_sha256.is_none());
assert!(pkg.desktop_file.is_none());
assert!(pkg.desktop_file_sha256.is_none());
}
#[test]
fn license_and_desktop_fields_roundtrip() {
let mut pkg: Package = serde_json::from_str(minimal_package_json()).unwrap();
pkg.license_file = Some("LICENSE".to_string());
pkg.license_file_sha256 = Some("abc123".to_string());
pkg.desktop_file = Some("breadhelp.desktop".to_string());
pkg.desktop_file_sha256 = Some("def456".to_string());
let json = serde_json::to_string(&pkg).unwrap();
let restored: Package = serde_json::from_str(&json).unwrap();
assert_eq!(restored.license_file.as_deref(), Some("LICENSE"));
assert_eq!(restored.license_file_sha256.as_deref(), Some("abc123"));
assert_eq!(restored.desktop_file.as_deref(), Some("breadhelp.desktop"));
assert_eq!(restored.desktop_file_sha256.as_deref(), Some("def456"));
}
}

546
bakery/src/prefix.rs Normal file
View file

@ -0,0 +1,546 @@
//! Install prefix: default `~/.local`, or a system root for BOS.
//!
//! Hermes and `get.sh` keep the user-local default. BOS sets
//! `prefix = "/usr/local"` in `/etc/bakery/config.toml` (or `BAKERY_PREFIX`)
//! so bakery-managed desktop apps live on the `@` root subvolume and ride
//! along with snapper/grub-btrfs snapshots. Per-user state stays under
//! `~/.local/state/bakery` either way — bakery still records what *this*
//! user asked for; the prefix only changes where bits land on disk.
//!
//! Writes that hit `EACCES` use `sudo -n` first, then `pkexec` if a
//! graphical session is available. Interactive `sudo` (password on stdin)
//! is never used — a GUI hook must not block on a TTY prompt.
use anyhow::{bail, Context, Result};
use serde::Deserialize;
use std::ffi::OsStr;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
/// Default user-local prefix when no config/env override is set.
const DEFAULT_USER_PREFIX: &str = ".local";
/// System-wide user units, used when the prefix is not under `$HOME`.
const SYSTEM_USER_UNIT_DIR: &str = "/usr/lib/systemd/user";
const SYSTEM_CONFIG_PATH: &str = "/etc/bakery/config.toml";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Layout {
pub prefix: PathBuf,
pub bin_dir: PathBuf,
pub share_dir: PathBuf,
pub systemd_user_dir: PathBuf,
/// True when `prefix` is not under the user's home directory.
pub is_system: bool,
}
impl Layout {
pub fn kind_label(&self) -> &'static str {
if self.is_system {
"system"
} else {
"user"
}
}
/// Map a (possibly custom) prefix onto bin/share/unit paths.
/// `bin_override` is `--bin-dir` / `BAKERY_BIN_DIR` and wins for bins only.
pub fn from_prefix(prefix: &Path, bin_override: Option<PathBuf>) -> Self {
let prefix = normalize_prefix_path(prefix);
let is_system = is_system_prefix(&prefix);
let bin_dir = bin_override.unwrap_or_else(|| prefix.join("bin"));
let share_dir = prefix.join("share");
let systemd_user_dir = if is_system {
PathBuf::from(SYSTEM_USER_UNIT_DIR)
} else {
user_systemd_dir()
};
Self {
prefix,
bin_dir,
share_dir,
systemd_user_dir,
is_system,
}
}
/// Historical default: `~/.local` bins, XDG data dir for share,
/// `~/.config/systemd/user` for units. Used when neither `BAKERY_PREFIX`
/// nor `/etc/bakery/config.toml` sets a prefix — hermes / get.sh.
pub fn user_default(bin_override: Option<PathBuf>) -> Self {
let prefix = default_user_prefix();
let bin_dir = bin_override.unwrap_or_else(|| prefix.join("bin"));
let share_dir = dirs::data_dir().unwrap_or_else(|| prefix.join("share"));
Self {
prefix,
bin_dir,
share_dir,
systemd_user_dir: user_systemd_dir(),
is_system: false,
}
}
}
/// Resolve the active layout. `BAKERY_PREFIX` wins over `/etc/bakery/config.toml`;
/// neither set keeps the `~/.local` default. `bin_override` is the existing
/// `--bin-dir` / `BAKERY_BIN_DIR` knob.
pub fn resolve(bin_override: Option<PathBuf>) -> Layout {
let env = std::env::var("BAKERY_PREFIX").ok();
resolve_from(env.as_deref(), Path::new(SYSTEM_CONFIG_PATH), bin_override)
}
/// Same as [`resolve`] with the env value and config path injected, so
/// tests don't have to mutate process-global env or touch `/etc`.
pub fn resolve_from(
env_prefix: Option<&str>,
config_path: &Path,
bin_override: Option<PathBuf>,
) -> Layout {
match configured_prefix_from(env_prefix, config_path) {
Some(prefix) => Layout::from_prefix(&prefix, bin_override),
None => Layout::user_default(bin_override),
}
}
pub fn configured_prefix_from(env_prefix: Option<&str>, config_path: &Path) -> Option<PathBuf> {
if let Some(raw) = env_prefix {
let trimmed = raw.trim();
if !trimmed.is_empty() {
return Some(normalize_prefix(trimmed));
}
}
load_config_prefix(config_path)
}
#[derive(Debug, Default, Deserialize)]
struct BakeryConfig {
prefix: Option<String>,
}
/// Reads `prefix = "..."` from a bakery config file. Missing file or empty
/// key → `None` (caller falls back to the user-local default). A file that
/// exists but fails to parse is warned about, not treated as fatal — a typo
/// in `/etc/bakery/config.toml` must not take down `bakery list`.
pub fn load_config_prefix(path: &Path) -> Option<PathBuf> {
if !path.exists() {
return None;
}
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) => {
eprintln!(
" {}",
crate::ui::warn(&format!("could not read {}: {e}", path.display()))
);
return None;
}
};
match toml::from_str::<BakeryConfig>(&text) {
Ok(cfg) => cfg
.prefix
.as_deref()
.map(str::trim)
.filter(|p| !p.is_empty())
.map(normalize_prefix),
Err(e) => {
eprintln!(
" {}",
crate::ui::warn(&format!("could not parse {}: {e}", path.display()))
);
None
}
}
}
fn default_user_prefix() -> PathBuf {
home_dir().join(DEFAULT_USER_PREFIX)
}
fn home_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| PathBuf::from("~"))
}
fn user_systemd_dir() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| home_dir().join(".config"))
.join("systemd/user")
}
fn is_system_prefix(prefix: &Path) -> bool {
match dirs::home_dir() {
Some(home) => !prefix.starts_with(&home),
None => true,
}
}
fn normalize_prefix(raw: &str) -> PathBuf {
normalize_prefix_path(&expand_tilde(raw))
}
fn normalize_prefix_path(path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(path)
}
}
fn expand_tilde(path: &str) -> PathBuf {
if path == "~" {
home_dir()
} else if let Some(rest) = path.strip_prefix("~/") {
home_dir().join(rest)
} else {
PathBuf::from(path)
}
}
fn is_permission_denied(err: &io::Error) -> bool {
err.kind() == io::ErrorKind::PermissionDenied
}
pub fn privilege_denied_msg(dest: &Path) -> String {
format!(
"permission denied writing {} — need root for this prefix. \
bakery tried `sudo -n` then `pkexec`; neither succeeded. \
Run from a root shell, grant passwordless sudo -n for install/rm/tar, \
or install a polkit rule. bakery will not prompt for a sudo password.",
dest.display()
)
}
fn has_graphical_session() -> bool {
std::env::var_os("WAYLAND_DISPLAY").is_some() || std::env::var_os("DISPLAY").is_some()
}
/// Write `bytes` to `dest`, creating parent dirs. Escalates on `EACCES`.
pub fn write_bytes(dest: &Path, bytes: &[u8], mode: u32) -> Result<()> {
match bread_utils::atomic::write_atomic_bytes(dest, bytes, Some(mode)) {
Ok(()) => Ok(()),
Err(e) if is_permission_denied(&e) => write_bytes_privileged(dest, bytes, mode),
Err(e) => Err(e).with_context(|| format!("writing {}", dest.display())),
}
}
fn write_bytes_privileged(dest: &Path, bytes: &[u8], mode: u32) -> Result<()> {
let mut tmp =
tempfile::NamedTempFile::new().context("creating temp file for privileged write")?;
tmp.write_all(bytes)
.and_then(|_| tmp.flush())
.and_then(|_| tmp.as_file().sync_all())
.context("writing temp file for privileged write")?;
let mode_str = format!("{mode:o}");
run_privileged(
Path::new("/usr/bin/install"),
&[
OsStr::new("-D"),
OsStr::new("-m"),
OsStr::new(&mode_str),
tmp.path().as_os_str(),
dest.as_os_str(),
],
dest,
)
}
pub fn create_dir_all(path: &Path) -> Result<()> {
match std::fs::create_dir_all(path) {
Ok(()) => Ok(()),
Err(e) if is_permission_denied(&e) => run_privileged(
Path::new("/usr/bin/install"),
&[
OsStr::new("-d"),
OsStr::new("-m"),
OsStr::new("755"),
path.as_os_str(),
],
path,
),
Err(e) => Err(e).with_context(|| format!("creating directory {}", path.display())),
}
}
pub fn remove_file(path: &Path) -> Result<()> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) if is_permission_denied(&e) => run_privileged(
Path::new("/usr/bin/rm"),
&[OsStr::new("-f"), path.as_os_str()],
path,
),
Err(e) => Err(e).with_context(|| format!("removing {}", path.display())),
}
}
pub fn remove_dir_all(path: &Path) -> Result<()> {
match std::fs::remove_dir_all(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) if is_permission_denied(&e) => run_privileged(
Path::new("/usr/bin/rm"),
&[OsStr::new("-rf"), path.as_os_str()],
path,
),
Err(e) => Err(e).with_context(|| format!("removing {}", path.display())),
}
}
/// Extract `archive` (a `.tar.gz`) into `dest_dir`. Escalates the `tar`
/// invocation when `dest_dir` is not writable by this user — typical for
/// `$prefix/share/<pkg>` under `/usr/local`.
pub fn extract_tar_gz(archive: &Path, dest_dir: &Path) -> Result<()> {
create_dir_all(dest_dir)?;
if dir_writable_by_self(dest_dir) {
let status = Command::new("tar")
.args([
"xzf",
&archive.to_string_lossy(),
"--no-same-owner",
"--no-same-permissions",
"-C",
])
.arg(dest_dir)
.status()
.with_context(|| format!("running tar to extract {}", archive.display()))?;
if !status.success() {
bail!("tar exited with {status} extracting {}", archive.display());
}
return Ok(());
}
run_privileged(
Path::new("/usr/bin/tar"),
&[
OsStr::new("xzf"),
archive.as_os_str(),
OsStr::new("--no-same-owner"),
OsStr::new("--no-same-permissions"),
OsStr::new("-C"),
dest_dir.as_os_str(),
],
dest_dir,
)
}
fn dir_writable_by_self(dir: &Path) -> bool {
tempfile::Builder::new()
.prefix(".bakery-wprobe-")
.tempfile_in(dir)
.is_ok()
}
fn run_privileged(program: &Path, args: &[&OsStr], dest: &Path) -> Result<()> {
// `sudo -n` never prompts; stdin is null so a misconfigured sudoers
// can't fall through to a password read on a GUI hook's non-tty stdin.
let sudo = Command::new("sudo")
.arg("-n")
.arg(program)
.args(args)
.stdin(Stdio::null())
.status();
if matches!(sudo, Ok(status) if status.success()) {
return Ok(());
}
// pkexec pops a polkit dialog — only useful with a display, and the
// one acceptable password prompt (GUI, not a stolen sudo TTY).
if has_graphical_session() {
let pk = Command::new("pkexec").arg(program).args(args).status();
if matches!(pk, Ok(status) if status.success()) {
return Ok(());
}
}
bail!("{}", privilege_denied_msg(dest))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn user_default_is_not_system_and_uses_local_bin() {
let layout = Layout::user_default(None);
assert!(!layout.is_system);
assert_eq!(layout.prefix, default_user_prefix());
assert_eq!(layout.bin_dir, default_user_prefix().join("bin"));
assert_eq!(layout.kind_label(), "user");
assert!(layout.systemd_user_dir.ends_with(Path::new("systemd/user")));
assert_ne!(layout.systemd_user_dir, PathBuf::from(SYSTEM_USER_UNIT_DIR));
}
#[test]
fn user_default_honors_bin_override() {
let layout = Layout::user_default(Some(PathBuf::from("/tmp/custom-bins")));
assert_eq!(layout.bin_dir, PathBuf::from("/tmp/custom-bins"));
assert!(!layout.is_system);
assert_eq!(layout.prefix, default_user_prefix());
}
#[test]
fn usr_local_is_system_layout() {
let layout = Layout::from_prefix(Path::new("/usr/local"), None);
assert!(layout.is_system);
assert_eq!(layout.prefix, PathBuf::from("/usr/local"));
assert_eq!(layout.bin_dir, PathBuf::from("/usr/local/bin"));
assert_eq!(layout.share_dir, PathBuf::from("/usr/local/share"));
assert_eq!(layout.systemd_user_dir, PathBuf::from(SYSTEM_USER_UNIT_DIR));
assert_eq!(layout.kind_label(), "system");
}
#[test]
fn custom_home_prefix_is_not_system() {
let home = dirs::home_dir().expect("home dir");
let prefix = home.join("apps");
let layout = Layout::from_prefix(&prefix, None);
assert!(!layout.is_system);
assert_eq!(layout.bin_dir, prefix.join("bin"));
assert_eq!(layout.share_dir, prefix.join("share"));
assert_ne!(layout.systemd_user_dir, PathBuf::from(SYSTEM_USER_UNIT_DIR));
}
#[test]
fn temp_prefix_maps_bin_and_share_under_prefix() {
let dir = tempdir().unwrap();
let layout = Layout::from_prefix(dir.path(), None);
assert_eq!(layout.bin_dir, dir.path().join("bin"));
assert_eq!(layout.share_dir, dir.path().join("share"));
// /tmp is not under $HOME, so this is a system-shaped prefix —
// units would go to /usr/lib/systemd/user. Writes still try
// unprivileged first, so tests can use a temp prefix without sudo.
assert!(layout.is_system);
assert_eq!(layout.systemd_user_dir, PathBuf::from(SYSTEM_USER_UNIT_DIR));
}
#[test]
fn bin_override_does_not_move_share_or_units() {
let layout = Layout::from_prefix(
Path::new("/usr/local"),
Some(PathBuf::from("/opt/override/bin")),
);
assert_eq!(layout.bin_dir, PathBuf::from("/opt/override/bin"));
assert_eq!(layout.share_dir, PathBuf::from("/usr/local/share"));
assert_eq!(layout.systemd_user_dir, PathBuf::from(SYSTEM_USER_UNIT_DIR));
}
#[test]
fn load_config_prefix_reads_value() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, "prefix = \"/usr/local\"\n").unwrap();
assert_eq!(load_config_prefix(&path), Some(PathBuf::from("/usr/local")));
}
#[test]
fn load_config_prefix_expands_tilde() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, "prefix = \"~/.local\"\n").unwrap();
assert_eq!(load_config_prefix(&path), Some(default_user_prefix()));
}
#[test]
fn load_config_prefix_missing_file_is_none() {
assert_eq!(
load_config_prefix(Path::new("/no/such/bakery-config.toml")),
None
);
}
#[test]
fn load_config_prefix_ignores_empty_value() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, "prefix = \"\"\n").unwrap();
assert_eq!(load_config_prefix(&path), None);
}
#[test]
fn load_config_prefix_malformed_is_none() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, "prefix = [\n").unwrap();
assert_eq!(load_config_prefix(&path), None);
}
#[test]
fn env_prefix_wins_over_config() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, "prefix = \"/usr/local\"\n").unwrap();
let layout = resolve_from(Some("/opt/bread"), &path, None);
assert_eq!(layout.prefix, PathBuf::from("/opt/bread"));
assert_eq!(layout.bin_dir, PathBuf::from("/opt/bread/bin"));
assert!(layout.is_system);
}
#[test]
fn empty_env_falls_through_to_config() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, "prefix = \"/usr/local\"\n").unwrap();
let layout = resolve_from(Some(" "), &path, None);
assert_eq!(layout.prefix, PathBuf::from("/usr/local"));
}
#[test]
fn no_env_no_config_is_user_default() {
let dir = tempdir().unwrap();
let path = dir.path().join("missing.toml");
let layout = resolve_from(None, &path, None);
assert_eq!(layout, Layout::user_default(None));
}
#[test]
fn write_bytes_to_writable_temp_prefix_needs_no_root() {
let dir = tempdir().unwrap();
let dest = dir.path().join("bin").join("foo");
write_bytes(&dest, b"hello", 0o755).unwrap();
assert_eq!(fs::read(&dest).unwrap(), b"hello");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
fs::metadata(&dest).unwrap().permissions().mode() & 0o777,
0o755
);
}
}
#[test]
fn create_and_remove_under_temp_prefix() {
let dir = tempdir().unwrap();
let nested = dir.path().join("share/licenses/pkg");
create_dir_all(&nested).unwrap();
assert!(nested.is_dir());
let file = nested.join("LICENSE");
write_bytes(&file, b"MIT\n", 0o644).unwrap();
remove_file(&file).unwrap();
assert!(!file.exists());
remove_dir_all(&dir.path().join("share")).unwrap();
assert!(!dir.path().join("share").exists());
}
#[test]
fn privilege_denied_msg_names_the_dest() {
let msg = privilege_denied_msg(Path::new("/usr/local/bin/breadd"));
assert!(msg.contains("/usr/local/bin/breadd"));
assert!(msg.contains("sudo -n"));
assert!(msg.contains("pkexec"));
assert!(msg.contains("will not prompt"));
}
#[test]
fn is_system_prefix_classifies_home_and_usr() {
let home = dirs::home_dir().expect("home dir");
assert!(!is_system_prefix(&home.join(".local")));
assert!(is_system_prefix(Path::new("/usr/local")));
assert!(is_system_prefix(Path::new("/opt/bread")));
}
}

View file

@ -1,4 +1,6 @@
use crate::track::Track;
use anyhow::{Context, Result};
use fs4::FileExt;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
@ -10,10 +12,34 @@ pub struct InstalledPackage {
pub binaries: Vec<String>,
pub services: Vec<String>,
pub installed_at: String,
// `#[serde(default)]` so an installed.json written before per-package
// track tracking existed still deserializes — defaults to Stable, same
// convention as `State.track` above.
#[serde(default)]
pub track: Track,
/// The version this package was upgraded from, if any — `bakery
/// rollback` uses this to find the matching local backup dir. `None` on
/// a fresh first-time install. `#[serde(default)]` for the same
/// old-shape-json reason as `track` above.
#[serde(default)]
pub previous_version: Option<String>,
/// SHA-256 (hex) of each installed binary, captured at install time.
/// `bakery verify` recomputes these from disk and compares against this
/// recorded value rather than a fresh index lookup — the index only
/// carries the checksum for whatever the *current latest* release is,
/// which may not match what's actually installed. Empty on installs
/// that predate this field.
#[serde(default)]
pub binary_sha256: HashMap<String, String>,
}
#[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>,
}
@ -29,16 +55,36 @@ impl State {
pub fn save(&self) -> Result<()> {
let path = state_path();
if let Some(dir) = path.parent() {
let text = serde_json::to_string_pretty(self)?;
bread_utils::atomic::write_atomic(&path, &text, None).context("writing installed.json")
}
/// Runs `f` against a freshly-loaded `State` while holding an exclusive
/// lock on a sibling `installed.json.lock` file, saving the result if `f`
/// succeeds. Without this, two concurrent `bakery` invocations each
/// load-mutate-save `installed.json` independently and the second save
/// silently drops the first's change — the lock serializes the whole
/// read-modify-write instead of just the final write.
pub fn with_lock<T>(f: impl FnOnce(&mut State) -> Result<T>) -> Result<T> {
let lock_path = PathBuf::from(format!("{}.lock", state_path().display()));
if let Some(dir) = lock_path.parent() {
std::fs::create_dir_all(dir)?;
}
let text = serde_json::to_string_pretty(self)?;
// Write to a temp file then rename for atomicity — avoids a torn write
// if the process is killed mid-save.
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, &text).context("writing installed.json.tmp")?;
std::fs::rename(&tmp, &path).context("atomically replacing installed.json")?;
Ok(())
let lock_file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&lock_path)
.context("opening installed.json.lock")?;
lock_file
.lock_exclusive()
.context("locking installed.json.lock")?;
let mut state = Self::load()?;
let result = f(&mut state)?;
state.save()?;
// Lock releases when `lock_file` drops at end of scope.
Ok(result)
}
pub fn is_installed(&self, name: &str) -> bool {
@ -52,16 +98,40 @@ 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_base_dir() -> PathBuf {
dirs::state_dir().unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("~"))
.join(".local/state")
})
}
fn state_path() -> PathBuf {
dirs::state_dir()
.unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("~"))
.join(".local/state")
})
.join("bakery/installed.json")
bakery_state_dir().join("installed.json")
}
/// Per-user bakery state dir (`~/.local/state/bakery`). Independent of the
/// install prefix — system-prefix installs still record what this user asked for.
pub fn bakery_state_dir() -> PathBuf {
state_base_dir().join("bakery")
}
/// Local backup dir for `pkg_name`'s `version` binaries, populated by
/// `install::install_package` right before an update overwrites the
/// previous binaries and consumed by `bakery rollback`. See
/// `install::backup_current_binary`'s doc comment for why this is a local
/// snapshot rather than a re-fetch of the old version from the server.
pub fn backup_dir(pkg_name: &str, version: &str) -> PathBuf {
state_base_dir()
.join("bakery/backups")
.join(pkg_name)
.join(version)
}
#[cfg(test)]
@ -75,6 +145,9 @@ mod tests {
binaries: vec![],
services: vec![],
installed_at: "2026-01-01T00:00:00Z".to_string(),
track: Track::Stable,
previous_version: None,
binary_sha256: HashMap::new(),
}
}
@ -101,6 +174,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();
@ -110,11 +200,76 @@ mod tests {
binaries: vec!["bar".to_string()],
services: vec!["bar.service".to_string()],
installed_at: "2026-06-01T00:00:00Z".to_string(),
track: Track::Beta,
previous_version: Some("1.0.0".to_string()),
binary_sha256: HashMap::from([("bar".to_string(), "abc123".to_string())]),
});
let json = serde_json::to_string(&state).unwrap();
let restored: State = serde_json::from_str(&json).unwrap();
assert!(restored.is_installed("bar"));
assert_eq!(restored.packages["bar"].version, "2.0.0");
assert_eq!(restored.packages["bar"].services, ["bar.service"]);
assert_eq!(restored.packages["bar"].track, Track::Beta);
assert_eq!(
restored.packages["bar"].previous_version.as_deref(),
Some("1.0.0")
);
assert_eq!(restored.packages["bar"].binary_sha256["bar"], "abc123");
}
#[test]
fn installed_package_track_defaults_to_stable_on_old_shape_json() {
// Simulates an installed.json entry written before per-package track
// tracking existed.
let old_shape = r#"{"name":"foo","version":"1.0.0","binaries":[],"services":[],"installed_at":"2026-01-01T00:00:00Z"}"#;
let installed: InstalledPackage = serde_json::from_str(old_shape).unwrap();
assert_eq!(installed.track, Track::Stable);
}
#[test]
fn installed_package_previous_version_and_binary_sha256_default_on_old_shape_json() {
// Simulates an installed.json entry written before rollback/verify
// support existed.
let old_shape = r#"{"name":"foo","version":"1.0.0","binaries":[],"services":[],"installed_at":"2026-01-01T00:00:00Z","track":"stable"}"#;
let installed: InstalledPackage = serde_json::from_str(old_shape).unwrap();
assert!(installed.previous_version.is_none());
assert!(installed.binary_sha256.is_empty());
}
#[test]
fn bakery_state_dir_is_under_state_home_and_independent_of_prefix() {
let dir = bakery_state_dir();
assert!(dir.ends_with("bakery"));
// Must not follow BAKERY_PREFIX — state is always per-user.
assert!(!dir.starts_with("/usr/local"));
}
#[test]
fn backup_dir_is_distinct_per_package_and_version() {
let a = backup_dir("bakery", "0.3.1");
let b = backup_dir("bakery", "0.3.2");
let c = backup_dir("breadhelp", "0.3.1");
assert_ne!(a, b);
assert_ne!(a, c);
assert!(a.ends_with("bakery/backups/bakery/0.3.1"));
}
#[test]
fn with_lock_persists_mutation_across_reload() {
let dir = tempfile::tempdir().unwrap();
// SAFETY (test-only): temporarily redirects the state dir env var so
// this test doesn't touch the real ~/.local/state/bakery/installed.json.
std::env::set_var("XDG_STATE_HOME", dir.path());
State::with_lock(|state| {
state.record(pkg("foo", "1.0.0"));
Ok(())
})
.unwrap();
let reloaded = State::load().unwrap();
assert!(reloaded.is_installed("foo"));
std::env::remove_var("XDG_STATE_HOME");
}
}

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

@ -0,0 +1,85 @@
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, Default, Deserialize, Serialize, ValueEnum)]
#[serde(rename_all = "lowercase")]
pub enum Track {
#[default]
Stable,
Beta,
Dev,
}
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);
}
}

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

@ -0,0 +1,457 @@
use crate::track::Track;
use clap::builder::styling::{AnsiColor, Effects, Styles};
use std::io::{IsTerminal, Write};
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";
pub const BOLD_CYAN: &str = "\x1b[1;36m";
/// Clap help styling — same cyan headers / green literals / dim placeholders
/// as the rest of bakery, so `bakery --help` doesn't look like a different
/// program from `bakery list`.
pub const CLAP_STYLES: Styles = Styles::styled()
.header(AnsiColor::Cyan.on_default().effects(Effects::BOLD))
.usage(AnsiColor::Cyan.on_default().effects(Effects::BOLD))
.literal(AnsiColor::Green.on_default().effects(Effects::BOLD))
.placeholder(AnsiColor::BrightBlack.on_default())
.error(AnsiColor::Red.on_default().effects(Effects::BOLD))
.valid(AnsiColor::Green.on_default().effects(Effects::BOLD))
.invalid(AnsiColor::Yellow.on_default().effects(Effects::BOLD));
/// 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 colors_enabled_err() -> bool {
std::env::var_os("NO_COLOR").is_none() && std::io::stderr().is_terminal()
}
pub fn style(s: &str, code: &str) -> String {
paint(s, code, colors_enabled())
}
fn style_err(s: &str, code: &str) -> String {
paint(s, code, colors_enabled_err())
}
fn paint(s: &str, code: &str, on: bool) -> String {
if on {
format!("{code}{s}{RESET}")
} else {
s.to_string()
}
}
pub fn bold(s: &str) -> String {
style(s, BOLD)
}
pub fn dim(s: &str) -> String {
style(s, DIM)
}
/// `" [beta]"` / `" [dev]"`, colored — empty string for `Stable` so the
/// common-case output is unchanged.
#[allow(dead_code)]
pub fn track_badge(track: Track) -> String {
let tag = track_tag(track);
if tag.is_empty() {
tag
} else {
format!(" {tag}")
}
}
/// `[beta]` / `[dev]` with no leading space; empty for `Stable`.
pub fn track_tag(track: Track) -> String {
match track {
Track::Stable => String::new(),
Track::Beta => style("[beta]", YELLOW),
Track::Dev => style("[dev]", MAGENTA),
}
}
pub fn ok(s: &str) -> String {
style(&format!("{s}"), GREEN)
}
pub fn fail(s: &str) -> String {
style(&format!("{s}"), RED)
}
/// Neutral "nothing to do" glyph, dim rather than green — for steady-state
/// noise like "already at latest" in `bakery update --all`, where most
/// packages hit this every run. Reusing GREEN there drowns out the
/// packages that actually changed, and meaning shouldn't depend on color
/// alone (an unusual terminal palette can make BOLD/GREEN/DIM look similar),
/// so this also carries its own glyph the way `ok`/`fail` do.
pub fn unchanged(s: &str) -> String {
style(&format!("· {s}"), DIM)
}
pub fn warn(s: &str) -> String {
style(&format!("warning: {s}"), YELLOW)
}
pub fn note(s: &str) -> String {
style(&format!("note: {s}"), DIM)
}
/// Cyan verb + bold name + dim version — the install/update/remove banner.
pub fn action(verb: &str, name: &str, version: Option<&str>) {
let mut line = format!("{} {}", style(verb, BOLD_CYAN), style(name, BOLD));
if let Some(v) = version {
line.push_str(" ");
line.push_str(&style(v, DIM));
}
println!("{line}");
}
/// Section title plus dim meta (`Packages 16 · 15 installed`).
pub fn heading(title: &str, parts: &[&str]) {
let mut line = style(title, BOLD_CYAN);
let visible: Vec<&str> = parts.iter().copied().filter(|p| !p.is_empty()).collect();
for (i, part) in visible.iter().enumerate() {
line.push_str(" ");
if i > 0 {
line.push_str(&style("·", DIM));
line.push_str(" ");
}
line.push_str(part);
}
println!("{line}");
println!();
}
pub fn summary(parts: &[&str]) {
let visible: Vec<&str> = parts.iter().copied().filter(|p| !p.is_empty()).collect();
if visible.is_empty() {
return;
}
println!();
println!("{}", style(&visible.join(" · "), BOLD));
}
/// Left-aligned verb column so install chatter (`downloading` / `placed` /
/// `unit`) lines up instead of drifting with the verb length.
pub fn step(verb: &str, detail: &str) {
println!(" {:<12} {}", dim(verb), detail);
}
pub fn kv(key: &str, value: &str) {
println!(" {:<12} {}", dim(key), value);
}
pub fn check_row(ok_flag: bool, name: &str, name_width: usize, message: &str) {
let glyph = if ok_flag {
style("", GREEN)
} else {
style("", RED)
};
println!(" {glyph} {:<name_width$} {message}", name);
}
pub fn unknown_row(name: &str, name_width: usize, message: &str) {
println!(
" {} {:<name_width$} {}",
style("?", DIM),
name,
dim(message)
);
}
pub struct CatalogRow {
pub name: String,
pub version: String,
pub installed: bool,
/// Wrapped onto following lines (descriptions).
pub detail: String,
/// Same-line suffix after the version (short dates). Empty for catalog
/// views that already use `detail`.
pub aside: String,
}
/// Two-line catalog: status glyph + aligned name/version, then a hanging
/// description (or date) wrapped to the terminal width. Column widths are
/// computed from the row set so long `-dev.` versions no longer smash the
/// old `{: <10}` pad.
pub fn print_catalog(rows: &[CatalogRow]) {
for line in format_catalog(rows, term_width()) {
println!("{line}");
}
}
pub fn format_catalog(rows: &[CatalogRow], width: usize) -> Vec<String> {
if rows.is_empty() {
return Vec::new();
}
let name_w = rows.iter().map(|r| r.name.len()).max().unwrap_or(0);
let indent = 5; // " ✓ " / " "
let detail_width = width.saturating_sub(indent).max(24);
let mut lines = Vec::new();
for row in rows {
let glyph = if row.installed {
style("", GREEN)
} else {
" ".to_string()
};
let name = style(&format!("{:<name_w$}", row.name), BOLD);
let version = style(&row.version, DIM);
let mut line = format!(" {glyph} {name} {version}");
if !row.aside.is_empty() {
line.push_str(" ");
line.push_str(&dim(&row.aside));
}
lines.push(line);
if !row.detail.is_empty() {
for wrapped in wrap_words(&row.detail, detail_width) {
lines.push(format!(" {}", dim(&wrapped)));
}
}
}
lines
}
pub fn wrap_words(text: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![text.to_string()];
}
let mut lines = Vec::new();
let mut cur = String::new();
for word in text.split_whitespace() {
if cur.is_empty() {
cur = word.to_string();
} else if cur.len() + 1 + word.len() <= width {
cur.push(' ');
cur.push_str(word);
} else {
lines.push(std::mem::take(&mut cur));
cur = word.to_string();
}
}
if !cur.is_empty() {
lines.push(cur);
}
lines
}
pub fn short_date(rfc3339: &str) -> String {
chrono::DateTime::parse_from_rfc3339(rfc3339)
.map(|dt| dt.format("%Y-%m-%d").to_string())
.unwrap_or_else(|_| rfc3339.to_string())
}
pub fn name_width<S: AsRef<str>>(names: impl IntoIterator<Item = S>) -> usize {
names
.into_iter()
.map(|s| s.as_ref().len())
.max()
.unwrap_or(0)
}
/// `\r`-overwritten download bar on stderr. Pads to a stable width so a
/// shorter later frame doesn't leave leftover characters from a longer one.
pub fn print_progress(downloaded: u64, total: u64) {
let width = term_width().clamp(40, 72);
let line = progress_line(downloaded, total, 20);
let padded = fit_width(&line, width);
eprint!("\r{padded}");
let _ = std::io::stderr().flush();
}
pub fn finish_progress() {
eprintln!();
}
pub fn progress_line(downloaded: u64, total: u64, bar_width: usize) -> String {
let dl = downloaded as f64 / 1_048_576.0;
let tot = total as f64 / 1_048_576.0;
let frac = if total == 0 {
0.0
} else {
(downloaded as f64 / total as f64).clamp(0.0, 1.0)
};
let filled = ((bar_width as f64) * frac).round() as usize;
let filled = filled.min(bar_width);
let bar = format!("{}{}", "".repeat(filled), "".repeat(bar_width - filled));
let pct = (frac * 100.0).round() as u32;
format!(
" ⇣ {} {:>3}% {:.1}/{:.1} MB",
style_err(&bar, CYAN),
pct,
dl,
tot
)
}
fn fit_width(s: &str, width: usize) -> String {
let visible = visible_len(s);
if visible >= width {
return s.to_string();
}
format!("{s}{}", " ".repeat(width - visible))
}
fn visible_len(s: &str) -> usize {
let mut n = 0;
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\u{1b}' {
if chars.peek() == Some(&'[') {
chars.next();
for next in chars.by_ref() {
if next.is_ascii_alphabetic() {
break;
}
}
}
continue;
}
n += 1;
}
n
}
pub fn term_width() -> usize {
if let Ok(w) = std::env::var("COLUMNS") {
if let Ok(n) = w.parse::<usize>() {
if n >= 40 {
return n;
}
}
}
ioctl_width().filter(|&n| n >= 40).unwrap_or(80)
}
#[cfg(unix)]
fn ioctl_width() -> Option<usize> {
use std::os::fd::AsRawFd;
#[repr(C)]
struct WinSize {
row: u16,
col: u16,
x: u16,
y: u16,
}
unsafe extern "C" {
fn ioctl(fd: i32, request: u64, argp: *mut WinSize) -> i32;
}
let mut ws = WinSize {
row: 0,
col: 0,
x: 0,
y: 0,
};
// TIOCGWINSZ on Linux.
let fd = std::io::stdout().as_raw_fd();
let ret = unsafe { ioctl(fd, 0x5413, &mut ws) };
if ret == 0 && ws.col > 0 {
Some(ws.col as usize)
} else {
None
}
}
#[cfg(not(unix))]
fn ioctl_width() -> Option<usize> {
None
}
#[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());
}
#[test]
fn unchanged_carries_a_distinct_glyph_from_ok_and_fail() {
// Meaning must survive even with colors stripped (NO_COLOR, or a
// terminal palette that makes ANSI codes look alike) — so the glyph
// itself has to differ, not just the color.
assert!(unchanged("foo").contains('·'));
assert!(!ok("foo").contains('·'));
assert!(!fail("foo").contains('·'));
}
#[test]
fn catalog_aligns_names_and_versions() {
let lines = format_catalog(
&[
CatalogRow {
name: "bakery".into(),
version: "0.7.2-dev.20260815142350+30517f1".into(),
installed: true,
detail: "Package manager".into(),
aside: String::new(),
},
CatalogRow {
name: "breadarr".into(),
version: "0.1.2".into(),
installed: false,
detail: "Homelab arr stack".into(),
aside: String::new(),
},
],
80,
);
assert_eq!(lines.len(), 4);
assert!(lines[0].contains("bakery"));
assert!(lines[0].contains("0.7.2-dev.20260815142350+30517f1"));
assert!(lines[1].contains("Package manager"));
// Shorter version is padded so the columns stay a block, not a
// ragged list — the long bakery version used to overflow `{: <10}`.
// Compare display columns, not byte offsets: the installed glyph
// is a 3-byte checkmark sitting in a 1-column slot.
let bakery_col = visible_len(&lines[0][..lines[0].find("0.7.2-dev").unwrap()]);
let breadarr_col = visible_len(&lines[2][..lines[2].find("0.1.2").unwrap()]);
assert_eq!(bakery_col, breadarr_col);
}
#[test]
fn wrap_words_breaks_on_width() {
let lines = wrap_words("one two three four", 9);
assert_eq!(lines, vec!["one two", "three", "four"]);
}
#[test]
fn progress_line_has_bar_and_percent() {
let line = progress_line(1_048_576, 2_097_152, 10);
assert!(line.contains('█'));
assert!(line.contains('░'));
assert!(line.contains("50%"));
assert!(line.contains("1.0/2.0 MB"));
}
#[test]
fn visible_len_ignores_ansi() {
assert_eq!(visible_len("hello"), 5);
assert_eq!(visible_len(&format!("{CYAN}hello{RESET}")), 5);
}
#[test]
fn short_date_from_rfc3339() {
assert_eq!(short_date("2026-08-15T14:23:50+00:00"), "2026-08-15");
}
}

20
bread-app/Cargo.toml Normal file
View file

@ -0,0 +1,20 @@
[package]
name = "bread-app"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
description = "GTK application bootstrap for bread desktop tools: app id, singleton, optional overlay popup, and command listen loop"
repository = "https://git.breadway.dev/Breadway/bread-ecosystem"
keywords = ["gtk4", "wayland", "hyprland"]
[dependencies]
bread-utils = { path = "../bread-utils" }
[features]
# Layer-shell overlay helper (`gtk_popup`). Matches `bread-utils/gtk` so a
# consumer that only wants app-id / singleton helpers does not pull GTK4.
gtk = ["bread-utils/gtk"]
# `BreadClient` listen loop on `bread.command.<app>.**`. Matches
# `bread-utils/bread-client`.
bread-client = ["bread-utils/bread-client"]

121
bread-app/src/command.rs Normal file
View file

@ -0,0 +1,121 @@
//! Command-bus helpers for `bread.command.<app>.**`.
//!
//! The `command_id` here is the breadd sibling-app id (`clip`, `box`,
//! `shot`) — often shorter than the GTK / singleton name (`breadclip`).
use crate::id::{parse_app_name, InvalidAppId};
use bread_utils::bread_client::{BreadClient, BreadEvent, Subscription};
/// Same charset as [`parse_app_name`]: a single command-bus segment.
pub fn parse_command_id(command_id: &str) -> Result<&str, InvalidAppId> {
parse_app_name(command_id)
}
/// Subscribe glob: `bread.command.<app>.**`.
pub fn command_pattern(command_id: &str) -> Result<String, InvalidAppId> {
let id = parse_command_id(command_id)?;
Ok(format!("bread.command.{id}.**"))
}
/// The verb segment of `bread.command.<app>.<verb>` (and extra trailing
/// segments, if any). `None` when the event is not addressed to
/// `command_id` or the verb is missing.
///
/// Extra dotted remainder (`bread.command.clip.stack.clear`) yields the
/// first remaining segment (`stack`) — a verb is one segment, matching
/// [`BreadClient::command`].
pub fn command_verb<'a>(event: &'a str, command_id: &str) -> Option<&'a str> {
if command_id.is_empty() {
return None;
}
let prefix = format!("bread.command.{command_id}.");
let rest = event.strip_prefix(&prefix)?;
let verb = rest.split('.').next()?;
if verb.is_empty() {
None
} else {
Some(verb)
}
}
/// Subscribe to `bread.command.<command_id>.**` and invoke `on_verb` with
/// the parsed verb plus the raw event.
///
/// Fail-silent: constructing the client and holding the subscription never
/// requires breadd to be running. Drop the returned [`Subscription`] (or
/// call [`Subscription::stop`]) to end the loop.
pub fn listen_commands<F>(command_id: &str, on_verb: F) -> Result<Subscription, InvalidAppId>
where
F: Fn(&str, BreadEvent) + Send + 'static,
{
let id = parse_command_id(command_id)?.to_string();
let client = BreadClient::connect(id.clone());
let pattern = format!("bread.command.{id}.**");
Ok(client.subscribe(pattern, move |event| {
let Some(verb) = command_verb(&event.event, &id).map(str::to_owned) else {
return;
};
on_verb(&verb, event);
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn command_pattern_uses_double_star() {
assert_eq!(command_pattern("clip").unwrap(), "bread.command.clip.**");
assert_eq!(command_pattern("shot").unwrap(), "bread.command.shot.**");
}
#[test]
fn command_pattern_rejects_invalid_id() {
assert!(command_pattern("").is_err());
assert!(command_pattern("clip.clear").is_err());
}
#[test]
fn command_verb_strips_app_prefix() {
assert_eq!(
command_verb("bread.command.clip.clear", "clip"),
Some("clear")
);
assert_eq!(
command_verb("bread.command.shot.region", "shot"),
Some("region")
);
assert_eq!(
command_verb("bread.command.shot.annotate", "shot"),
Some("annotate")
);
}
#[test]
fn command_verb_takes_first_segment_only() {
assert_eq!(
command_verb("bread.command.clip.stack.clear", "clip"),
Some("stack")
);
}
#[test]
fn command_verb_rejects_other_apps_and_missing_verb() {
assert_eq!(command_verb("bread.command.clip.clear", "shot"), None);
assert_eq!(command_verb("bread.command.clip", "clip"), None);
assert_eq!(command_verb("bread.command.clip.", "clip"), None);
assert_eq!(command_verb("bread.clip.copied", "clip"), None);
assert_eq!(command_verb("bread.command.clip.clear", ""), None);
}
#[test]
fn listen_commands_rejects_invalid_id() {
assert!(listen_commands("", |_, _| {}).is_err());
}
#[test]
fn listen_commands_stop_joins_without_a_daemon() {
let sub = listen_commands("clip", |_, _| {}).unwrap();
sub.stop();
}
}

145
bread-app/src/id.rs Normal file
View file

@ -0,0 +1,145 @@
//! App-id helpers shared by GTK tools and the singleton lock.
//!
//! The process / pid-file name (`breadbox`, `bread-polkit`) is also the
//! last segment of the GApplication id (`com.breadway.breadbox`). That is
//! *not* always the breadd command-bus id (`box`, `clip`) — see
//! [`crate::command_verb`] under feature `bread-client`.
use std::io;
use crate::singleton::{self, Acquire, Toggle};
/// Why [`parse_app_name`] / [`application_id`] rejected a string.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InvalidAppId {
/// The rejected input, owned so the error is `'static`.
pub name: String,
/// Short reason suitable for an `io::Error` / clap message.
pub reason: &'static str,
}
impl std::fmt::Display for InvalidAppId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "invalid app id '{}': {}", self.name, self.reason)
}
}
impl std::error::Error for InvalidAppId {}
/// Accept a process / GTK application name (`breadbox`, `bread-polkit`).
///
/// Rules match a GApplication id *element*: non-empty, ASCII letter first,
/// then ASCII alphanumeric / `-` / `_`. Dots are rejected so the name can
/// sit in `com.breadway.<name>` without creating extra segments.
pub fn parse_app_name(name: &str) -> Result<&str, InvalidAppId> {
if name.is_empty() {
return Err(InvalidAppId {
name: name.to_string(),
reason: "must not be empty",
});
}
let mut chars = name.chars();
let first = chars.next().expect("non-empty");
if !first.is_ascii_alphabetic() {
return Err(InvalidAppId {
name: name.to_string(),
reason: "must start with an ASCII letter",
});
}
if !chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
return Err(InvalidAppId {
name: name.to_string(),
reason: "only ASCII letters, digits, '-' and '_' are allowed",
});
}
Ok(name)
}
/// Reverse-DNS GApplication id: `com.breadway.<name>`.
pub fn application_id(app_name: &str) -> Result<String, InvalidAppId> {
let name = parse_app_name(app_name)?;
Ok(format!("com.breadway.{name}"))
}
/// [`singleton::try_acquire`] after [`parse_app_name`].
///
/// Invalid names become [`io::ErrorKind::InvalidInput`] and never touch
/// the pid file.
pub fn try_acquire(app_name: &str) -> io::Result<Acquire> {
let name =
parse_app_name(app_name).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
singleton::try_acquire(name)
}
/// [`singleton::toggle_or_kill`] after [`parse_app_name`].
pub fn toggle_or_kill(app_name: &str) -> io::Result<Toggle> {
let name =
parse_app_name(app_name).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
singleton::toggle_or_kill(name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_app_name_accepts_existing_tool_names() {
for name in ["breadbox", "breadclip", "bread-polkit", "breadcast"] {
assert_eq!(parse_app_name(name), Ok(name));
}
}
#[test]
fn parse_app_name_rejects_empty_dot_and_leading_digit() {
assert!(parse_app_name("").is_err());
assert!(parse_app_name("bread.box").is_err());
assert!(parse_app_name("1box").is_err());
assert!(parse_app_name("-box").is_err());
assert!(parse_app_name("bread box").is_err());
}
#[test]
fn application_id_uses_com_breadway_prefix() {
assert_eq!(application_id("breadbox").unwrap(), "com.breadway.breadbox");
assert_eq!(
application_id("bread-polkit").unwrap(),
"com.breadway.bread-polkit"
);
}
#[test]
fn application_id_rejects_invalid_name() {
assert!(application_id("").is_err());
assert!(application_id("bread.box").is_err());
}
#[test]
fn try_acquire_rejects_invalid_name_before_lock() {
match try_acquire("") {
Err(err) => assert_eq!(err.kind(), io::ErrorKind::InvalidInput),
Ok(_) => panic!("empty name must not acquire a lock"),
}
match try_acquire("bread.box") {
Err(err) => assert_eq!(err.kind(), io::ErrorKind::InvalidInput),
Ok(_) => panic!("dotted name must not acquire a lock"),
}
}
#[test]
fn try_acquire_accepts_valid_name() {
let name = format!("bread-app-id-test-{}", std::process::id());
match try_acquire(&name).unwrap() {
Acquire::Acquired(_guard) => {}
Acquire::HeldByOther(_) => panic!("expected first acquire to succeed"),
}
}
#[test]
fn toggle_or_kill_starts_when_nothing_else_is_running() {
let name = format!("bread-app-toggle-test-{}", std::process::id());
match toggle_or_kill(&name).unwrap() {
Toggle::Started(_guard) => {}
Toggle::KilledExisting => panic!("expected to start as the first instance"),
}
}
}

67
bread-app/src/lib.rs Normal file
View file

@ -0,0 +1,67 @@
//! GTK application bootstrap for bread desktop tools.
//!
//! New GTK tools should depend on this crate instead of copying a sixth
//! `main.rs` that wires a `com.breadway.*` application id, a
//! [`bread_utils::singleton`] lock, a layer-shell overlay, and a
//! `bread.command.<app>.**` listen loop.
//!
//! # What this is
//!
//! The pieces every bread GTK binary already copies:
//!
//! - [`application_id`] / [`parse_app_name`] — reverse-DNS id
//! (`com.breadway.breadbox`) and the same name used for the singleton
//! pid file.
//! - [`try_acquire`] / [`toggle_or_kill`] — [`bread_utils::singleton`]
//! wrappers that reject an invalid name before touching the lock.
//! - feature `gtk` — re-exports [`gtk_popup`] (`bread_utils::gtk_popup`)
//! for the full-screen overlay breadbox / breadclip / breadcast start
//! from.
//! - feature `bread-client` — [`listen_commands`] plus [`command_verb`] /
//! [`command_pattern`] so a tool can honor `bread.command.<app>.**`
//! without re-deriving the prefix strip.
//!
//! This crate does **not** migrate existing apps. Callers still own their
//! widgets, CSS, and clap. Screenshot / `--screenshot` helpers stay in
//! [`bread_utils::screenshot_cli`].
//!
//! # Example
//!
//! ```ignore
//! let _guard = match bread_app::try_acquire("breadbox")? {
//! bread_app::singleton::Acquire::Acquired(g) => g,
//! bread_app::singleton::Acquire::HeldByOther(_) => return Ok(()),
//! };
//! let app = gtk4::Application::builder()
//! .application_id(&bread_app::application_id("breadbox")?)
//! .build();
//!
//! #[cfg(feature = "gtk")]
//! app.connect_activate(|app| {
//! let window = bread_app::gtk_popup::new_overlay_window(app, "breadbox");
//! window.present();
//! });
//!
//! #[cfg(feature = "bread-client")]
//! let _commands = bread_app::listen_commands("box", |verb, event| {
//! // verb is the single segment after `bread.command.box.`
//! let _ = (verb, event);
//! })?;
//! ```
pub use bread_utils::singleton;
#[cfg(feature = "gtk")]
pub use bread_utils::gtk_popup;
mod id;
pub use id::{application_id, parse_app_name, toggle_or_kill, try_acquire, InvalidAppId};
#[cfg(feature = "bread-client")]
mod command;
#[cfg(feature = "bread-client")]
pub use bread_utils::bread_client::{BreadClient, BreadEvent, Subscription};
#[cfg(feature = "bread-client")]
pub use command::{command_pattern, command_verb, listen_commands, parse_command_id};

21
bread-capture/Cargo.toml Normal file
View file

@ -0,0 +1,21 @@
[package]
name = "bread-capture"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
description = "Orchestrator for the bread ecosystem's UI screenshot tooling: drives each app's --screenshot mode and collects the resulting PNGs"
repository = "https://git.breadway.dev/Breadway/bread-ecosystem"
keywords = ["screenshot", "ci", "tooling"]
[[bin]]
name = "bread-capture"
path = "src/main.rs"
[dependencies]
bread-utils = { path = "../bread-utils" }
clap = { workspace = true }
anyhow = { workspace = true }
# Generates the isolated capture canvas's rainbow-gradient background — see
# isolation.rs. png-only: no decoding, no other format support needed.
image = { version = "0.25", default-features = false, features = ["png"] }

View file

@ -0,0 +1,233 @@
//! Runs each capture target inside a headless Sway instance instead of the
//! operator's live desktop, so nothing on their screen (other windows, a
//! differently-themed real bar, whatever's behind a popover) can leak into a
//! capture, and the capture never flashes across their desktop either.
//!
//! This replaced an earlier nested-Hyprland approach (see git history for
//! `feature/capture-isolation` if you want the gory details). That worked,
//! but Hyprland's own backend library (Aquamarine) has no genuinely headless
//! mode when a live session already holds the seat — the only path was
//! nesting a full second Hyprland as an ordinary Wayland *client* of the
//! outer session, which meant: the outer compositor deciding the nested
//! window's pixel size (so every capture needed an outer-session
//! float+resize dispatch), the outer compositor throttling frame callbacks
//! for occluded surfaces (so the nested window also had to be *focused*, or
//! `grim` run inside it hung forever waiting on a frame that never came),
//! and — the thing that ultimately motivated dropping this approach — no way
//! to fully suppress the brief real, visible flash of that window on the
//! operator's actual screen (Lua-config Hyprland has no `keyword`-based
//! pre-emptive windowrule injection, and parking it on an untoggled special
//! workspace produced broken, half-rendered captures instead).
//!
//! wlroots (which Sway, not Hyprland, is built directly on) has a real
//! headless backend: `WLR_BACKENDS=headless` skips DRM and Wayland-client
//! backends entirely and synthesizes a virtual output with no seat/DRM-master
//! claim at all — no fight with logind over the live session's seat, and no
//! window anywhere, nested or otherwise, for the operator to ever see. Empirically
//! confirmed on this machine: zero visible footprint, `zwlr_layer_shell_v1`
//! and `zwlr_screencopy_manager_v1` both present (so a layer-shell bar and
//! `grim` both work), and a manual `grim` capture against it completes
//! instantly with no focus/occlusion dance required.
//!
//! One consequence of not nesting inside Hyprland at all: breadbar's
//! workspace list (`src/bar/workspaces.rs`, via the `hyprland` crate) talks
//! to whatever `HYPRLAND_INSTANCE_SIGNATURE` points at. Left alone, that
//! still points at the operator's real, live Hyprland instance — a data leak
//! into an otherwise-isolated capture (real workspace names/count showing up
//! in a bar screenshot that's supposed to be clean). Sway has no equivalent
//! IPC this needs to keep working, so [`Isolation::start`] unsets it;
//! breadbar already has to tolerate a missing/dead Hyprland connection
//! gracefully (it survives Hyprland restarting), so this just exercises that
//! same fallback path instead of a real error case.
//!
//! The background is a generated rainbow gradient, not a flat colour —
//! deliberately: a solid fill can't tell you whether a window that's
//! *supposed* to be translucent (breadbox/breadclip/breadsearch's
//! full-screen overlay windows, breadbar's notification/OSD surfaces) is
//! actually compositing as translucent, since a flat colour showing through
//! a flat colour still just looks flat. A continuously-varying gradient
//! makes any real transparency immediately obvious (multiple hues bleed
//! through) and any accidentally-opaque surface just as obvious (it blocks
//! the gradient out entirely, a flat rectangle where there should be colour
//! variation).
use anyhow::{bail, Context, Result};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(5);
pub struct Isolation {
child: Child,
pub wayland_display: String,
config_path: PathBuf,
background_path: PathBuf,
runtime_dir: PathBuf,
}
impl Isolation {
/// Spawn the headless instance sized to `width`x`height`, and set
/// `WAYLAND_DISPLAY` on *this* process's own environment (and unset
/// `HYPRLAND_INSTANCE_SIGNATURE`) so every subsequent
/// `bread_utils::proc::run` spawn (the target app, and in turn its own
/// `grim` calls) inherits them and lands inside the isolated instance.
pub fn start(width: u32, height: u32) -> Result<Self> {
let runtime_dir = PathBuf::from(
std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".to_string()),
);
let background_path = write_rainbow_background(width, height)?;
let config_path = write_headless_config(width, height, &background_path)?;
let before_sockets: HashSet<String> = dir_names(&runtime_dir)
.into_iter()
.filter(|n| is_wayland_socket_name(n))
.collect();
let child = Command::new("sway")
.arg("-c")
.arg(&config_path)
.env("WLR_BACKENDS", "headless")
.env("XDG_RUNTIME_DIR", &runtime_dir)
.env_remove("WAYLAND_DISPLAY")
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.context("spawning headless sway")?;
let mut isolation = Isolation {
child,
wayland_display: String::new(),
config_path,
background_path,
runtime_dir: runtime_dir.clone(),
};
match poll_for_new(&runtime_dir, &before_sockets, DISCOVERY_TIMEOUT, is_wayland_socket_name)
.context("waiting for headless sway's Wayland socket to appear")
{
Ok(name) => isolation.wayland_display = name,
Err(e) => {
// Best-effort teardown of the half-started instance before
// propagating — the normal Drop impl still runs too, but
// doing it here as well means a failure this early doesn't
// depend on isolation ever being bound to a variable that
// outlives this function.
let _ = isolation.child.kill();
let _ = isolation.child.wait();
return Err(e);
}
}
std::env::set_var("WAYLAND_DISPLAY", &isolation.wayland_display);
std::env::remove_var("HYPRLAND_INSTANCE_SIGNATURE");
Ok(isolation)
}
}
impl Drop for Isolation {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
let _ = std::fs::remove_file(&self.config_path);
let _ = std::fs::remove_file(&self.background_path);
// Killing sway doesn't unlink the socket it bound — confirmed
// empirically, a killed instance leaves both files behind — so
// without this, every capture run permanently orphans a
// `wayland-N`/`wayland-N.lock` pair in the runtime dir.
if !self.wayland_display.is_empty() {
let _ = std::fs::remove_file(self.runtime_dir.join(&self.wayland_display));
let _ = std::fs::remove_file(self.runtime_dir.join(format!("{}.lock", self.wayland_display)));
}
}
}
fn write_headless_config(width: u32, height: u32, background_path: &Path) -> Result<PathBuf> {
let path = std::env::temp_dir().join(format!("bread-capture-sway-{}.conf", std::process::id()));
let bg = background_path.display();
let contents = format!(
"output HEADLESS-1 resolution {width}x{height}\n\
output HEADLESS-1 bg {bg} stretch\n"
);
std::fs::write(&path, contents).with_context(|| format!("writing {}", path.display()))?;
Ok(path)
}
/// Renders a diagonal rainbow (full hue sweep, both x and y contribute) to a
/// PNG at exactly `width`x`height`, for use as the isolated canvas's
/// background — see the module doc for why a gradient instead of a flat
/// colour. Diagonal rather than a simple left-to-right sweep so a capture
/// showing color variation isn't just luck-of-the-x-position: a purely
/// horizontal gradient would still make a tall, narrow surface look like a
/// near-flat single hue.
fn write_rainbow_background(width: u32, height: u32) -> Result<PathBuf> {
let path = std::env::temp_dir().join(format!("bread-capture-bg-{}.png", std::process::id()));
let mut img = image::RgbImage::new(width.max(1), height.max(1));
let denom = (width + height).max(1) as f32;
for y in 0..img.height() {
for x in 0..img.width() {
let hue = ((x + y) as f32 / denom) * 360.0;
img.put_pixel(x, y, image::Rgb(hsv_to_rgb(hue, 0.85, 0.95)));
}
}
img.save(&path).with_context(|| format!("writing {}", path.display()))?;
Ok(path)
}
/// Standard HSV -> RGB conversion. `h` in degrees [0, 360), `s`/`v` in [0, 1].
fn hsv_to_rgb(h: f32, s: f32, v: f32) -> [u8; 3] {
let c = v * s;
let h_prime = (h / 60.0) % 6.0;
let x = c * (1.0 - (h_prime % 2.0 - 1.0).abs());
let m = v - c;
let (r1, g1, b1) = match h_prime as u32 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
[
((r1 + m) * 255.0).round() as u8,
((g1 + m) * 255.0).round() as u8,
((b1 + m) * 255.0).round() as u8,
]
}
fn dir_names(path: &Path) -> HashSet<String> {
std::fs::read_dir(path)
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect()
}
fn is_wayland_socket_name(name: &str) -> bool {
name.strip_prefix("wayland-")
.is_some_and(|rest| !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit()))
}
fn poll_for_new(
dir: &Path,
before: &HashSet<String>,
timeout: Duration,
relevant: impl Fn(&str) -> bool,
) -> Result<String> {
let start = Instant::now();
loop {
let after = dir_names(dir);
if let Some(name) = after.iter().find(|n| relevant(n) && !before.contains(*n)) {
return Ok(name.clone());
}
if start.elapsed() > timeout {
bail!(
"timed out after {timeout:?} waiting for a new entry in {}",
dir.display()
);
}
std::thread::sleep(Duration::from_millis(100));
}
}

251
bread-capture/src/main.rs Normal file
View file

@ -0,0 +1,251 @@
//! Orchestrator for the bread ecosystem's UI screenshot tooling.
//!
//! Drives each target app's `--screenshot <view> --output <path>` mode (see
//! `bread-screenshots` for what that mode does inside the app) and reports
//! pass/fail per view/app. Plain `bread-capture` with no flags captures
//! every known app's every view in one run — each app's binary is resolved
//! by its own bare name via `$PATH`, same as running it directly by name
//! would. `--app <name>` restricts to one app; `--app-path <path>`
//! overrides where its binary is found (and, without `--app`, also selects
//! which app by its file stem — so `--app-path ./target/release/breadbox`
//! alone still works); `--view <name>` further restricts to one view. The
//! view list for each app is looked up from [`TARGETS`] below. Each app
//! gets its own subdirectory under `--out-dir` (`<out-dir>/<app>/<view>.png`)
//! — no versioned `screenshots/vX.Y.Z/latest` structure or manifest file
//! yet, since that's still not earning its complexity over a handful of
//! apps.
//!
//! By default every capture runs inside a throwaway headless Sway instance
//! (see [`isolation`]) rather than the operator's live desktop, so another
//! window (or their own differently-themed real bar) can't leak into a
//! capture. `--no-isolate` skips that and captures directly against whatever
//! session bread-capture itself is running in — useful for debugging the
//! capture sequence itself, since you can then actually watch it happen.
mod isolation;
use anyhow::{bail, Result};
use clap::Parser;
use std::path::PathBuf;
use std::process::ExitCode;
use std::time::Duration;
const CAPTURE_TIMEOUT: Duration = Duration::from_secs(10);
/// Per-app (view name, output filename) lists. Keyed by the app's binary
/// name — see `--app-name`. Filenames are plain (no app prefix): each app
/// gets its own subdirectory under `--out-dir` (`<out-dir>/<app>/<file>`),
/// so the prefix would just be redundant with the folder name.
const TARGETS: &[(&str, &[(&str, &str)])] = &[
(
"breadbar",
&[
("bar", "bar.png"),
("control-panel", "control-panel.png"),
("connectivity-wifi", "connectivity-wifi.png"),
("connectivity-bluetooth", "connectivity-bluetooth.png"),
("media-popover", "media-popover.png"),
("notification", "notification.png"),
("notification-critical", "notification-critical.png"),
("osd-volume", "osd-volume.png"),
("osd-brightness", "osd-brightness.png"),
("wifi-add-dialog", "wifi-add-dialog.png"),
],
),
("breadbox", &[("launcher", "launcher.png")]),
("breadclip", &[("history", "history.png")]),
("breadsearch", &[("search", "search.png")]),
(
"breadpad",
&[
("popup", "popup.png"),
("reminder", "reminder.png"),
("reminder-snooze", "reminder-snooze.png"),
],
),
(
"breadhelp",
&[
("home", "home.png"),
("learn", "learn.png"),
("ask", "ask.png"),
("troubleshoot-wizard", "troubleshoot-wizard.png"),
],
),
(
"breadman",
&[
("all", "all.png"),
("upcoming", "upcoming.png"),
("todo", "todo.png"),
("reminder", "reminder.png"),
("idea", "idea.png"),
("note", "note.png"),
("question", "question.png"),
("archive", "archive.png"),
("settings", "settings.png"),
("errors", "errors.png"),
("editor", "editor.png"),
("new-note", "new-note.png"),
],
),
(
"bos-settings",
&[
("network", "network.png"),
("breadcrumbs", "breadcrumbs.png"),
("bluetooth", "bluetooth.png"),
("firewall", "firewall.png"),
("sound", "sound.png"),
("power", "power.png"),
("datetime", "datetime.png"),
("hyprland", "hyprland.png"),
("keybinds", "keybinds.png"),
("autostart", "autostart.png"),
("users", "users.png"),
("appearance", "appearance.png"),
("breadpaper", "breadpaper.png"),
("breadbar", "breadbar.png"),
("breadbox", "breadbox.png"),
("breadclip", "breadclip.png"),
("breadpad", "breadpad.png"),
("breadsearch", "breadsearch.png"),
("bread", "bread.png"),
("packages", "packages.png"),
("aur", "aur.png"),
("firmware", "firmware.png"),
("snapshots", "snapshots.png"),
("about", "about.png"),
],
),
];
#[derive(Parser)]
struct Cli {
/// Restrict to one app (see `TARGETS` for known names). Omit to capture
/// every known app's every view in one run.
#[arg(long)]
app: Option<String>,
/// Path to that app's binary (resolved via $PATH if not a path).
/// Without `--app`, this also selects *which* app by its file stem
/// (e.g. `./target/release/breadbox` -> `breadbox`) — so a single-app
/// run never needs both flags. Ignored (with a warning) if given
/// together with a multi-app run (no `--app`, and the path isn't
/// resolvable to exactly one app).
#[arg(long)]
app_path: Option<String>,
/// Restrict to one view within the selected app(s) (see each app's
/// entry in `TARGETS` for known view names). Apps that don't have a
/// view by this name are skipped, not treated as an error, since a
/// multi-app run's view names naturally don't all overlap.
#[arg(long)]
view: Option<String>,
/// Directory to write captured PNGs into.
#[arg(long, default_value = "./screenshots")]
out_dir: PathBuf,
/// Capture directly against the current session instead of a headless,
/// throwaway Sway instance. Off by default so captures can't pick up
/// whatever else is on the operator's desktop.
#[arg(long)]
no_isolate: bool,
/// Width of the isolated session's capture canvas.
#[arg(long, default_value_t = 1920)]
isolate_width: u32,
/// Height of the isolated session's capture canvas.
#[arg(long, default_value_t = 1080)]
isolate_height: u32,
}
fn known_app_names() -> String {
TARGETS.iter().map(|(n, _)| *n).collect::<Vec<_>>().join(", ")
}
/// (app_name, binary_path, views) per selected app.
type SelectedTarget = (&'static str, String, &'static [(&'static str, &'static str)]);
/// Resolves which `TARGETS` entries this run covers, and the binary path
/// to use for each.
fn selected_targets(cli: &Cli) -> Result<Vec<SelectedTarget>> {
if let Some(app) = &cli.app {
let Some((name, views)) = TARGETS.iter().find(|(n, _)| n == app) else {
bail!("no known view list for app '{app}' (known: {})", known_app_names());
};
let path = cli.app_path.clone().unwrap_or_else(|| name.to_string());
return Ok(vec![(name, path, views)]);
}
if let Some(path) = &cli.app_path {
let stem = PathBuf::from(path)
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| path.clone());
let Some((name, views)) = TARGETS.iter().find(|(n, _)| *n == stem) else {
bail!("no known view list for app '{stem}' (known: {})", known_app_names());
};
return Ok(vec![(name, path.clone(), views)]);
}
// No --app / --app-path at all: every known app, resolved by its own
// bare name via $PATH.
Ok(TARGETS.iter().map(|(name, views)| (*name, name.to_string(), *views)).collect())
}
fn main() -> Result<ExitCode> {
let cli = Cli::parse();
let targets = selected_targets(&cli)?;
if let Some(view) = &cli.view {
if !targets.iter().any(|(_, _, views)| views.iter().any(|(v, _)| v == view)) {
bail!("view '{view}' doesn't match any selected app's views");
}
}
// Bound, not dropped-and-discarded: `_isolation`'s teardown (kill the
// compositor, remove its socket/config) must run via Drop regardless of
// how this function returns below — returning an ExitCode rather than
// calling `std::process::exit` (which skips destructors entirely) is
// what makes that true on the failure path too.
let _isolation = if cli.no_isolate {
None
} else {
Some(isolation::Isolation::start(cli.isolate_width, cli.isolate_height)?)
};
let width_str = cli.isolate_width.to_string();
let height_str = cli.isolate_height.to_string();
let mut failed = false;
for (app_name, app_path, views) in &targets {
for (view, filename) in *views {
if cli.view.as_deref().is_some_and(|v| v != *view) {
continue;
}
let out_path = cli.out_dir.join(app_name).join(filename);
let out_str = out_path.to_string_lossy();
let result = bread_utils::proc::run(
app_path,
&[
"--screenshot", view,
"--output", &out_str,
"--width", &width_str,
"--height", &height_str,
],
CAPTURE_TIMEOUT,
);
if result.success {
println!("ok {app_name}/{view} -> {}", out_path.display());
} else {
failed = true;
println!("FAIL {app_name}/{view}: {}", result.stderr.trim());
}
}
}
Ok(if failed { ExitCode::FAILURE } else { ExitCode::SUCCESS })
}

View file

@ -32,3 +32,12 @@ anyhow = { workspace = true }
[dev-dependencies]
tempfile = "3"
# Dev-dependency features unify into this crate's own test/bench builds
# only, never into downstream consumers (they aren't part of the dependency
# graph a consuming app resolves) — so this doesn't compromise the
# consumer-chooses-the-backend policy above. Without it, `cargo test` here
# fails at link time (undefined OrtGetApiBase) because nothing in this
# workspace supplies a backend; none of bread-onnx's own unit tests open a
# real ONNX session, so `load-dynamic` (dlopen at runtime, no static link)
# is enough to satisfy the linker.
ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "tracing", "load-dynamic", "api-24"] }

27
bread-polkit/Cargo.toml Normal file
View file

@ -0,0 +1,27 @@
[package]
name = "bread-polkit"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
description = "Themed PolicyKit authentication agent for the bread desktop"
repository = "https://git.breadway.dev/Breadway/bread-ecosystem"
keywords = ["polkit", "gtk4", "wayland"]
[lib]
path = "src/lib.rs"
[[bin]]
name = "bread-polkit"
path = "src/main.rs"
[dependencies]
anyhow = { workspace = true }
bread-app = { path = "../bread-app", features = ["gtk"] }
bread-theme = { path = "../bread-theme", features = ["gtk"] }
gtk4 = { version = "0.11", features = ["v4_12"] }
serde = { workspace = true }
tokio = { version = "1", features = ["rt", "net", "sync", "time", "macros", "io-util", "process"] }
tracing = { workspace = true }
tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "env-filter", "std"] }
zbus = { version = "5", default-features = false, features = ["tokio"] }

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

@ -0,0 +1,11 @@
name = "bread-polkit"
description = "Themed PolicyKit authentication agent for the bread desktop"
binaries = ["bread-polkit"]
system_deps = ["gtk4", "gtk4-layer-shell", "polkit"]
optional_system_deps = ["hyprland"]
bread_deps = []
license_file = "LICENSE"
desktop_file = "bread-polkit.desktop"
[install]
post_install = []

View file

@ -0,0 +1,12 @@
[Desktop Entry]
Type=Application
Name=Bread PolicyKit Agent
Comment=Themed PolicyKit authentication agent for the bread desktop
Exec=bread-polkit
Icon=dialog-password
Terminal=false
Categories=System;Security;
StartupNotify=false
X-GNOME-Autostart-Phase=Initialization
X-GNOME-AutoRestart=true
X-GNOME-Autostart-Notify=false

View file

@ -0,0 +1,10 @@
# bread-polkit — add to hyprland.conf
#
# Session authentication agent. Copy contrib/bread-polkit.desktop to
# ~/.config/autostart/ instead if you prefer XDG autostart.
exec-once = bread-polkit
# Optional: blur the overlay panel (namespace is bread-polkit).
layerrule = blur, bread-polkit
layerrule = ignorezero, bread-polkit

300
bread-polkit/src/agent.rs Normal file
View file

@ -0,0 +1,300 @@
//! Session-bus registration and the PolicyKit1 AuthenticationAgent.
use std::collections::HashMap;
use std::sync::{Arc, OnceLock};
use anyhow::{Context, Result};
use gtk4::glib;
use gtk4::prelude::*;
use serde::{Deserialize, Serialize};
use tokio::sync::{mpsc, Mutex};
use zbus::zvariant::{OwnedValue, Type, Value};
use zbus::{connection, interface, proxy, DBusError};
use bread_polkit::helper::{discover_transport, Transport};
use bread_polkit::identity::{current_uid, pick_user, read_passwd, users_from_uids, UnixUser};
use bread_polkit::session::session_id;
use crate::auth::{self, Outcome};
use crate::ui::{self, Prompt};
pub const OBJECT_PATH: &str = "/com/breadway/PolicyKit1/AuthenticationAgent";
/// Reply from the GTK prompt.
#[derive(Debug)]
pub enum UserAction {
Submit { username: String, password: String },
Cancel,
}
#[derive(Debug, DBusError)]
#[zbus(prefix = "org.freedesktop.PolicyKit1.Error")]
enum AgentError {
#[zbus(error)]
ZBus(zbus::Error),
Failed(String),
Cancelled(String),
}
#[derive(Debug, Deserialize, Serialize, Type)]
struct Identity {
kind: String,
details: HashMap<String, OwnedValue>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Type)]
struct Subject {
kind: String,
details: HashMap<String, OwnedValue>,
}
#[proxy(
interface = "org.freedesktop.PolicyKit1.Authority",
default_service = "org.freedesktop.PolicyKit1",
default_path = "/org/freedesktop/PolicyKit1/Authority"
)]
trait Authority {
fn register_authentication_agent(
&self,
subject: &Subject,
locale: &str,
object_path: &str,
) -> zbus::Result<()>;
fn unregister_authentication_agent(
&self,
subject: &Subject,
object_path: &str,
) -> zbus::Result<()>;
}
struct Agent {
transport: Transport,
pending: Arc<Mutex<Option<mpsc::Sender<UserAction>>>>,
}
#[interface(name = "org.freedesktop.PolicyKit1.AuthenticationAgent")]
impl Agent {
async fn begin_authentication(
&mut self,
action_id: String,
message: String,
_icon_name: String,
_details: HashMap<String, String>,
cookie: String,
identities: Vec<Identity>,
) -> Result<(), AgentError> {
tracing::info!(%action_id, %cookie, "BeginAuthentication");
let users = unix_users(&identities);
let username = pick_user(&users, current_uid())
.map(|u| u.name.clone())
.ok_or_else(|| AgentError::Failed("no unix-user identity".into()))?;
let (tx, mut rx) = mpsc::channel(4);
*self.pending.lock().await = Some(tx.clone());
let prompt = Prompt {
cookie: cookie.clone(),
message: message.clone(),
action_id: action_id.clone(),
username: username.clone(),
reply: tx,
};
invoke_ui(move || {
if let Some(app) = running_app() {
ui::show_prompt(&app, prompt);
}
});
let result = self.drive_prompt(&cookie, &username, &mut rx).await;
*self.pending.lock().await = None;
let cookie_close = cookie.clone();
invoke_ui(move || ui::close_prompt(&cookie_close));
result
}
async fn cancel_authentication(&self, cookie: String) {
tracing::info!(%cookie, "CancelAuthentication");
if let Some(tx) = self.pending.lock().await.as_ref() {
let _ = tx.try_send(UserAction::Cancel);
}
invoke_ui(move || ui::close_prompt(&cookie));
}
}
impl Agent {
async fn drive_prompt(
&self,
cookie: &str,
default_user: &str,
rx: &mut mpsc::Receiver<UserAction>,
) -> Result<(), AgentError> {
loop {
match rx.recv().await {
None => {
return Err(AgentError::Cancelled("authentication prompt closed".into()));
}
Some(UserAction::Cancel) => {
return Err(AgentError::Cancelled("user cancelled".into()));
}
Some(UserAction::Submit { username, password }) => {
let user = if username.is_empty() {
default_user
} else {
username.as_str()
};
match auth::authenticate(&self.transport, user, cookie, &password).await {
Ok(Outcome::Success) => return Ok(()),
Ok(Outcome::Failure { message }) => {
let text = message
.unwrap_or_else(|| auth::default_failure_message().to_string());
let cookie = cookie.to_string();
invoke_ui(move || ui::show_retry(&cookie, &text));
}
Err(e) => {
tracing::warn!("helper: {e:#}");
let text = e.to_string();
let cookie = cookie.to_string();
invoke_ui(move || ui::show_retry(&cookie, &text));
}
}
}
}
}
}
}
fn unix_users(identities: &[Identity]) -> Vec<UnixUser> {
let mut uids = Vec::new();
for identity in identities {
if identity.kind != "unix-user" {
continue;
}
if let Some(uid) = uid_from_details(&identity.details) {
uids.push(uid);
}
}
users_from_uids(&uids, &read_passwd())
}
fn uid_from_details(details: &HashMap<String, OwnedValue>) -> Option<u32> {
let value = details.get("uid")?;
u32::try_from(value).ok().or_else(|| {
i32::try_from(value)
.ok()
.and_then(|n| u32::try_from(n).ok())
})
}
fn running_app() -> Option<gtk4::Application> {
gtk4::gio::Application::default().and_then(|app| app.downcast::<gtk4::Application>().ok())
}
/// GTK thread-default context, captured in [`spawn`] so the dbus thread
/// can `invoke` onto the UI thread instead of its own empty context.
static GTK_CTX: OnceLock<glib::MainContext> = OnceLock::new();
fn invoke_ui(f: impl FnOnce() + Send + 'static) {
let ctx = GTK_CTX
.get()
.cloned()
.unwrap_or_else(glib::MainContext::default);
ctx.invoke(f);
}
fn unix_session_subject(id: &str) -> Result<Subject> {
let value = Value::from(id.to_string());
let owned = OwnedValue::try_from(value).context("session-id variant")?;
let mut details = HashMap::new();
details.insert("session-id".into(), owned);
Ok(Subject {
kind: "unix-session".into(),
details,
})
}
/// Spawn the system-bus agent on a background thread. Returns once the
/// thread has been started; registration errors quit the GTK app.
///
/// Must be called from the GTK thread so the main context we capture is
/// the one driving the password prompt.
pub fn spawn() -> Result<()> {
let _ = GTK_CTX.set(glib::MainContext::default());
std::thread::Builder::new()
.name("bread-polkit-dbus".into())
.spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
invoke_ui(move || {
eprintln!("bread-polkit: tokio runtime failed: {e}");
if let Some(app) = running_app() {
app.quit();
}
});
return;
}
};
rt.block_on(async move {
if let Err(e) = run().await {
eprintln!("bread-polkit: {e:#}");
invoke_ui(|| {
if let Some(app) = running_app() {
app.quit();
}
});
}
});
})
.context("spawn dbus thread")?;
Ok(())
}
async fn run() -> Result<()> {
let transport = discover_transport().context(
"no polkit helper: expected /run/polkit/agent-helper.socket \
or /usr/lib/polkit-1/polkit-agent-helper-1",
)?;
tracing::info!(?transport, "using polkit helper");
let session = session_id().context(
"no session id (XDG_SESSION_ID / /proc/self/sessionid); \
cannot register a session authentication agent",
)?;
let subject = unix_session_subject(&session)?;
let locale = std::env::var("LANG").unwrap_or_else(|_| "C".into());
let agent = Agent {
transport,
pending: Arc::new(Mutex::new(None)),
};
let connection = connection::Builder::system()?
.serve_at(OBJECT_PATH, agent)?
.build()
.await
.context("system bus")?;
let authority = AuthorityProxy::new(&connection)
.await
.context("PolicyKit1 authority proxy")?;
authority
.register_authentication_agent(&subject, &locale, OBJECT_PATH)
.await
.context("RegisterAuthenticationAgent")?;
tracing::info!(%session, "registered as PolicyKit authentication agent");
std::future::pending::<()>().await;
#[allow(unreachable_code)]
{
let _ = authority
.unregister_authentication_agent(&subject, OBJECT_PATH)
.await;
Ok(())
}
}

109
bread-polkit/src/auth.rs Normal file
View file

@ -0,0 +1,109 @@
//! PAM conversation with the polkit agent helper.
use std::process::Stdio;
use anyhow::{Context, Result};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
use tokio::process::Command;
use bread_polkit::helper::{parse_helper_line, HelperLine, Transport};
/// Outcome of one helper conversation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
Success,
Failure { message: Option<String> },
}
/// Handshake + PAM loop for one password attempt.
pub async fn authenticate(
transport: &Transport,
username: &str,
cookie: &str,
password: &str,
) -> Result<Outcome> {
match transport {
Transport::Socket(path) => {
let mut stream = UnixStream::connect(path)
.await
.with_context(|| format!("connect {}", path.display()))?;
stream.write_all(username.as_bytes()).await?;
stream.write_all(b"\n").await?;
stream.write_all(cookie.as_bytes()).await?;
stream.write_all(b"\n").await?;
let (reader, writer) = stream.into_split();
converse(BufReader::new(reader), writer, password).await
}
Transport::Exec(path) => {
let mut child = Command::new(path)
.arg(username)
.env("LC_ALL", "C")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.with_context(|| format!("spawn {}", path.display()))?;
let mut stdin = child.stdin.take().context("polkit helper has no stdin")?;
let stdout = child.stdout.take().context("polkit helper has no stdout")?;
stdin.write_all(cookie.as_bytes()).await?;
stdin.write_all(b"\n").await?;
let outcome = converse(BufReader::new(stdout), stdin, password).await;
let _ = child.wait().await;
outcome
}
}
}
async fn converse<R, W>(mut reader: BufReader<R>, mut writer: W, password: &str) -> Result<Outcome>
where
R: tokio::io::AsyncRead + Unpin,
W: tokio::io::AsyncWrite + Unpin,
{
let mut last_info: Option<String> = None;
let mut line = String::new();
loop {
line.clear();
let n = reader.read_line(&mut line).await?;
if n == 0 {
return Ok(Outcome::Failure {
message: last_info.take(),
});
}
match parse_helper_line(&line) {
HelperLine::PromptEchoOff(_) => {
writer.write_all(password.as_bytes()).await?;
writer.write_all(b"\n").await?;
writer.flush().await?;
}
HelperLine::PromptEchoOn(_) => {
// Visible prompt (username, etc.) — we already sent the
// identity in the handshake. An empty line is safer than
// echoing the password.
writer.write_all(b"\n").await?;
writer.flush().await?;
}
HelperLine::ErrorMsg(msg) | HelperLine::TextInfo(msg) => {
if !msg.is_empty() {
last_info = Some(msg);
}
}
HelperLine::Success => return Ok(Outcome::Success),
HelperLine::Failure => {
return Ok(Outcome::Failure {
message: last_info.take(),
});
}
HelperLine::Other(other) => {
if !other.is_empty() {
tracing::debug!("helper: {other}");
}
}
}
}
}
/// Shared default when the helper gives no `PAM_*` text on failure.
pub fn default_failure_message() -> &'static str {
"Authentication failed. Try again."
}

205
bread-polkit/src/helper.rs Normal file
View file

@ -0,0 +1,205 @@
//! `polkit-agent-helper-1` transport and PAM line parser.
//!
//! Arch polkit 127+ talks over `/run/polkit/agent-helper.socket`. Older
//! builds still spawn the setuid helper at
//! `/usr/lib/polkit-1/polkit-agent-helper-1`. Prefer the socket when it
//! exists.
use std::path::{Path, PathBuf};
/// How this agent will talk to polkit's helper.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Transport {
/// systemd socket-activated helper (polkit 127+).
Socket(PathBuf),
/// Legacy setuid helper binary.
Exec(PathBuf),
}
const SOCKET_CANDIDATES: &[&str] = &["/run/polkit/agent-helper.socket"];
const HELPER_CANDIDATES: &[&str] = &[
"/usr/lib/polkit-1/polkit-agent-helper-1",
"/usr/libexec/polkit-1/polkit-agent-helper-1",
];
/// One stdout line from the helper after the cookie handshake.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HelperLine {
PromptEchoOff(String),
PromptEchoOn(String),
ErrorMsg(String),
TextInfo(String),
Success,
Failure,
Other(String),
}
/// Pick a live transport: `BREAD_POLKIT_SOCKET` / `BREAD_POLKIT_HELPER`
/// if set and present, otherwise the first existing well-known path.
pub fn discover_transport() -> Option<Transport> {
discover_transport_from(
std::env::var_os("BREAD_POLKIT_SOCKET")
.map(PathBuf::from)
.as_deref(),
std::env::var_os("BREAD_POLKIT_HELPER")
.map(PathBuf::from)
.as_deref(),
SOCKET_CANDIDATES,
HELPER_CANDIDATES,
|p| p.exists(),
)
}
/// Testable discovery: `exists` is injected so unit tests do not need a
/// real `/run/polkit` socket.
pub fn discover_transport_from(
socket_override: Option<&Path>,
helper_override: Option<&Path>,
sockets: &[&str],
helpers: &[&str],
exists: impl Fn(&Path) -> bool,
) -> Option<Transport> {
if let Some(path) = socket_override {
if exists(path) {
return Some(Transport::Socket(path.to_path_buf()));
}
}
for candidate in sockets {
let path = Path::new(candidate);
if exists(path) {
return Some(Transport::Socket(path.to_path_buf()));
}
}
if let Some(path) = helper_override {
if exists(path) {
return Some(Transport::Exec(path.to_path_buf()));
}
}
for candidate in helpers {
let path = Path::new(candidate);
if exists(path) {
return Some(Transport::Exec(path.to_path_buf()));
}
}
None
}
/// Parse one helper protocol line. Prefix match is case-sensitive and
/// matches polkit's own `PAM_*` / `SUCCESS` / `FAILURE` tokens.
pub fn parse_helper_line(line: &str) -> HelperLine {
let line = line.trim_end_matches(['\r', '\n']);
if line == "SUCCESS" || line.starts_with("SUCCESS") {
return HelperLine::Success;
}
if line == "FAILURE" || line.starts_with("FAILURE") {
return HelperLine::Failure;
}
if let Some(rest) = line.strip_prefix("PAM_PROMPT_ECHO_OFF") {
return HelperLine::PromptEchoOff(rest.trim().to_string());
}
if let Some(rest) = line.strip_prefix("PAM_PROMPT_ECHO_ON") {
return HelperLine::PromptEchoOn(rest.trim().to_string());
}
if let Some(rest) = line.strip_prefix("PAM_ERROR_MSG") {
return HelperLine::ErrorMsg(rest.trim().to_string());
}
if let Some(rest) = line.strip_prefix("PAM_TEXT_INFO") {
return HelperLine::TextInfo(rest.trim().to_string());
}
HelperLine::Other(line.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
use std::path::PathBuf;
#[test]
fn parse_helper_line_known_tokens() {
assert_eq!(parse_helper_line("SUCCESS"), HelperLine::Success);
assert_eq!(parse_helper_line("SUCCESS\n"), HelperLine::Success);
assert_eq!(parse_helper_line("FAILURE"), HelperLine::Failure);
assert_eq!(
parse_helper_line("PAM_PROMPT_ECHO_OFF Password:"),
HelperLine::PromptEchoOff("Password:".into())
);
assert_eq!(
parse_helper_line("PAM_PROMPT_ECHO_OFF"),
HelperLine::PromptEchoOff(String::new())
);
assert_eq!(
parse_helper_line("PAM_PROMPT_ECHO_ON login:"),
HelperLine::PromptEchoOn("login:".into())
);
assert_eq!(
parse_helper_line("PAM_ERROR_MSG Authentication failure"),
HelperLine::ErrorMsg("Authentication failure".into())
);
assert_eq!(
parse_helper_line("PAM_TEXT_INFO Account locked"),
HelperLine::TextInfo("Account locked".into())
);
assert_eq!(
parse_helper_line("garbage"),
HelperLine::Other("garbage".into())
);
}
#[test]
fn discover_prefers_socket_over_exec() {
let present: HashSet<PathBuf> = [
"/run/polkit/agent-helper.socket",
"/usr/lib/polkit-1/polkit-agent-helper-1",
]
.into_iter()
.map(PathBuf::from)
.collect();
let got = discover_transport_from(None, None, SOCKET_CANDIDATES, HELPER_CANDIDATES, |p| {
present.contains(p)
});
assert_eq!(
got,
Some(Transport::Socket(PathBuf::from(
"/run/polkit/agent-helper.socket"
)))
);
}
#[test]
fn discover_falls_back_to_helper_binary() {
let present: HashSet<PathBuf> = ["/usr/lib/polkit-1/polkit-agent-helper-1"]
.into_iter()
.map(PathBuf::from)
.collect();
let got = discover_transport_from(None, None, SOCKET_CANDIDATES, HELPER_CANDIDATES, |p| {
present.contains(p)
});
assert_eq!(
got,
Some(Transport::Exec(PathBuf::from(
"/usr/lib/polkit-1/polkit-agent-helper-1"
)))
);
}
#[test]
fn discover_override_socket_wins_when_present() {
let override_path = Path::new("/tmp/bread-polkit-test.sock");
let got = discover_transport_from(
Some(override_path),
None,
SOCKET_CANDIDATES,
HELPER_CANDIDATES,
|p| p == override_path,
);
assert_eq!(got, Some(Transport::Socket(override_path.to_path_buf())));
}
#[test]
fn discover_none_when_nothing_exists() {
let got =
discover_transport_from(None, None, SOCKET_CANDIDATES, HELPER_CANDIDATES, |_| false);
assert_eq!(got, None);
}
}

View file

@ -0,0 +1,128 @@
//! Unix-user identities from a PolicyKit `BeginAuthentication` call.
/// A `unix-user` identity the agent can authenticate as.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnixUser {
pub uid: u32,
pub name: String,
}
/// Look up `uid` in a passwd-file dump (`name:x:uid:...` lines).
pub fn name_for_uid(uid: u32, passwd: &str) -> Option<String> {
for line in passwd.lines() {
if line.starts_with('#') {
continue;
}
let mut parts = line.split(':');
let name = parts.next()?;
let _pw = parts.next()?;
let id = parts.next()?.parse::<u32>().ok()?;
if id == uid && !name.is_empty() {
return Some(name.to_string());
}
}
None
}
/// Resolve each uid to a [`UnixUser`], falling back to `uid N` when
/// `/etc/passwd` has no name.
pub fn users_from_uids(uids: &[u32], passwd: &str) -> Vec<UnixUser> {
uids.iter()
.copied()
.map(|uid| UnixUser {
uid,
name: name_for_uid(uid, passwd).unwrap_or_else(|| format!("uid {uid}")),
})
.collect()
}
/// Prefer the process's own uid when it is in `users`, otherwise the first.
pub fn pick_user<'a>(users: &'a [UnixUser], current_uid: Option<u32>) -> Option<&'a UnixUser> {
if let Some(uid) = current_uid {
if let Some(user) = users.iter().find(|u| u.uid == uid) {
return Some(user);
}
}
users.first()
}
/// Real uid from a `/proc/self/status` dump (`Uid:\t<real> ...`).
pub fn uid_from_status(status: &str) -> Option<u32> {
for line in status.lines() {
let Some(rest) = line.strip_prefix("Uid:") else {
continue;
};
return rest.split_whitespace().next()?.parse().ok();
}
None
}
/// Current real uid, or `None` if `/proc/self/status` is unreadable.
pub fn current_uid() -> Option<u32> {
let status = std::fs::read_to_string("/proc/self/status").ok()?;
uid_from_status(&status)
}
/// Contents of `/etc/passwd`, or empty if unreadable.
pub fn read_passwd() -> String {
std::fs::read_to_string("/etc/passwd").unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
const PASSWD: &str = "\
# comment
root:x:0:0:root:/root:/bin/sh
alice:x:1000:1000:Alice:/home/alice:/bin/zsh
bob:x:1001:1001:Bob:/home/bob:/bin/bash
";
#[test]
fn name_for_uid_reads_passwd_lines() {
assert_eq!(name_for_uid(0, PASSWD).as_deref(), Some("root"));
assert_eq!(name_for_uid(1000, PASSWD).as_deref(), Some("alice"));
assert_eq!(name_for_uid(99, PASSWD), None);
}
#[test]
fn users_from_uids_falls_back_to_uid_label() {
let users = users_from_uids(&[1000, 42], PASSWD);
assert_eq!(
users,
vec![
UnixUser {
uid: 1000,
name: "alice".into()
},
UnixUser {
uid: 42,
name: "uid 42".into()
},
]
);
}
#[test]
fn pick_user_prefers_current_uid() {
let users = users_from_uids(&[0, 1000], PASSWD);
let picked = pick_user(&users, Some(1000)).unwrap();
assert_eq!(picked.name, "alice");
}
#[test]
fn pick_user_falls_back_to_first() {
let users = users_from_uids(&[0, 1000], PASSWD);
let picked = pick_user(&users, Some(7)).unwrap();
assert_eq!(picked.name, "root");
assert!(pick_user(&[], Some(1000)).is_none());
}
#[test]
fn uid_from_status_reads_real_uid() {
let status = "Name:\tbread-polkit\nUid:\t1000\t1000\t1000\t1000\n";
assert_eq!(uid_from_status(status), Some(1000));
assert_eq!(uid_from_status("Name:\tfoo\n"), None);
}
}

10
bread-polkit/src/lib.rs Normal file
View file

@ -0,0 +1,10 @@
//! Non-GTK PolicyKit helper logic for `bread-polkit`.
//!
//! The binary (`bread-polkit`) registers as a session authentication
//! agent and shows a themed password prompt. This library is the
//! transport / identity / session parsing that can be unit-tested
//! without a display.
pub mod helper;
pub mod identity;
pub mod session;

94
bread-polkit/src/main.rs Normal file
View file

@ -0,0 +1,94 @@
//! bread-polkit — themed PolicyKit authentication agent.
//!
//! Registers on the `org.freedesktop.PolicyKit1.AuthenticationAgent`
//! interface and shows a bread-theme GTK4 password prompt. This is an
//! agent, not a wrapper that execs `polkit-gnome`.
//!
//! Autostart: copy `contrib/bread-polkit.desktop` to
//! `~/.config/autostart/`, or add `exec-once = bread-polkit` to Hyprland.
mod agent;
mod auth;
mod ui;
use bread_app::singleton::Acquire;
use gtk4::prelude::*;
const APP_NAME: &str = "bread-polkit";
fn main() {
let arg = std::env::args().nth(1);
match arg.as_deref() {
Some("-h") | Some("--help") => {
print_help();
return;
}
Some("-V") | Some("--version") => {
println!("bread-polkit {}", env!("CARGO_PKG_VERSION"));
return;
}
Some(other) => {
eprintln!("bread-polkit: unknown argument '{other}'");
print_help();
std::process::exit(2);
}
None => {}
}
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.with_target(false)
.init();
let _guard = match bread_app::try_acquire(APP_NAME) {
Ok(Acquire::Acquired(g)) => Some(g),
Ok(Acquire::HeldByOther(pid)) => {
eprintln!("bread-polkit: already running (pid {pid:?})");
std::process::exit(0);
}
Err(e) => {
eprintln!("bread-polkit: singleton lock unavailable ({e}); continuing");
None
}
};
let app_id = bread_app::application_id(APP_NAME).expect("static app name");
let app = gtk4::Application::builder().application_id(&app_id).build();
app.connect_activate(|app| {
bread_theme::gtk::apply_shared();
bread_theme::gtk::apply_app_css(ui::app_css);
// No window until polkit asks; hold so GApplication stays alive.
std::mem::forget(app.hold());
if let Err(e) = agent::spawn() {
eprintln!("bread-polkit: {e:#}");
app.quit();
}
});
app.run();
}
fn print_help() {
print!(
"\
bread-polkit themed PolicyKit authentication agent
Usage:
bread-polkit
bread-polkit --help
bread-polkit --version
Autostart (pick one):
cp contrib/bread-polkit.desktop ~/.config/autostart/
exec-once = bread-polkit # Hyprland
The agent talks to the polkit1 AuthenticationAgent API and prompts for
a password. It does not exec polkit-gnome. Not a bakery product; not
on the BOS ISO lockfile.
"
);
}

View file

@ -0,0 +1,49 @@
//! Session subject for `RegisterAuthenticationAgent`.
/// Logind session id from `XDG_SESSION_ID`, falling back to
/// `/proc/self/sessionid` when the kernel has one.
pub fn session_id() -> Option<String> {
let xdg = std::env::var("XDG_SESSION_ID").ok();
let proc = std::fs::read_to_string("/proc/self/sessionid").ok();
session_id_from(xdg.as_deref(), proc.as_deref())
}
/// `None` when both sources are empty or the kernel reports the
/// unsigned `-1` sentinel (`4294967295`) meaning "no session".
pub fn session_id_from(xdg: Option<&str>, proc_sessionid: Option<&str>) -> Option<String> {
if let Some(id) = xdg.map(str::trim).filter(|s| !s.is_empty()) {
return Some(id.to_string());
}
let raw = proc_sessionid?.trim();
if raw.is_empty() || raw == "4294967295" {
return None;
}
Some(raw.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prefers_xdg_session_id() {
assert_eq!(session_id_from(Some("3"), Some("7")).as_deref(), Some("3"));
assert_eq!(
session_id_from(Some(" 3 "), Some("7")).as_deref(),
Some("3")
);
}
#[test]
fn falls_back_to_proc_sessionid() {
assert_eq!(session_id_from(Some(""), Some("7")).as_deref(), Some("7"));
assert_eq!(session_id_from(None, Some("7\n")).as_deref(), Some("7"));
}
#[test]
fn rejects_unset_kernel_session() {
assert_eq!(session_id_from(None, Some("4294967295")), None);
assert_eq!(session_id_from(Some(""), Some("")), None);
assert_eq!(session_id_from(None, None), None);
}
}

286
bread-polkit/src/ui.rs Normal file
View file

@ -0,0 +1,286 @@
//! GTK4 password prompt, themed with bread-theme.
use std::cell::RefCell;
use std::rc::Rc;
use gtk4::gdk::Key;
use gtk4::glib::{self, Propagation};
use gtk4::prelude::*;
use gtk4::{
Align, Application, ApplicationWindow, Box as GBox, Button, Entry, EventControllerKey, Label,
Orientation,
};
use bread_theme::tokens;
use crate::agent::UserAction;
const PANEL_WIDTH: i32 = 400;
struct Active {
cookie: String,
window: ApplicationWindow,
password: Entry,
error: Label,
reply: tokio::sync::mpsc::Sender<UserAction>,
username: String,
}
thread_local! {
static ACTIVE: RefCell<Option<Active>> = const { RefCell::new(None) };
}
/// App-specific rules layered on the shared bread-theme stylesheet.
pub fn app_css() -> String {
format!(
".polkit-panel {{\
background-color: @surface; color: @on-surface;\
border-radius: {r}px; padding: {pad}px;\
min-width: {w}px;\
}}\n\
.polkit-title {{ font-size: 1.4em; font-weight: bold; }}\n\
.polkit-message {{ opacity: 0.85; }}\n\
.polkit-identity {{ opacity: 0.7; font-size: {sec}px; }}\n\
.polkit-error {{ color: @on-red; }}\n\
.polkit-buttons {{ padding-top: {sm}px; }}\n",
r = tokens::RADIUS_PRIMARY,
pad = tokens::SPACE_XL,
w = PANEL_WIDTH,
sec = tokens::FONT_SIZE_SECONDARY,
sm = tokens::SPACE_SM,
)
}
pub struct Prompt {
pub cookie: String,
pub message: String,
pub action_id: String,
pub username: String,
pub reply: tokio::sync::mpsc::Sender<UserAction>,
}
/// Show (or replace) the password overlay for this cookie.
pub fn show_prompt(app: &Application, prompt: Prompt) {
close_if_other_cookie(&prompt.cookie);
if ACTIVE.with(|a| {
a.borrow()
.as_ref()
.is_some_and(|active| active.cookie == prompt.cookie)
}) {
present_existing(&prompt);
return;
}
let window = bread_app::gtk_popup::new_overlay_window(app, "bread-polkit");
let panel = GBox::new(Orientation::Vertical, tokens::SPACE_MD as i32);
panel.add_css_class("polkit-panel");
panel.add_css_class("card");
panel.set_halign(Align::Center);
panel.set_valign(Align::Center);
panel.set_size_request(PANEL_WIDTH, -1);
let title = Label::new(Some("Authentication required"));
title.add_css_class("polkit-title");
title.add_css_class("page-title");
title.set_halign(Align::Start);
title.set_wrap(true);
panel.append(&title);
let message = if prompt.message.trim().is_empty() {
prompt.action_id.clone()
} else {
prompt.message.clone()
};
let msg = Label::new(Some(&message));
msg.add_css_class("polkit-message");
msg.set_halign(Align::Start);
msg.set_wrap(true);
msg.set_xalign(0.0);
panel.append(&msg);
if !prompt.username.is_empty() {
let identity = Label::new(Some(&format!("Authenticating as {}", prompt.username)));
identity.add_css_class("polkit-identity");
identity.add_css_class("dim-label");
identity.set_halign(Align::Start);
panel.append(&identity);
}
let error = Label::new(None);
error.add_css_class("polkit-error");
error.set_halign(Align::Start);
error.set_wrap(true);
error.set_visible(false);
panel.append(&error);
let password = Entry::builder()
.visibility(false)
.input_purpose(gtk4::InputPurpose::Password)
.placeholder_text("Password")
.hexpand(true)
.build();
panel.append(&password);
let buttons = GBox::new(Orientation::Horizontal, tokens::SPACE_SM as i32);
buttons.add_css_class("polkit-buttons");
buttons.set_halign(Align::End);
let cancel = Button::with_label("Cancel");
cancel.add_css_class("flat");
let confirm = Button::with_label("Authenticate");
confirm.add_css_class("suggested-action");
buttons.append(&cancel);
buttons.append(&confirm);
panel.append(&buttons);
window.set_child(Some(&panel));
bread_theme::gtk::bind_window_auto_with_app_css(&window, |_| app_css());
let reply = prompt.reply.clone();
let cookie = prompt.cookie.clone();
let username = prompt.username.clone();
let submit = {
let password = password.clone();
let reply = reply.clone();
let username = username.clone();
Rc::new(move || {
let secret = password.text().to_string();
password.set_text("");
let _ = reply.try_send(UserAction::Submit {
username: username.clone(),
password: secret,
});
})
};
let cancel_fn = {
let reply = reply.clone();
let window = window.clone();
Rc::new(move || {
let _ = reply.try_send(UserAction::Cancel);
window.close();
ACTIVE.with(|a| a.replace(None));
})
};
confirm.connect_clicked({
let submit = submit.clone();
move |_| submit()
});
password.connect_activate({
let submit = submit.clone();
move |_| submit()
});
cancel.connect_clicked({
let cancel_fn = cancel_fn.clone();
move |_| cancel_fn()
});
let keys = EventControllerKey::new();
keys.connect_key_pressed({
let cancel_fn = cancel_fn.clone();
move |_, key, _, _| {
if key == Key::Escape {
cancel_fn();
Propagation::Stop
} else {
Propagation::Proceed
}
}
});
window.add_controller(keys);
bread_app::gtk_popup::close_on_outside_click(&window, &panel, {
let cancel_fn = cancel_fn.clone();
move || cancel_fn()
});
window.connect_close_request({
let reply = reply.clone();
move |_| {
let closing_ours = ACTIVE.with(|a| {
a.borrow()
.as_ref()
.is_some_and(|active| active.cookie == cookie)
});
if closing_ours {
let _ = reply.try_send(UserAction::Cancel);
ACTIVE.with(|a| a.replace(None));
}
glib::Propagation::Proceed
}
});
ACTIVE.with(|a| {
*a.borrow_mut() = Some(Active {
cookie: prompt.cookie,
window: window.clone(),
password: password.clone(),
error,
reply,
username,
});
});
window.present();
password.grab_focus();
}
fn present_existing(prompt: &Prompt) {
ACTIVE.with(|a| {
if let Some(active) = a.borrow_mut().as_mut() {
active.reply = prompt.reply.clone();
active.username = prompt.username.clone();
active.error.set_visible(false);
active.password.set_text("");
active.window.present();
active.password.grab_focus();
}
});
}
/// Show a retry message on the open dialog for `cookie`.
pub fn show_retry(cookie: &str, message: &str) {
ACTIVE.with(|a| {
let mut guard = a.borrow_mut();
let Some(active) = guard.as_mut() else {
return;
};
if active.cookie != cookie {
return;
}
active.error.set_label(message);
active.error.set_visible(true);
active.password.set_text("");
active.window.present();
active.password.grab_focus();
});
}
/// Close the dialog if it is still showing `cookie`.
pub fn close_prompt(cookie: &str) {
ACTIVE.with(|a| {
let Some(active) = a.borrow_mut().take() else {
return;
};
if active.cookie == cookie {
active.window.close();
} else {
*a.borrow_mut() = Some(active);
}
});
}
fn close_if_other_cookie(cookie: &str) {
ACTIVE.with(|a| {
let Some(active) = a.borrow_mut().take() else {
return;
};
if active.cookie == cookie {
*a.borrow_mut() = Some(active);
} else {
active.window.close();
}
});
}

View file

@ -0,0 +1,14 @@
[package]
name = "bread-screenshots"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
description = "Shared capture plumbing for the bread ecosystem's UI screenshot tooling: layer-surface and output geometry via Hyprland IPC, capture via grim"
repository = "https://git.breadway.dev/Breadway/bread-ecosystem"
keywords = ["hyprland", "wayland", "screenshot", "grim"]
[dependencies]
bread-utils = { path = "../bread-utils" }
anyhow = { workspace = true }
tracing = { workspace = true }

View file

@ -0,0 +1,30 @@
//! Capture primitive for the bread ecosystem's UI screenshot tooling (see
//! `bread-capture`, the orchestrator that drives this crate's consumers).
//!
//! Deliberately compositor-agnostic: no Hyprland IPC, no layer/output
//! lookup. `bread-capture` runs every target app inside an isolated,
//! headless compositor instance of a known, fixed size (see its
//! `isolation` module), so the caller already knows exactly what region to
//! grab — there's nothing to query.
use anyhow::{bail, Context, Result};
use std::path::Path;
use std::time::Duration;
const GRIM_TIMEOUT: Duration = Duration::from_secs(5);
/// Capture a `w`x`h` region at `(x, y)` (compositor-global coordinates) to
/// `out` via `grim -g`.
pub fn capture_region(x: i32, y: i32, w: i32, h: i32, out: &Path) -> Result<()> {
if let Some(parent) = out.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
let out_str = out.to_str().context("output path is not valid UTF-8")?;
let geometry = format!("{x},{y} {w}x{h}");
let result = bread_utils::proc::run("grim", &["-g", &geometry, out_str], GRIM_TIMEOUT);
if !result.success {
bail!("grim failed for geometry {geometry}: {}", result.stderr.trim());
}
Ok(())
}

View file

@ -1,11 +1,48 @@
# bread-theme changelog
## 0.7.4
Per-output (per-monitor) theming. Session-global `theme.css` remains the
fallback / focused-monitor sheet; each Hyprland/GDK connector can now have
its own palette and stylesheet. BOS still keeps bg/surface/overlay/fg
fixed — only color16 come from the wallpaper.
On disk under `$XDG_RUNTIME_DIR/bread/` (same fallback as `shared_css_path`):
- `palettes/<sanitized-output>.json` — accents only (round-trips through
`from_wal_json` / a color16 object; never persists pywal's light bg)
- `themes/<sanitized-output>.css``stylesheet()` for that palette
New lib API:
- `themes_dir`, `palettes_dir`, `output_css_path`, `output_palette_path`,
`sanitize_output`
- `load_palette_for`, `write_output_palette`, `write_output_css`,
`write_shared_css_from`
- `palette_from_image` (isolated `wal -i`, does not touch `~/.cache/wal`),
`generate_output`, `palette_from_json`
- `stylesheet_resolved` — inlines `@accent` / `@on-bg` / … to hex so GTK's
display-global `@define-color` cannot leak the wrong monitor's accent
GTK (`gtk` feature): `bind_window`, `bind_window_with_app_css`,
`output_for_widget`, `bind_window_auto`, `bind_window_auto_with_app_css`.
Widget-scoped providers at `USER - 10` so they beat `apply_shared` but
lose to user CSS. Existing `apply_shared` / `apply_app_css` /
`apply_css` / `apply_user_css` are unchanged.
CLI: `bread-theme generate-output <OUTPUT> --image <PATH> | --from-json
<FILE> [--shared]`. Does not write `theme.css` unless `--shared`.
## Coordinated bump policy
`bread-theme` is consumed by `breadbar`, `breadbox`, and `breadpad` as a pinned
git dependency. A breaking change to `Palette`, `css_vars`, or the `gtk` feature
API requires all three dependents to bump their `Cargo.toml` git tag and cut a
release together. Note the impact in this file before tagging.
`bread-theme` is consumed by `breadbar`, `breadbox`, `breadpad`, and the other
GTK bread apps as a pinned git dependency. A breaking change to `Palette`,
`css_vars`, or the `gtk` feature API requires dependents to bump their
`Cargo.toml` git tag and cut a release together. Note the impact in this file
before tagging.
**0.7.4** adds per-output bind APIs (`bind_window*`, `load_palette_for`,
`generate_output`). Apps that call those must pin `tag = "v0.7.4"`.
---

View file

@ -13,11 +13,24 @@ serde = { workspace = true }
serde_json = { workspace = true }
dirs = { workspace = true }
gtk4 = { version = "0.11", features = ["v4_12"], optional = true }
# Rust bindings for libadwaita (GNOME's widget library on top of GTK4) — the
# actual source of the modern GNOME look (grouped preference rows, real
# toggle/spin rows, view switchers), not just a CSS reskin of plain GTK4
# widgets. `v1_7` for ToggleGroup (used for tab-row-style pickers); the
# system library only needs to be >= that (this machine has 1.9.2).
libadwaita = { version = "0.9", features = ["v1_7"], optional = true }
[features]
# Enable GTK4 CSS provider helpers (breadbar, breadbox, breadpad use this).
# bread (daemon) and breadcrumbs (CLI) depend on this crate without the feature.
gtk = ["dep:gtk4"]
# Composite libadwaita-based widgets (bread_theme::adw) — separate from `gtk`
# because libadwaita's own top-level window chrome (AdwApplicationWindow)
# isn't compatible with gtk4-layer-shell surfaces, so the five layer-shell
# apps (breadbar, breadbox, breadclip, breadsearch, breadpad) only want
# plain CSS, not this. Apps with an ordinary top-level window (breadman,
# breadhelp) want both.
adw = ["gtk", "dep:libadwaita"]
# The generator CLI. It only touches the gtk-free lib API (render + write), so
# it builds without the gtk feature and stays light.

78
bread-theme/src/adw.rs Normal file
View file

@ -0,0 +1,78 @@
//! Composite libadwaita widgets for the bread ecosystem's design system —
//! the actual mechanism (real GNOME-style widgets, not more hand-rolled CSS)
//! behind why bos-settings' sidebar/section/toggle rows read as more polished
//! than the plain-GTK4 apps'. An app calls these instead of assembling boxes
//! and labels and raw widgets from scratch each time, so spacing/sizing/
//! grouping decisions get made once, correctly, here — not re-derived per
//! screen.
//!
//! Not usable from the five `gtk4-layer-shell` apps (breadbar, breadbox,
//! breadclip, breadsearch, breadpad): `AdwApplicationWindow`'s own chrome
//! isn't compatible with a layer-shell surface, and these helpers assume an
//! ordinary top-level window. Apps with a plain top-level window (breadman,
//! breadhelp) can use the full set.
use libadwaita as adw;
use adw::prelude::*;
/// Call once at startup, before building any widgets from this module —
/// initializes libadwaita's style manager and forces dark mode regardless of
/// the system GTK theme preference. bread-theme's whole design is a *fixed*
/// dark base (only the accent tracks pywal — see `palette::FIXED_BACKGROUND`
/// etc.) so an app respecting a light system preference here would silently
/// break that contract the moment someone's GNOME settings say "light".
pub fn init() {
adw::init().expect("failed to initialize libadwaita");
adw::StyleManager::default().set_color_scheme(adw::ColorScheme::ForceDark);
}
/// A titled, optionally-described group of setting rows — the
/// title-then-description-then-rows rhythm bos-settings already uses per
/// section, now available to native GTK4/relm4 apps instead of a hand-rolled
/// vbox with a bold label glued to the top.
pub fn preferences_group(title: &str, description: Option<&str>) -> adw::PreferencesGroup {
let group = adw::PreferencesGroup::builder().title(title).build();
if let Some(desc) = description {
group.set_description(Some(desc));
}
group
}
/// A single on/off setting row with a correctly-sized, correctly-positioned
/// switch — the direct fix for the ~1400px-wide stretched-switch bug
/// (breadman/settings had no intrinsic width on its hand-rolled switch, so
/// it filled the row like a progress bar).
pub fn toggle_row(title: &str, subtitle: Option<&str>, active: bool) -> adw::SwitchRow {
let row = adw::SwitchRow::builder().title(title).active(active).build();
if let Some(sub) = subtitle {
row.set_subtitle(sub);
}
row
}
/// A single numeric setting row (spin button docked to its own label,
/// instead of stranded ~1300px away at the window's far edge).
pub fn spin_row(title: &str, subtitle: Option<&str>, adjustment: &gtk4::Adjustment) -> adw::SpinRow {
let row = adw::SpinRow::builder().title(title).adjustment(adjustment).build();
if let Some(sub) = subtitle {
row.set_subtitle(sub);
}
row
}
/// A general label(+subtitle) row with room for a trailing widget
/// (`row.add_suffix(&widget)`) — for settings that don't fit switch/spin
/// (text entries, buttons, dropdowns, a raw value display).
pub fn action_row(title: &str, subtitle: Option<&str>) -> adw::ActionRow {
let row = adw::ActionRow::builder().title(title).build();
if let Some(sub) = subtitle {
row.set_subtitle(sub);
}
row
}
/// A page of one or more `preferences_group`s, with correct margins and
/// scroll handling — the top-level content container for a settings screen.
pub fn preferences_page() -> adw::PreferencesPage {
adw::PreferencesPage::new()
}

View file

@ -9,6 +9,8 @@
//! # signal every running bread GUI to recolour
//! bread-theme path # print the stylesheet path
//! bread-theme print # render to stdout (no write)
//! bread-theme generate-output <OUTPUT> --image <PATH> [--shared]
//! bread-theme generate-output <OUTPUT> --from-json <PATH> [--shared]
use std::process::ExitCode;
@ -25,6 +27,149 @@ fn write_and_report(verb: &str) -> ExitCode {
}
}
fn print_help() {
eprintln!(
"bread-theme — shared stylesheet generator\n\n\
USAGE:\n\
\x20 bread-theme [generate|reload|path|print]\n\
\x20 bread-theme generate-output <OUTPUT> --image <PATH> [--shared]\n\
\x20 bread-theme generate-output <OUTPUT> --from-json <WAL-OR-PALETTE.json> [--shared]\n\n\
generate render the pywal palette to the shared stylesheet (default)\n\
reload re-render and signal running bread GUIs to recolour live\n\
path print the stylesheet path ({})\n\
print render to stdout without writing\n\
generate-output write palettes/<OUTPUT>.json and themes/<OUTPUT>.css\n\
\x20 --image isolated `wal -i` (does not touch ~/.cache/wal)\n\
\x20 --from-json wal colors.json or a color1-6 object\n\
\x20 --shared also write the session-global theme.css",
bread_theme::shared_css_path().display()
);
}
fn generate_output_cmd() -> ExitCode {
let args: Vec<String> = std::env::args().skip(2).collect();
if args.is_empty()
|| args
.iter()
.any(|a| matches!(a.as_str(), "-h" | "--help" | "help"))
{
print_help();
return if args.is_empty() {
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
};
}
let output = args[0].as_str();
if output.starts_with('-') {
eprintln!("bread-theme: generate-output requires an OUTPUT name (got '{output}')");
return ExitCode::FAILURE;
}
let mut image: Option<&str> = None;
let mut from_json: Option<&str> = None;
let mut shared = false;
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--shared" => shared = true,
"--image" => {
i += 1;
match args.get(i) {
Some(p) => image = Some(p.as_str()),
None => {
eprintln!("bread-theme: --image requires a path");
return ExitCode::FAILURE;
}
}
}
"--from-json" => {
i += 1;
match args.get(i) {
Some(p) => from_json = Some(p.as_str()),
None => {
eprintln!("bread-theme: --from-json requires a path");
return ExitCode::FAILURE;
}
}
}
other => {
eprintln!("bread-theme: unknown generate-output flag '{other}'");
return ExitCode::FAILURE;
}
}
i += 1;
}
match (image, from_json) {
(Some(_), Some(_)) => {
eprintln!("bread-theme: pass only one of --image or --from-json");
ExitCode::FAILURE
}
(None, None) => {
eprintln!("bread-theme: generate-output needs --image <PATH> or --from-json <PATH>");
ExitCode::FAILURE
}
(Some(path), None) => {
match bread_theme::generate_output(output, std::path::Path::new(path)) {
Ok(css) => finish_generate_output(output, css, shared),
Err(e) => {
eprintln!("bread-theme: generate-output failed: {e}");
ExitCode::FAILURE
}
}
}
(None, Some(path)) => match write_output_from_json(output, path, shared) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("bread-theme: generate-output failed: {e}");
ExitCode::FAILURE
}
},
}
}
fn write_output_from_json(output: &str, json_path: &str, shared: bool) -> std::io::Result<()> {
let json = std::fs::read_to_string(json_path)?;
let palette = bread_theme::palette_from_json(&json).ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("could not parse palette JSON: {json_path}"),
)
})?;
let pal_path = bread_theme::write_output_palette(output, &palette)?;
let css_path = bread_theme::write_output_css(output, &palette)?;
eprintln!(
"bread-theme: wrote {} and {}",
pal_path.display(),
css_path.display()
);
if shared {
let shared_path = bread_theme::write_shared_css_from(&palette)?;
eprintln!("bread-theme: wrote shared {}", shared_path.display());
}
Ok(())
}
fn finish_generate_output(output: &str, css: std::path::PathBuf, shared: bool) -> ExitCode {
eprintln!("bread-theme: wrote {}", css.display());
if shared {
match bread_theme::write_shared_css_from(&bread_theme::load_palette_for(output)) {
Ok(path) => {
eprintln!("bread-theme: wrote shared {}", path.display());
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("bread-theme: failed to write shared stylesheet: {e}");
ExitCode::FAILURE
}
}
} else {
ExitCode::SUCCESS
}
}
fn main() -> ExitCode {
let cmd = std::env::args().nth(1).unwrap_or_else(|| "generate".into());
match cmd.as_str() {
@ -42,20 +187,15 @@ fn main() -> ExitCode {
// the file monitor in every running bread GUI, so they all re-read the
// palette and recolour live — shared widgets *and* each app's own rules.
"reload" => write_and_report("reloaded"),
"generate-output" => generate_output_cmd(),
"-h" | "--help" | "help" => {
eprintln!(
"bread-theme — shared stylesheet generator\n\n\
USAGE:\n bread-theme [generate|reload|path|print]\n\n\
generate render the pywal palette to the shared stylesheet (default)\n\
reload re-render and signal running bread GUIs to recolour live\n\
path print the stylesheet path ({})\n\
print render to stdout without writing",
bread_theme::shared_css_path().display()
);
print_help();
ExitCode::SUCCESS
}
other => {
eprintln!("bread-theme: unknown command '{other}' (try generate|reload|path|print)");
eprintln!(
"bread-theme: unknown command '{other}' (try generate|reload|path|print|generate-output)"
);
ExitCode::FAILURE
}
}

View file

@ -1,8 +1,18 @@
use gtk4::gdk::prelude::*;
use gtk4::gio;
use gtk4::glib::object::ObjectType;
use gtk4::prelude::*;
use gtk4::CssProvider;
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::rc::Rc;
use crate::Palette;
/// Above APPLICATION (600) so we beat [`apply_shared`], below USER (800)
/// so `apply_user_css` still wins.
const BIND_PRIORITY: u32 = gtk4::STYLE_PROVIDER_PRIORITY_USER - 10;
thread_local! {
static SHARED_PROVIDER: RefCell<Option<CssProvider>> = const { RefCell::new(None) };
@ -14,8 +24,7 @@ thread_local! {
}
fn reload_shared() {
let css = std::fs::read_to_string(crate::shared_css_path())
.unwrap_or_else(|_| crate::render());
let css = std::fs::read_to_string(crate::shared_css_path()).unwrap_or_else(|_| crate::render());
SHARED_PROVIDER.with(|cell| apply_css(&css, cell));
}
@ -114,6 +123,345 @@ pub fn apply_css(css: &str, provider: &RefCell<Option<CssProvider>>) {
}
}
/// A filter/tag chip using the shared `.chip` stylesheet rule (an
/// `@overlay`-filled pill, `@accent`-filled when the `active` CSS class is
/// set) instead of a fresh literal color — this is the fix for the same
/// component drifting to three different fills across breadclip (grey),
/// breadpad, and breadman (both cream), none of which agreed with each
/// other or with the shared token.
pub fn chip(label: &str) -> gtk4::Button {
gtk4::Button::builder()
.label(label)
.css_classes(["chip"])
.build()
}
/// Toggles a chip's (or any widget's) `active` CSS class — the `.chip.active`
/// stylesheet rule fills it with the accent instead of the neutral overlay.
/// Wiring *when* a chip becomes active (single-select filter, multi-select
/// tags, etc.) is genuinely per-app, so that stays the caller's job; this is
/// just the one-line visual toggle every case needs.
pub fn set_chip_active(chip: &impl IsA<gtk4::Widget>, active: bool) {
if active {
chip.add_css_class("active");
} else {
chip.remove_css_class("active");
}
}
/// Gdk connector for the monitor currently showing this widget, if any.
pub fn output_for_widget(widget: &impl IsA<gtk4::Widget>) -> Option<String> {
let widget = widget.as_ref();
let native = widget.native()?;
let surface = NativeExt::surface(&native)?;
let monitor = widget.display().monitor_at_surface(&surface)?;
monitor.connector().map(|c| c.to_string())
}
struct WidgetBind {
output: String,
theme: CssProvider,
app: Option<CssProvider>,
app_build: Option<Rc<dyn Fn(&Palette) -> String>>,
/// Keep the directory monitor + child model alive for this widget.
_watch: Option<gio::ListModel>,
}
thread_local! {
static BINDS: RefCell<HashMap<usize, WidgetBind>> = RefCell::new(HashMap::new());
static THEMES_MONITOR: RefCell<Option<gio::FileMonitor>> = const { RefCell::new(None) };
static DESTROY_HOOKED: RefCell<HashSet<usize>> = RefCell::new(HashSet::new());
static AUTO_HOOKED: RefCell<HashSet<usize>> = RefCell::new(HashSet::new());
static ENTER_HOOKED: RefCell<HashSet<usize>> = RefCell::new(HashSet::new());
}
fn widget_key(widget: &gtk4::Widget) -> usize {
widget.as_ptr() as usize
}
#[allow(deprecated)]
fn add_widget_provider(widget: &gtk4::Widget, provider: &CssProvider, prio: u32) {
widget.style_context().add_provider(provider, prio);
}
/// Same `CssProvider` on the widget and its current descendants so component
/// rules actually reach buttons/labels (a style-context provider is not
/// inherited by children).
fn attach_tree(widget: &gtk4::Widget, theme: &CssProvider, app: Option<&CssProvider>) {
add_widget_provider(widget, theme, BIND_PRIORITY);
if let Some(app) = app {
add_widget_provider(widget, app, BIND_PRIORITY + 1);
}
let mut child = widget.first_child();
while let Some(c) = child {
attach_tree(&c, theme, app);
child = c.next_sibling();
}
}
fn ensure_destroy_cleanup(widget: &gtk4::Widget) {
let key = widget_key(widget);
let inserted = DESTROY_HOOKED.with(|s| s.borrow_mut().insert(key));
if !inserted {
return;
}
widget.connect_destroy(move |w| {
let key = widget_key(w);
BINDS.with(|b| {
b.borrow_mut().remove(&key);
});
DESTROY_HOOKED.with(|s| {
s.borrow_mut().remove(&key);
});
AUTO_HOOKED.with(|s| {
s.borrow_mut().remove(&key);
});
});
}
fn ensure_themes_watch() {
THEMES_MONITOR.with(|cell| {
if cell.borrow().is_some() {
return;
}
let dir = crate::themes_dir();
let _ = std::fs::create_dir_all(&dir);
let monitor = gio::File::for_path(&dir)
.monitor_directory(gio::FileMonitorFlags::WATCH_MOVES, gio::Cancellable::NONE)
.ok();
if let Some(ref m) = monitor {
m.connect_changed(move |_, file, other, _event| {
let path = file.path().or_else(|| other.and_then(|f| f.path()));
let Some(path) = path else {
return;
};
if path.extension().and_then(|e| e.to_str()) != Some("css") {
return;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
return;
};
reload_binds_for_sanitized(stem);
});
}
*cell.borrow_mut() = monitor;
});
}
fn reload_binds_for_sanitized(sanitized: &str) {
BINDS.with(|binds| {
for bind in binds.borrow_mut().values_mut() {
if crate::sanitize_output(&bind.output) != sanitized {
continue;
}
let palette = crate::load_palette_for(&bind.output);
bind.theme
.load_from_string(&crate::stylesheet_resolved(&palette));
if let (Some(build), Some(provider)) = (&bind.app_build, &bind.app) {
provider.load_from_string(&crate::resolve_color_names(&build(&palette), &palette));
}
}
});
}
fn watch_root_children(widget: &gtk4::Widget) -> gio::ListModel {
let model = widget.observe_children();
let root = widget.downgrade();
model.connect_items_changed(move |_, _, _, _| {
let Some(root) = root.upgrade() else {
return;
};
let key = widget_key(&root);
BINDS.with(|binds| {
if let Some(bind) = binds.borrow().get(&key) {
attach_tree(&root, &bind.theme, bind.app.as_ref());
}
});
});
model
}
fn bind_window_inner(
widget: &gtk4::Widget,
output: &str,
app_build: Option<Rc<dyn Fn(&Palette) -> String>>,
) {
let key = widget_key(widget);
let palette = crate::load_palette_for(output);
let theme_css = crate::stylesheet_resolved(&palette);
let app_css = app_build
.as_ref()
.map(|build| crate::resolve_color_names(&build(&palette), &palette));
BINDS.with(|binds| {
let mut map = binds.borrow_mut();
if let Some(existing) = map.get_mut(&key) {
existing.output = output.to_string();
existing.theme.load_from_string(&theme_css);
existing.app_build = app_build.clone();
match (&app_css, existing.app.as_ref()) {
(Some(css), Some(p)) => p.load_from_string(css),
(Some(css), None) => {
let p = CssProvider::new();
p.load_from_string(css);
add_widget_provider(widget, &p, BIND_PRIORITY + 1);
existing.app = Some(p);
}
(None, Some(p)) => p.load_from_string(""),
(None, None) => {}
}
attach_tree(widget, &existing.theme, existing.app.as_ref());
return;
}
let theme = CssProvider::new();
theme.load_from_string(&theme_css);
add_widget_provider(widget, &theme, BIND_PRIORITY);
let app = app_css.map(|css| {
let p = CssProvider::new();
p.load_from_string(&css);
add_widget_provider(widget, &p, BIND_PRIORITY + 1);
p
});
attach_tree(widget, &theme, app.as_ref());
let child_model = watch_root_children(widget);
map.insert(
key,
WidgetBind {
output: output.to_string(),
theme,
app,
app_build,
_watch: Some(child_model),
},
);
});
ensure_destroy_cleanup(widget);
ensure_themes_watch();
ensure_map_reattach(widget);
}
fn ensure_map_reattach(widget: &gtk4::Widget) {
// `connect_map` once per widget — re-bind already lives in BINDS.
thread_local! {
static MAP_HOOKED: RefCell<HashSet<usize>> = RefCell::new(HashSet::new());
}
let key = widget_key(widget);
let inserted = MAP_HOOKED.with(|s| s.borrow_mut().insert(key));
if !inserted {
return;
}
widget.connect_map(|w| {
BINDS.with(|binds| {
if let Some(bind) = binds.borrow().get(&widget_key(w)) {
attach_tree(w, &bind.theme, bind.app.as_ref());
}
});
});
widget.connect_destroy(move |_| {
MAP_HOOKED.with(|s| {
s.borrow_mut().remove(&key);
});
});
}
/// Attach a widget-level `CssProvider` with
/// `stylesheet_resolved(load_palette_for(output))` above APPLICATION so it
/// beats [`apply_shared`] for this widget tree. User CSS still wins.
/// Calling again on the same widget replaces the provider; it does not stack.
pub fn bind_window(widget: &impl IsA<gtk4::Widget>, output: &str) {
bind_window_inner(widget.as_ref(), output, None);
}
/// [`bind_window`], then also apply `build(&palette)` on the same widget.
/// App CSS may still use `@accent` etc.; those names are inlined against
/// the same palette before loading.
pub fn bind_window_with_app_css<F>(widget: &impl IsA<gtk4::Widget>, output: &str, build: F)
where
F: Fn(&Palette) -> String + 'static,
{
bind_window_inner(widget.as_ref(), output, Some(Rc::new(build)));
}
fn attach_enter_monitor(widget: &gtk4::Widget, build: Option<Rc<dyn Fn(&Palette) -> String>>) {
let Some(native) = widget.native() else {
return;
};
let Some(surface) = NativeExt::surface(&native) else {
return;
};
let surf_key = surface.as_ptr() as usize;
let already = ENTER_HOOKED.with(|s| !s.borrow_mut().insert(surf_key));
if already {
return;
}
let widget = widget.clone();
surface.connect_enter_monitor(move |_, monitor| {
let Some(conn) = monitor.connector() else {
return;
};
bind_window_inner(&widget, conn.as_str(), build.clone());
});
}
fn bind_auto(native: &gtk4::Native, build: Option<Rc<dyn Fn(&Palette) -> String>>) {
let widget = native.upcast_ref::<gtk4::Widget>().clone();
let apply = {
let widget = widget.clone();
let build = build.clone();
Rc::new(move || {
if let Some(output) = output_for_widget(&widget) {
bind_window_inner(&widget, &output, build.clone());
}
})
};
apply();
let key = widget_key(&widget);
let inserted = AUTO_HOOKED.with(|s| s.borrow_mut().insert(key));
if inserted {
widget.connect_realize({
let apply = apply.clone();
let widget = widget.clone();
let build = build.clone();
move |_| {
apply();
attach_enter_monitor(&widget, build.clone());
}
});
widget.connect_map({
let apply = apply.clone();
move |_| apply()
});
ensure_destroy_cleanup(&widget);
}
if widget.is_realized() {
attach_enter_monitor(&widget, build);
}
}
/// Realize + `GdkSurface::enter-monitor`: rebind when the window moves
/// outputs. If the connector is unknown, leave unbound (display fallback)
/// rather than guessing the wrong monitor.
pub fn bind_window_auto(window: &impl IsA<gtk4::Native>) {
bind_auto(window.as_ref(), None);
}
/// [`bind_window_auto`] plus per-output app CSS, resolved to hex.
pub fn bind_window_auto_with_app_css<F>(window: &impl IsA<gtk4::Native>, build: F)
where
F: Fn(&Palette) -> String + 'static,
{
bind_auto(window.as_ref(), Some(Rc::new(build)));
}
/// Apply a user CSS override file at USER priority. Clears the provider if the
/// file is absent so stale overrides don't persist across SIGHUP reloads.
pub fn apply_user_css(path: &Path, provider: &RefCell<Option<CssProvider>>) {

View file

@ -1,7 +1,15 @@
pub mod palette;
#[cfg(feature = "adw")]
pub mod adw;
#[cfg(feature = "gtk")]
pub mod gtk;
mod output;
pub mod palette;
pub use output::{
generate_output, load_palette_for, output_css_path, output_palette_path, palette_from_image,
palette_from_json, palettes_dir, sanitize_output, themes_dir, write_output_css,
write_output_palette, write_shared_css_from,
};
pub use palette::{load_palette, Palette};
/// Design tokens from BREAD_DESIGN_SYSTEM.md.
@ -24,6 +32,13 @@ pub mod tokens {
pub const RADIUS_PILL: u16 = 999;
}
/// CSS `font-family` list: quote the named face, leave the generic fallback
/// unquoted. Wrapping [`tokens::FONT_FAMILY`] in one pair of quotes would
/// make a single family named "Varela Round, sans-serif" and drop sans-serif.
fn css_font_family() -> &'static str {
"'Varela Round', sans-serif"
}
/// Emit the `@define-color` block that all bread apps use, plus the shared
/// font rule.
///
@ -39,9 +54,9 @@ pub mod tokens {
/// one color-block implementation and it cannot drift again.
pub fn css_vars(p: &Palette) -> String {
format!(
"{vars}* {{ font-family: '{font}'; font-size: {size}px; }}\n",
"{vars}* {{ font-family: {font}; font-size: {size}px; }}\n",
vars = define_colors(p),
font = tokens::FONT_FAMILY,
font = css_font_family(),
size = tokens::FONT_SIZE_BASE,
)
}
@ -51,7 +66,11 @@ pub fn luminance(hex: &str) -> f32 {
let h = hex.trim_start_matches('#');
let lin = |i: usize| -> f32 {
let c = u8::from_str_radix(h.get(i..i + 2).unwrap_or("00"), 16).unwrap_or(0) as f32 / 255.0;
if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
if c <= 0.04045 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
};
0.2126 * lin(0) + 0.7152 * lin(2) + 0.0722 * lin(4)
}
@ -62,44 +81,101 @@ pub fn luminance(hex: &str) -> f32 {
/// text readable no matter how light or dark pywal makes a given palette slot,
/// without altering the palette colours themselves.
pub fn ink_on(hex: &str) -> &'static str {
if luminance(hex) > 0.179 { "#11111b" } else { "#f5f5f5" }
if luminance(hex) > 0.179 {
"#11111b"
} else {
"#f5f5f5"
}
}
/// Canonical `@define-color` block: the single naming all bread apps share.
/// Canonical (name, value) list: the single naming all bread apps share.
/// `surface` = color0 (darkest surface), `overlay` = color7 (muted), and
/// `accent` = color4. Apps must use these names, not raw palette slots, so the
/// whole ecosystem recolours together.
///
/// The `on-*` colours are computed ink (black/white) guaranteed to be legible on
/// the matching background — use `@on-surface` for text on a `@surface` panel,
/// `@on-accent` on an `@accent` button, etc. They exist because pywal can emit a
/// the matching background — use `on-surface` for text on a `surface` panel,
/// `on-accent` on an `accent` button, etc. They exist because pywal can emit a
/// light value in any slot, and white text on a light surface disappears.
///
/// [`define_colors`] (GTK `@define-color`) and [`css_custom_properties`] (web
/// `:root { --name: ... }`) both format this same list rather than each
/// hand-writing their own — see `css_vars_and_stylesheet_agree_on_color_block`
/// and `css_custom_properties_matches_define_colors_name_set` for the
/// regression tests this exists to satisfy.
fn color_pairs(p: &Palette) -> [(&'static str, String); 16] {
[
("bg", p.background.clone()),
("fg", p.foreground.clone()),
("surface", p.color0.clone()),
("overlay", p.color7.clone()),
("accent", p.color4.clone()),
("red", p.color1.clone()),
("green", p.color2.clone()),
("yellow", p.color3.clone()),
("blue", p.color4.clone()),
("pink", p.color5.clone()),
("teal", p.color6.clone()),
("on-bg", ink_on(&p.background).to_string()),
("on-surface", ink_on(&p.color0).to_string()),
("on-accent", ink_on(&p.color4).to_string()),
("on-red", ink_on(&p.color1).to_string()),
("on-overlay", ink_on(&p.color7).to_string()),
]
}
/// GTK `@define-color` block built from [`color_pairs`].
fn define_colors(p: &Palette) -> String {
color_pairs(p)
.iter()
.map(|(name, value)| format!("@define-color {name} {value};\n"))
.collect()
}
/// CSS custom-properties block (`:root { --bg: ...; --on-accent: ...; }`) for
/// web frontends (Tauri), using the exact same names as [`define_colors`] so
/// the GTK and web outputs cannot drift apart independently — both are
/// generated from [`color_pairs`], not two hand-written copies.
pub fn css_custom_properties(p: &Palette) -> String {
let vars: String = color_pairs(p)
.iter()
.map(|(name, value)| format!(" --{name}: {value};\n"))
.collect();
format!(":root {{\n{vars}}}\n")
}
/// CSS custom-properties for [`tokens`] (font, spacing, radii) — the web
/// counterpart to [`tokens`] being hand-read by GTK code, so a web frontend
/// isn't hand-copying the same numbers into a second source of truth.
pub fn css_tokens() -> String {
use tokens::*;
format!(
"@define-color bg {bg};\n\
@define-color fg {fg};\n\
@define-color surface {c0};\n\
@define-color overlay {c7};\n\
@define-color accent {c4};\n\
@define-color red {c1};\n\
@define-color green {c2};\n\
@define-color yellow {c3};\n\
@define-color blue {c4};\n\
@define-color pink {c5};\n\
@define-color teal {c6};\n\
@define-color on-bg {on_bg};\n\
@define-color on-surface {on_surface};\n\
@define-color on-accent {on_accent};\n\
@define-color on-red {on_red};\n\
@define-color on-overlay {on_overlay};\n",
bg = p.background, fg = p.foreground,
c0 = p.color0, c1 = p.color1, c2 = p.color2, c3 = p.color3,
c4 = p.color4, c5 = p.color5, c6 = p.color6, c7 = p.color7,
on_bg = ink_on(&p.background),
on_surface = ink_on(&p.color0),
on_accent = ink_on(&p.color4),
on_red = ink_on(&p.color1),
on_overlay = ink_on(&p.color7),
":root {{\n\
\x20\x20--font-family: {font};\n\
\x20\x20--font-size-base: {base}px;\n\
\x20\x20--font-size-secondary: {sec}px;\n\
\x20\x20--space-xs: {xs}px;\n\
\x20\x20--space-sm: {sm}px;\n\
\x20\x20--space-md: {md}px;\n\
\x20\x20--space-lg: {lg}px;\n\
\x20\x20--space-xl: {xl}px;\n\
\x20\x20--radius-primary: {r1}px;\n\
\x20\x20--radius-secondary: {r2}px;\n\
\x20\x20--radius-tertiary: {r3}px;\n\
\x20\x20--radius-pill: {pill}px;\n\
}}\n",
font = css_font_family(),
base = FONT_SIZE_BASE,
sec = FONT_SIZE_SECONDARY,
xs = SPACE_XS,
sm = SPACE_SM,
md = SPACE_MD,
lg = SPACE_LG,
xl = SPACE_XL,
r1 = RADIUS_PRIMARY,
r2 = RADIUS_SECONDARY,
r3 = RADIUS_TERTIARY,
pill = RADIUS_PILL,
)
}
@ -113,16 +189,27 @@ pub fn stylesheet(p: &Palette) -> String {
use tokens::*;
format!(
"{vars}\
* {{ font-family: '{font}'; font-size: {base}px; }}\n\
* {{ font-family: {font}; font-size: {base}px; }}\n\
/* Colour is set on containers; labels inherit it, so text on any panel,\
button, or accent is always the legible ink for that background. Bare\
`label {{ color }}` is deliberately avoided as a type selector it\
would override a container's colour on its own child labels. */\n\
window {{ background-color: @bg; color: @on-bg; }}\n\
.dim-label, .dim {{ opacity: 0.6; font-size: {sec}px; }}\n\
.title {{ font-size: 1.4em; font-weight: bold; }}\n\
/* Named `.page-title`, not the more obvious `.title` - libadwaita's\
own row/window-title widgets (AdwActionRow, AdwWindowTitle, GtkHeaderBar)\
put a bare `title` CSS class on their internal label, so a generic\
`.title` rule here would inflate every libadwaita row's title text\
to 1.4em too (this is exactly what caused the settings screen's\
~24px row-title bug). Scoping the name avoids the collision instead\
of trying to out-specificity a first-party GTK/libadwaita class. */\n\
.page-title {{ font-size: 1.4em; font-weight: bold; }}\n\
.heading {{ font-weight: bold; opacity: 0.85; }}\n\
.subtitle {{ opacity: 0.7; font-size: {sec}px; }}\n\
/* Same libadwaita-collision reasoning as `.page-title` above - a bare\
`.subtitle` also matches libadwaita's internal row-subtitle labels.\
Unused by any app today, but scoped so a future caller doesn't\
reintroduce the fight. */\n\
.page-subtitle {{ opacity: 0.7; font-size: {sec}px; }}\n\
button {{ background-color: @surface; color: @on-surface; border: none;\
border-radius: {r1}px; padding: {sm}px {lg}px; }}\n\
button:hover {{ background-color: alpha(@on-surface, 0.14); }}\n\
@ -131,8 +218,15 @@ pub fn stylesheet(p: &Palette) -> String {
button.flat {{ background-color: transparent; color: @on-bg; }}\n\
button.suggested-action {{ background-color: @accent; color: @on-accent; }}\n\
button.suggested-action:hover {{ background-color: alpha(@accent, 0.85); }}\n\
button.destructive-action {{ background-color: @red; color: @on-red; }}\n\
button.destructive-action:hover {{ background-color: alpha(@red, 0.85); }}\n\
/* Deliberately NOT @red: pywal can hand `red` any hue depending on\
the wallpaper (a blue-toned wallpaper's \"red\" slot can literally\
render blue), which would make a destructive action indistinguishable\
from a normal accent button - exactly backwards for a warning colour.\
GNOME's own destructive-action is a fixed red for the same reason;\
this is the one button style in the whole system that intentionally\
doesn't follow the palette. */\n\
button.destructive-action {{ background-color: #e01b24; color: #ffffff; }}\n\
button.destructive-action:hover {{ background-color: #c01c28; }}\n\
entry, spinbutton {{ background-color: @surface; color: @on-surface;\
border: 1px solid @overlay; border-radius: {r2}px;\
padding: {xs}px {sm}px; caret-color: @on-surface; }}\n\
@ -143,7 +237,23 @@ pub fn stylesheet(p: &Palette) -> String {
switch {{ background-color: @overlay; border-radius: {pill}px; }}\n\
switch:checked {{ background-color: @accent; }}\n\
switch slider {{ background-color: @on-surface; border-radius: {pill}px; }}\n\
/* GtkScale (sliders) render with GTK's own default accent (a fixed\
blue, independent of the app's theme) unless styled explicitly \
every app with a volume/brightness slider was silently showing\
that default instead of the palette's accent until this rule\
existed. */\n\
scale trough {{ background-color: @overlay; border-radius: {pill}px; min-height: 6px; }}\n\
scale trough highlight {{ background-color: @accent; border-radius: {pill}px; min-height: 6px; }}\n\
scale slider {{ background-color: @on-bg; border-radius: {pill}px; }}\n\
list, listbox {{ background-color: transparent; }}\n\
/* libadwaita's AdwPreferencesGroup wraps its rows in a GtkListBox\
carrying the `boxed-list` class, expecting a surface fill + radius\
to read as a card. The bare-type rule above (needed so plain\
GTK4 sidebars/lists stay transparent) was overriding that with\
equal specificity and no fill ever won, leaving preference groups\
as a bare bordered table instead of a card. This is scoped to the\
class only, so it doesn't touch any non-adw list. */\n\
list.boxed-list, listbox.boxed-list {{ background-color: @surface; border-radius: {r1}px; }}\n\
row {{ border-radius: {r2}px; }}\n\
row:selected, list row:selected {{ background-color: @accent; color: @on-accent; }}\n\
.sidebar {{ background-color: @surface; color: @on-surface; }}\n\
@ -162,7 +272,7 @@ pub fn stylesheet(p: &Palette) -> String {
textview, .mono {{ font-family: monospace; }}\n\
textview text {{ background-color: @surface; color: @on-surface; }}\n",
vars = define_colors(p),
font = FONT_FAMILY,
font = css_font_family(),
base = FONT_SIZE_BASE,
sec = FONT_SIZE_SECONDARY,
xs = SPACE_XS, sm = SPACE_SM, md = SPACE_MD, lg = SPACE_LG,
@ -181,28 +291,33 @@ pub fn render() -> String {
/// `bread-theme generate` CLI writes it. Per-session under `XDG_RUNTIME_DIR`,
/// falling back to the cache dir.
pub fn shared_css_path() -> std::path::PathBuf {
if let Ok(rt) = std::env::var("XDG_RUNTIME_DIR") {
if !rt.is_empty() {
return std::path::PathBuf::from(rt).join("bread").join("theme.css");
}
}
dirs::cache_dir()
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
.join("bread")
.join("theme.css")
output::runtime_bread_dir().join("theme.css")
}
/// Write the shared stylesheet to [`shared_css_path`] (atomic rename). Returns
/// the path written. Used by the `bread-theme` CLI.
pub fn write_shared_css() -> std::io::Result<std::path::PathBuf> {
let path = shared_css_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
write_shared_css_from(&load_palette())
}
/// `stylesheet()` with `@name` references in rule bodies replaced by hex.
/// Longer names first (`on-surface` before `surface`, `on-bg` before `bg`)
/// so a prefix match cannot half-replace `@on-bg`.
pub fn stylesheet_resolved(p: &Palette) -> String {
resolve_color_names(&stylesheet(p), p)
}
/// Replace `@define-color` names (`@accent`, `@on-bg`, …) with hex values.
/// Used by [`stylesheet_resolved`] and by GTK `bind_window` so display-global
/// named colors cannot leak the wrong monitor's accent.
pub(crate) fn resolve_color_names(css: &str, p: &Palette) -> String {
let mut pairs: Vec<(&str, String)> = color_pairs(p).into_iter().collect();
pairs.sort_by(|a, b| b.0.len().cmp(&a.0.len()));
let mut out = css.to_string();
for (name, value) in pairs {
out = out.replace(&format!("@{name}"), &value);
}
let tmp = path.with_extension("css.tmp");
std::fs::write(&tmp, render())?;
std::fs::rename(&tmp, &path)?;
Ok(path)
out
}
/// Convert a `#rrggbb` hex colour to `rgba(r, g, b, alpha)`.
@ -221,15 +336,24 @@ mod tests {
#[test]
fn css_vars_contains_all_define_color_names() {
let css = css_vars(&Palette::default());
for name in &["bg", "fg", "surface", "red", "green", "yellow", "blue", "pink", "teal", "overlay"] {
assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
for name in &[
"bg", "fg", "surface", "red", "green", "yellow", "blue", "pink", "teal", "overlay",
] {
assert!(
css.contains(&format!("@define-color {name} ")),
"missing @define-color {name}"
);
}
}
#[test]
fn css_vars_contains_font_rule() {
let css = css_vars(&Palette::default());
assert!(css.contains("Varela Round"));
assert!(css.contains("font-family: 'Varela Round', sans-serif;"));
assert!(
!css.contains("font-family: 'Varela Round, sans-serif'"),
"named face and generic fallback must not be one quoted family"
);
assert!(css.contains("14px"));
}
@ -242,8 +366,18 @@ mod tests {
// 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}");
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}"
);
}
}
@ -254,7 +388,16 @@ mod tests {
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"] {
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));
}
@ -264,13 +407,67 @@ mod tests {
fn stylesheet_defines_canonical_colors_and_components() {
let css = stylesheet(&Palette::default());
for name in &["bg", "fg", "surface", "overlay", "accent", "red", "blue"] {
assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
assert!(
css.contains(&format!("@define-color {name} ")),
"missing @define-color {name}"
);
}
// a representative spread of the shared component selectors
for sel in &["button", "entry", "switch:checked", ".card", ".sidebar", "scrollbar slider", ".title"] {
for sel in &[
"button",
"entry",
"switch:checked",
".card",
".sidebar",
"scrollbar slider",
".page-title",
] {
assert!(css.contains(sel), "stylesheet missing selector: {sel}");
}
assert!(css.contains("Varela Round"));
assert!(css.contains("font-family: 'Varela Round', sans-serif;"));
assert!(
!css.contains("font-family: 'Varela Round, sans-serif'"),
"named face and generic fallback must not be one quoted family"
);
}
#[test]
fn css_custom_properties_matches_define_colors_name_set() {
// Both must derive from the same color_pairs() list, so the web
// output can't drift from the GTK one the way css_vars/stylesheet
// used to (see css_vars_and_stylesheet_agree_on_color_block above).
let p = Palette::default();
let gtk = define_colors(&p);
let web = css_custom_properties(&p);
for (name, _) in color_pairs(&p) {
assert!(
gtk.contains(&format!("@define-color {name} ")),
"gtk missing {name}"
);
assert!(web.contains(&format!("--{name}: ")), "web missing {name}");
}
}
#[test]
fn css_custom_properties_is_valid_root_block() {
let p = Palette::default();
let css = css_custom_properties(&p);
assert!(css.starts_with(":root {\n"));
assert!(css.trim_end().ends_with('}'));
assert!(css.contains(&format!("--accent: {};", p.color4)));
}
#[test]
fn css_tokens_contains_font_and_spacing_vars() {
let css = css_tokens();
assert!(css.contains("--font-family: 'Varela Round', sans-serif;"));
assert!(
!css.contains("--font-family: 'Varela Round, sans-serif'"),
"named face and generic fallback must not be one quoted family"
);
assert!(css.contains("--font-size-base: 14px;"));
assert!(css.contains("--space-md: 12px;"));
assert!(css.contains("--radius-pill: 999px;"));
}
#[test]
@ -297,7 +494,10 @@ mod tests {
fn stylesheet_defines_on_colors() {
let css = stylesheet(&Palette::default());
for name in &["on-bg", "on-surface", "on-accent", "on-red", "on-overlay"] {
assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
assert!(
css.contains(&format!("@define-color {name} ")),
"missing @define-color {name}"
);
}
}
@ -306,13 +506,58 @@ mod tests {
// A bare `label { color: ... }` would override container colours on child
// labels — the bug that made coloured-background text illegible.
let css = stylesheet(&Palette::default());
assert!(!css.contains("label { color:"), "blanket label colour rule reintroduced");
assert!(
!css.contains("label { color:"),
"blanket label colour rule reintroduced"
);
}
#[test]
fn shared_css_path_uses_runtime_dir() {
let _lock = crate::output::XDG_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
std::env::set_var("XDG_RUNTIME_DIR", "/run/user/1234");
assert_eq!(shared_css_path(), std::path::PathBuf::from("/run/user/1234/bread/theme.css"));
assert_eq!(
shared_css_path(),
std::path::PathBuf::from("/run/user/1234/bread/theme.css")
);
}
#[test]
fn stylesheet_resolved_inlines_color4_and_drops_named_refs_in_rules() {
let mut p = Palette::default();
p.color4 = "#7aa2f7".into();
let css = stylesheet_resolved(&p);
assert!(css.contains("#7aa2f7"), "color4 must appear as hex: {css}");
// Rule bodies must not keep named colors — GTK display-global
// @define-color would otherwise leak the wrong monitor's accent.
let rules = css
.lines()
.filter(|l| !l.trim_start().starts_with("@define-color"))
.collect::<Vec<_>>()
.join("\n");
assert!(
!rules.contains("@accent"),
"leftover @accent in rules:\n{rules}"
);
assert!(
!rules.contains("@on-bg"),
"leftover @on-bg in rules:\n{rules}"
);
assert!(
!rules.contains("@on-surface"),
"leftover @on-surface in rules:\n{rules}"
);
assert!(
!rules.contains("@on-accent"),
"leftover @on-accent in rules:\n{rules}"
);
// Longer names first: @on-bg must not become @on-#...
assert!(
!rules.contains("@on-#"),
"half-replaced on-* name:\n{rules}"
);
}
#[test]

338
bread-theme/src/output.rs Normal file
View file

@ -0,0 +1,338 @@
//! Per-output (per-monitor) palette and stylesheet paths under
//! `$XDG_RUNTIME_DIR/bread/{palettes,themes}/`.
use serde::Serialize;
use std::path::{Path, PathBuf};
use crate::palette::{from_wal_json, Palette};
use crate::{load_palette, stylesheet};
/// Session-scoped `$XDG_RUNTIME_DIR/bread`, same fallback as [`crate::shared_css_path`].
pub(crate) fn runtime_bread_dir() -> PathBuf {
if let Ok(rt) = std::env::var("XDG_RUNTIME_DIR") {
if !rt.is_empty() {
return PathBuf::from(rt).join("bread");
}
}
dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join("bread")
}
/// Keep `[A-Za-z0-9._-]`; replace everything else with `_`.
pub fn sanitize_output(output: &str) -> String {
let s: String = output
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
c
} else {
'_'
}
})
.collect();
if s.is_empty() {
"_".into()
} else {
s
}
}
pub fn themes_dir() -> PathBuf {
runtime_bread_dir().join("themes")
}
pub fn palettes_dir() -> PathBuf {
runtime_bread_dir().join("palettes")
}
pub fn output_css_path(output: &str) -> PathBuf {
themes_dir().join(format!("{}.css", sanitize_output(output)))
}
pub fn output_palette_path(output: &str) -> PathBuf {
palettes_dir().join(format!("{}.json", sanitize_output(output)))
}
fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = match path.file_name().and_then(|n| n.to_str()) {
Some(name) => path.with_file_name(format!("{name}.tmp")),
None => path.with_extension("tmp"),
};
std::fs::write(&tmp, contents)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
/// Accents only — never persist pywal's light background/surface/overlay/fg.
#[derive(Serialize)]
struct StoredColors {
color1: String,
color2: String,
color3: String,
color4: String,
color5: String,
color6: String,
}
#[derive(Serialize)]
struct StoredPalette {
colors: StoredColors,
}
/// Parse on-disk JSON: wal `colors.json` shape, or a flat `{color1..color6}` object.
/// Always forces FIXED background/foreground/color0/color7 via [`from_wal_json`].
pub fn palette_from_json(json: &str) -> Option<Palette> {
let value: serde_json::Value = serde_json::from_str(json).ok()?;
if value
.get("colors")
.and_then(|c| c.as_object())
.is_some_and(|o| !o.is_empty())
{
return from_wal_json(json);
}
if value.get("color1").is_some()
|| value.get("color2").is_some()
|| value.get("color3").is_some()
|| value.get("color4").is_some()
|| value.get("color5").is_some()
|| value.get("color6").is_some()
{
let wrapped = serde_json::json!({ "colors": value });
return from_wal_json(&wrapped.to_string());
}
from_wal_json(json)
}
/// Load `palettes/<output>.json`; fall back to [`load_palette`].
pub fn load_palette_for(output: &str) -> Palette {
std::fs::read_to_string(output_palette_path(output))
.ok()
.and_then(|s| palette_from_json(&s))
.unwrap_or_else(load_palette)
}
pub fn write_output_palette(output: &str, palette: &Palette) -> std::io::Result<PathBuf> {
let path = output_palette_path(output);
let stored = StoredPalette {
colors: StoredColors {
color1: palette.color1.clone(),
color2: palette.color2.clone(),
color3: palette.color3.clone(),
color4: palette.color4.clone(),
color5: palette.color5.clone(),
color6: palette.color6.clone(),
},
};
let json = serde_json::to_string_pretty(&stored)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
atomic_write(&path, &json)?;
Ok(path)
}
pub fn write_output_css(output: &str, palette: &Palette) -> std::io::Result<PathBuf> {
let path = output_css_path(output);
atomic_write(&path, &stylesheet(palette))?;
Ok(path)
}
/// Like [`crate::write_shared_css`] but from an explicit palette.
pub fn write_shared_css_from(palette: &Palette) -> std::io::Result<PathBuf> {
let path = crate::shared_css_path();
atomic_write(&path, &stylesheet(palette))?;
Ok(path)
}
/// Isolated `wal -i <image> -n -q` with `XDG_CACHE_HOME` set to a unique temp
/// dir so the user's `~/.cache/wal` is not clobbered.
pub fn palette_from_image(path: &Path) -> std::io::Result<Palette> {
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let tmp = std::env::temp_dir().join(format!("bread-theme-wal-{pid}-{nanos}"));
std::fs::create_dir_all(&tmp)?;
struct Rm(PathBuf);
impl Drop for Rm {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let _guard = Rm(tmp.clone());
// Classic pywal ignores XDG_CACHE_HOME and writes $HOME/.cache/wal.
// Point HOME at the temp dir so a per-output extract cannot clobber
// the session cache (or the other monitor's last `wal -i`).
let status = match std::process::Command::new("wal")
.arg("-i")
.arg(path)
.args(["-n", "-q"])
.env("HOME", &tmp)
.env("XDG_CACHE_HOME", tmp.join(".cache"))
.status()
{
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"wal is not installed",
));
}
Err(e) => return Err(e),
Ok(s) => s,
};
if !status.success() {
return Err(std::io::Error::new(
std::io::ErrorKind::Other,
format!("wal failed with {status}"),
));
}
let json_path = [
tmp.join(".cache").join("wal").join("colors.json"),
tmp.join("wal").join("colors.json"),
]
.into_iter()
.find(|p| p.is_file())
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"wal did not write colors.json under the isolated cache",
)
})?;
let json = std::fs::read_to_string(&json_path)?;
from_wal_json(&json).ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
"wal produced unparseable colors.json",
)
})
}
/// [`palette_from_image`] + [`write_output_palette`] + [`write_output_css`].
pub fn generate_output(output: &str, image: &Path) -> std::io::Result<PathBuf> {
let palette = palette_from_image(image)?;
write_output_palette(output, &palette)?;
write_output_css(output, &palette)
}
#[cfg(test)]
pub(crate) static XDG_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
mod tests {
use super::*;
use crate::palette::{FIXED_BACKGROUND, FIXED_FOREGROUND, FIXED_OVERLAY, FIXED_SURFACE};
fn lock_xdg() -> std::sync::MutexGuard<'static, ()> {
XDG_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
fn with_runtime_dir<T>(f: impl FnOnce(&Path) -> T) -> T {
let _lock = lock_xdg();
let dir = std::env::temp_dir().join(format!(
"bread-theme-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let old = std::env::var("XDG_RUNTIME_DIR").ok();
std::env::set_var("XDG_RUNTIME_DIR", &dir);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&dir)));
match old {
Some(v) => std::env::set_var("XDG_RUNTIME_DIR", v),
None => std::env::remove_var("XDG_RUNTIME_DIR"),
}
let _ = std::fs::remove_dir_all(&dir);
match result {
Ok(v) => v,
Err(e) => std::panic::resume_unwind(e),
}
}
#[test]
fn sanitize_output_keeps_hyprland_connectors() {
assert_eq!(sanitize_output("HDMI-A-1"), "HDMI-A-1");
assert_eq!(sanitize_output("eDP-1"), "eDP-1");
assert_eq!(sanitize_output("DP-2"), "DP-2");
}
#[test]
fn sanitize_output_replaces_unsafe_chars() {
assert_eq!(sanitize_output("HDMI A:1"), "HDMI_A_1");
assert_eq!(sanitize_output("foo/bar"), "foo_bar");
assert_eq!(sanitize_output(""), "_");
assert_eq!(sanitize_output("..ok_name-1"), "..ok_name-1");
}
#[test]
fn output_paths_use_sanitize_and_sit_under_dirs() {
let _lock = lock_xdg();
std::env::set_var("XDG_RUNTIME_DIR", "/run/user/1234");
let css = output_css_path("HDMI A:1");
let pal = output_palette_path("HDMI A:1");
assert_eq!(css, themes_dir().join("HDMI_A_1.css"));
assert_eq!(pal, palettes_dir().join("HDMI_A_1.json"));
assert!(css.starts_with(themes_dir()));
assert!(pal.starts_with(palettes_dir()));
assert_eq!(
output_css_path("eDP-1"),
PathBuf::from("/run/user/1234/bread/themes/eDP-1.css")
);
}
#[test]
fn load_palette_for_missing_file_has_fixed_bg() {
with_runtime_dir(|_| {
let p = load_palette_for("no-such-output");
assert_eq!(p.background, FIXED_BACKGROUND);
assert!(p.color4.starts_with('#'));
});
}
#[test]
fn write_output_palette_roundtrips_color4() {
with_runtime_dir(|_| {
let mut p = Palette::default();
p.color4 = "#7aa2f7".into();
p.background = "#ffffff".into();
write_output_palette("HDMI-A-1", &p).unwrap();
let loaded = load_palette_for("HDMI-A-1");
assert_eq!(loaded.color4, "#7aa2f7");
assert_eq!(loaded.background, FIXED_BACKGROUND);
assert_eq!(loaded.foreground, FIXED_FOREGROUND);
assert_eq!(loaded.color0, FIXED_SURFACE);
assert_eq!(loaded.color7, FIXED_OVERLAY);
});
}
#[test]
fn write_shared_css_from_writes_shared_css_path() {
with_runtime_dir(|rt| {
let path = write_shared_css_from(&Palette::default()).unwrap();
assert_eq!(path, crate::shared_css_path());
assert_eq!(path, rt.join("bread").join("theme.css"));
let css = std::fs::read_to_string(&path).unwrap();
assert!(css.contains("@define-color accent "));
});
}
#[test]
fn load_palette_for_accepts_flat_color_object() {
with_runtime_dir(|_| {
let path = output_palette_path("DP-1");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, r##"{"color4":"#112233","color1":"#abcdef"}"##).unwrap();
let p = load_palette_for("DP-1");
assert_eq!(p.color4, "#112233");
assert_eq!(p.color1, "#abcdef");
assert_eq!(p.background, FIXED_BACKGROUND);
});
}
}

View file

@ -9,10 +9,10 @@ use std::path::PathBuf;
/// 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";
pub(crate) const FIXED_BACKGROUND: &str = "#0c0c0c";
pub(crate) const FIXED_FOREGROUND: &str = "#e8e8e8";
pub(crate) const FIXED_SURFACE: &str = "#1a1a1a";
pub(crate) 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,
@ -84,7 +84,10 @@ pub fn load_palette() -> Palette {
pub(crate) fn from_wal_json(json: &str) -> Option<Palette> {
let wal: WalColors = serde_json::from_str(json).ok()?;
let c = |k: &str, fallback: &str| -> String {
wal.colors.get(k).cloned().unwrap_or_else(|| fallback.into())
wal.colors
.get(k)
.cloned()
.unwrap_or_else(|| fallback.into())
};
Some(Palette {
background: FIXED_BACKGROUND.into(),

View file

@ -15,7 +15,7 @@ dirs = { workspace = true }
gtk4 = { version = "0.11", features = ["v4_12"], optional = true }
gtk4-layer-shell = { version = "0.8", optional = true }
toml_edit = { version = "0.22", optional = true }
bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.7.0", optional = true }
bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.8.0", optional = true }
[features]
# Enable the layer-shell popup scaffold (breadbox, breadclip). Kept optional

View file

@ -14,10 +14,15 @@
//!
//! A sibling app must never crash or block because breadd is down,
//! restarting, or was never installed. Concretely:
//! - [`BreadClient::emit`] is a best-effort, fire-and-forget single-shot
//! connection (mirroring `bread-emit`'s own stance) — if breadd is
//! unreachable, the event is silently dropped, not an error the caller
//! has to handle.
//! - [`BreadClient::emit`] and [`BreadClient::command`] are best-effort,
//! fire-and-forget single-shot connections (mirroring `bread-emit`'s
//! own stance) — if breadd is unreachable, the event is silently
//! dropped, not an error the caller has to handle.
//! - [`BreadClient::health`] / [`BreadClient::api_version`] return `None`
//! when breadd is unreachable or the response is missing fields.
//! Long-running daemons SHOULD log a warning in that case and MUST NOT
//! crash. [`BreadClient::connect`] never fails just because breadd is
//! down — do not change that.
//! - [`BreadClient::subscribe`] runs its read loop on a background thread
//! that reconnects with exponential backoff on any disconnect. The
//! caller's callback simply stops being invoked while disconnected; it
@ -29,6 +34,15 @@
//! outside the app's own `bread.<app_id>.*` segment, so a misconfigured
//! caller fails fast instead of discovering the mistake from the daemon's
//! rejection. The daemon enforces the same rule server-side regardless.
//!
//! `command` is the outbound half of the same story: it publishes
//! `bread.command.<target_app>.<verb>` as an **unsourced** IPC emit
//! (`params` is `{ event, data }` only — no `source`/`kind`). The
//! daemon treats unsourced `bread.command.<known_app>.*` as legal so a
//! sibling can address another app without impersonating that app's
//! own namespace. Local refusal (eprint + return, same stance as
//! `emit`) if `target_app` or `verb` is empty, or if `verb` contains
//! `.` (a command verb is a single segment).
use std::io::{BufRead, BufReader, Write};
use std::net::Shutdown;
@ -94,25 +108,104 @@ impl BreadClient {
return;
}
fire_and_forget_emit(json!({
"event": event,
"source": self.app_id,
"kind": event,
"data": data,
}));
}
/// Publish `bread.command.<target_app>.<verb>` as an unsourced IPC
/// emit so another bread app (or a Lua workflow) can act on it.
///
/// Fire-and-forget: same silent-if-down stance as [`emit`]. Locally
/// refuses (eprint + return, no socket) if `target_app` or `verb` is
/// empty, or if `verb` contains `.` — a verb is one segment
/// (`clear`, not `history.clear`).
///
/// The wire payload is `{ method: "emit", params: { event, data } }`
/// with **no** `source`/`kind`. Do not add those: a sourced emit
/// would have to claim the *target's* namespace (or ours), and the
/// daemon half of this integration is specifically making unsourced
/// `bread.command.<known_app>.*` legal.
pub fn command(&self, target_app: &str, verb: &str, data: Value) {
if target_app.is_empty() || verb.is_empty() || verb.contains('.') {
eprintln!(
"bread-client: refusing to send command to '{target_app}' with verb '{verb}' \
(target and verb must be non-empty; verb must be a single segment)"
);
return;
}
let event = format!("bread.command.{target_app}.{verb}");
fire_and_forget_emit(json!({
"event": event,
"data": data,
}));
}
/// One-shot `health` IPC request. `None` if breadd is unreachable or
/// the response is malformed / an error.
///
/// Long-running daemons SHOULD log a warning when this returns
/// `None` (or when [`api_version`] is missing) and MUST NOT crash.
pub fn health(&self) -> Option<Value> {
self.request("health", json!({}))
}
/// `api_version` string from [`health`], or `None` if health failed
/// or the field is absent. Same SHOULD-warn / MUST-NOT-crash rule
/// as [`health`].
pub fn api_version(&self) -> Option<String> {
self.health()?
.get("api_version")
.and_then(Value::as_str)
.map(str::to_owned)
}
/// Send a one-shot IPC request and return its `result`, or `None` on any
/// failure — breadd unreachable, a malformed response, or an `error`
/// field in the response. Mirrors `emit`'s graceful-degradation stance:
/// a caller checks for `None` the same way it'd handle "daemon not
/// installed," not via a `Result` that forces error-path plumbing for
/// what is, for most callers (a refresh-on-connect read), an expected
/// possibility rather than an exceptional one.
///
/// Unlike `emit`, this is not restricted to the client's own namespace —
/// `method`/`params` map directly onto breadd's IPC method table (see
/// `Documentation.md`'s "Dictionary: IPC protocol"), most of which
/// (`state.get`, `widgets.list`, ...) are cross-namespace reads by
/// design. Only first-party compiled code links `bread-utils`, so this
/// carries the same trust level as `emit`'s own request construction.
pub fn request(&self, method: &str, params: Value) -> Option<Value> {
let request = json!({
"id": "0",
"method": "emit",
"params": {
"event": event,
"source": self.app_id,
"kind": event,
"data": data,
}
"method": method,
"params": params,
});
let Ok(line) = serde_json::to_string(&request) else {
return;
};
let line = serde_json::to_string(&request).ok()?;
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}");
let mut stream = UnixStream::connect(bread_shared::resolve_socket_path()).ok()?;
stream
.set_write_timeout(Some(Duration::from_millis(200)))
.ok()?;
stream
.set_read_timeout(Some(Duration::from_millis(500)))
.ok()?;
writeln!(stream, "{line}").ok()?;
let mut response_line = String::new();
BufReader::new(stream).read_line(&mut response_line).ok()?;
if response_line.trim().is_empty() {
return None;
}
let value: Value = serde_json::from_str(&response_line).ok()?;
if value.get("error").is_some() {
return None;
}
value.get("result").cloned()
}
/// Subscribe to events matching `pattern` (glob: `*`/`**`/`?`), invoking
@ -161,6 +254,27 @@ impl BreadClient {
}
}
/// Fire-and-forget a single `emit` request. Shared by sourced [`BreadClient::emit`]
/// and unsourced [`BreadClient::command`] so the write/timeout path cannot
/// drift. Silent if the socket is missing, the write fails, or the body
/// cannot be serialized.
fn fire_and_forget_emit(params: Value) {
let request = json!({
"id": "0",
"method": "emit",
"params": params,
});
let Ok(line) = serde_json::to_string(&request) else {
return;
};
let Ok(mut stream) = UnixStream::connect(bread_shared::resolve_socket_path()) else {
return;
};
let _ = stream.set_write_timeout(Some(Duration::from_millis(200)));
let _ = writeln!(stream, "{line}");
}
/// Connects once, sends `events.subscribe`, and invokes `on_event` for every
/// matching line until the connection ends (cleanly or with an error).
/// Stores the live stream in `current_stream` so [`Subscription::stop`] can
@ -290,6 +404,83 @@ mod tests {
// integration tests for the IPC-side of namespace validation.
}
/// Point `HOME` + `XDG_RUNTIME_DIR` at an empty temp dir so
/// `resolve_socket_path` cannot find a live breadd (either via
/// `~/.config/bread/breadd.toml` or `$XDG_RUNTIME_DIR/bread/breadd.sock`).
/// Serialized with the other env-mutating tests via `env_test_lock`.
fn with_unreachable_daemon<T>(f: impl FnOnce() -> T) -> T {
let _lock = crate::env_test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
let old_home = std::env::var("HOME").ok();
let old_xdg = std::env::var("XDG_RUNTIME_DIR").ok();
unsafe {
std::env::set_var("HOME", tmp.path());
std::env::set_var("XDG_RUNTIME_DIR", tmp.path());
}
struct Restore(Option<String>, Option<String>);
impl Drop for Restore {
fn drop(&mut self) {
unsafe {
match &self.0 {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
match &self.1 {
Some(v) => std::env::set_var("XDG_RUNTIME_DIR", v),
None => std::env::remove_var("XDG_RUNTIME_DIR"),
}
}
}
}
let _restore = Restore(old_home, old_xdg);
f()
}
#[test]
fn request_returns_none_when_daemon_is_unreachable() {
with_unreachable_daemon(|| {
let client = BreadClient::connect("clip");
assert!(client.request("widgets.list", json!(null)).is_none());
});
}
#[test]
fn command_refuses_empty_target_without_connecting() {
let client = BreadClient::connect("clip");
client.command("", "clear", json!({}));
}
#[test]
fn command_refuses_empty_verb_without_connecting() {
let client = BreadClient::connect("clip");
client.command("clip", "", json!({}));
}
#[test]
fn command_refuses_dotted_verb_without_connecting() {
// A verb is a single segment — `history.clear` would produce
// `bread.command.clip.history.clear`, which is two verb segments.
let client = BreadClient::connect("clip");
client.command("clip", "history.clear", json!({}));
}
#[test]
fn command_is_a_silent_no_op_when_daemon_is_unreachable() {
let client = BreadClient::connect("clip");
client.command("clip", "clear", json!({ "n": 1 }));
}
#[test]
fn health_and_api_version_return_none_when_daemon_is_unreachable() {
with_unreachable_daemon(|| {
let client = BreadClient::connect("clip");
assert!(client.health().is_none());
assert!(client.api_version().is_none());
});
}
#[test]
fn subscription_stop_joins_the_background_thread() {
let client = BreadClient::connect("clip");

View file

@ -19,12 +19,18 @@
//! - [`gtk_popup`] (feature `gtk`) — shared layer-shell popup window setup,
//! list navigation, and click-outside-to-close.
//! - [`bread_client`] (feature `bread-client`) — a persistent-connection
//! client for breadd's IPC socket (emit + subscribe), for sibling
//! `bread*` app daemons integrating with the bread automation fabric.
//! client for breadd's IPC socket (`emit`, unsourced `command`,
//! `health`/`api_version`, `subscribe`), for sibling `bread*` app
//! daemons integrating with the bread automation fabric.
//! - [`screenshot_cli`] — shared `--screenshot` / `--output` /
//! `--width` / `--height` values, `SETTLE_DELAY` (300ms), and the
//! "both flags or neither" validator. Next-pin helper; no clap/GTK
//! dependency. Does not replace `bread-screenshots` or `bread-capture`.
pub mod atomic;
pub mod hypr;
pub mod proc;
pub mod screenshot_cli;
pub mod singleton;
pub mod xdg;

View file

@ -0,0 +1,156 @@
//! Shared `--screenshot` CLI flags and the post-`map` settle delay used by
//! bread-capture-driven GTK apps.
//!
//! The same clap block (`--screenshot`, `--output`, `--width`, `--height`)
//! plus a 300ms settle after GTK `map` is cloned across breadbar, breadbox,
//! breadclip, breadpad, breadman, breadsearch, and breadhelp. This module
//! is the next-pin target for that duplication — consumers this cycle still
//! pin an older bread-utils tag and will not see it until a future release.
//!
//! Zero extra deps: no clap, no GTK. Apps keep (or flatten) the four `#[arg]`
//! fields themselves and call [`validate_pair`] / [`SETTLE_DELAY`].
//!
//! Confirmed present in:
//! - `breadbar/src/screenshot.rs`
//! - `breadbox/breadbox/src/screenshot.rs`
//! - `breadclip/breadclip/src/screenshot.rs`
//! - `breadsearch/breadsearch/src/screenshot.rs`
//! - `breadpad/breadpad/src/screenshot.rs`
//! - `breadpad/breadman/src/screenshot.rs`
//! - `breadhelp/src/screenshot.rs`
//!
//! Do not fold `bread-screenshots` or `bread-capture`'s `TARGETS` table into
//! this module — those are the capture primitive and the orchestrator, not
//! the per-app CLI flags.
use std::path::{Path, PathBuf};
use std::time::Duration;
/// Extra settle time after GTK `map` for the first frame to actually paint
/// before grim runs. `map` fires once the surface exists, not once anything
/// has been drawn into it.
pub const SETTLE_DELAY: Duration = Duration::from_millis(300);
/// Default `--width`, matching `bread-capture --isolate-width`.
pub const DEFAULT_WIDTH: u32 = 1920;
/// Default `--height`, matching `bread-capture --isolate-height`.
pub const DEFAULT_HEIGHT: u32 = 1080;
/// The four `--screenshot` / `--output` / `--width` / `--height` values
/// parsed from an app's CLI.
///
/// `screenshot` and `output` must both be present (a capture run) or both
/// absent (a normal run) — see [`ScreenshotCli::validate`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScreenshotCli {
/// Named view to capture (`--screenshot`). `None` for a normal run.
pub screenshot: Option<String>,
/// PNG path to write (`--output`). Required together with `screenshot`.
pub output: Option<PathBuf>,
/// Capture canvas width (`--width`).
pub width: u32,
/// Capture canvas height (`--height`).
pub height: u32,
}
impl Default for ScreenshotCli {
fn default() -> Self {
Self {
screenshot: None,
output: None,
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
}
}
}
/// Why a screenshot-flag pair is invalid.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScreenshotCliError {
/// `--screenshot` was given without `--output`.
ScreenshotWithoutOutput,
/// `--output` was given without `--screenshot`.
OutputWithoutScreenshot,
}
impl std::fmt::Display for ScreenshotCliError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ScreenshotWithoutOutput => write!(f, "--screenshot requires --output"),
Self::OutputWithoutScreenshot => write!(f, "--output requires --screenshot"),
}
}
}
impl std::error::Error for ScreenshotCliError {}
/// Both `--screenshot` and `--output` must be present, or neither.
pub fn validate_pair(
screenshot: Option<&str>,
output: Option<&Path>,
) -> Result<(), ScreenshotCliError> {
match (screenshot, output) {
(Some(_), Some(_)) | (None, None) => Ok(()),
(Some(_), None) => Err(ScreenshotCliError::ScreenshotWithoutOutput),
(None, Some(_)) => Err(ScreenshotCliError::OutputWithoutScreenshot),
}
}
impl ScreenshotCli {
/// Both `screenshot` and `output` present, or neither.
pub fn validate(&self) -> Result<(), ScreenshotCliError> {
validate_pair(self.screenshot.as_deref(), self.output.as_deref())
}
/// `true` when this is a capture run (both flags present).
pub fn is_screenshot_run(&self) -> bool {
self.screenshot.is_some() && self.output.is_some()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settle_delay_is_300ms() {
assert_eq!(SETTLE_DELAY, Duration::from_millis(300));
}
#[test]
fn neither_flag_is_ok() {
assert!(validate_pair(None, None).is_ok());
assert!(ScreenshotCli::default().validate().is_ok());
assert!(!ScreenshotCli::default().is_screenshot_run());
}
#[test]
fn both_flags_are_ok() {
let cli = ScreenshotCli {
screenshot: Some("search".into()),
output: Some(PathBuf::from("/tmp/out.png")),
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
};
assert!(cli.validate().is_ok());
assert!(cli.is_screenshot_run());
}
#[test]
fn screenshot_without_output_is_an_error() {
assert_eq!(
validate_pair(Some("search"), None),
Err(ScreenshotCliError::ScreenshotWithoutOutput)
);
}
#[test]
fn output_without_screenshot_is_an_error() {
let path = PathBuf::from("/tmp/out.png");
assert_eq!(
validate_pair(None, Some(path.as_path())),
Err(ScreenshotCliError::OutputWithoutScreenshot)
);
}
}

30
ci/Containerfile Normal file
View file

@ -0,0 +1,30 @@
# Shared CI build environment for bread-ecosystem GTK4/libadwaita apps.
#
# Arch base: current gtk4/libadwaita/gtk4-layer-shell/graphene are all
# available as prebuilt pacman packages, so no from-source library builds
# are needed (unlike Fedora, where breadpad's CI used to rebuild libadwaita
# from source on every single run and broke repeatedly on version drift).
#
# Base image pinned by digest, package set frozen at build time: this image
# only changes when someone deliberately rebuilds it, not on every push.
# Product repos that depend on this file should pin it to a commit sha
# (see each product's ci/bread-ecosystem.rev), not track `main` — otherwise
# an unrelated change here silently breaks every product's next release.
#
# EXTRA_PKGS lets a product layer on extra pacman packages (see that
# product's ci/deps.txt) without forking this file.
FROM archlinux@sha256:fae033b815a16f930325c2697e620362be4d2e5d739a301b10ad1fc9c8643a06
ARG EXTRA_PKGS=""
RUN pacman -Syu --noconfirm --needed \
base-devel \
git \
pkgconf \
rust \
gtk4 \
libadwaita \
gtk4-layer-shell \
graphene \
${EXTRA_PKGS} \
&& pacman -Scc --noconfirm

60
ci/build.sh Executable file
View file

@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Shared CI build script for bread-ecosystem GTK4/libadwaita apps.
#
# Builds (or reuses, via docker's own layer cache) the pinned Arch image
# from ci/Containerfile, then runs the given cargo command inside it
# against a product repo checkout.
#
# Usage: ci/build.sh <product-name> <product-repo-root> <cargo-command...>
# e.g. ci/build.sh breadpad /path/to/breadpad cargo build --release --locked
#
# <product-name> is used verbatim as the image tag and cache-volume name —
# it must be passed explicitly rather than derived from <product-repo-root>'s
# basename, because every product's CI checks out into a directory literally
# named `src`, which would otherwise collide across every product sharing
# this runner (same image tag, same cargo-target cache volume).
#
# If <product-repo-root>/ci/deps.txt exists (one pacman package per line,
# '#' comments and blank lines ignored), those packages are installed on
# top of the shared base image.
#
# Cargo's registry/git caches are shared across all products (same crates
# regardless of which app is building); CARGO_TARGET_DIR is cached
# per-product. Both persist in named docker volumes across runs.
set -euo pipefail
if [ $# -lt 3 ]; then
echo "usage: build.sh <product-name> <product-repo-root> <cargo-command...>" >&2
exit 1
fi
PRODUCT="$1"
REPO_ROOT="$(cd "$2" && pwd)"
shift 2
CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
EXTRA_PKGS=""
if [ -f "${REPO_ROOT}/ci/deps.txt" ]; then
EXTRA_PKGS="$(grep -vE '^\s*(#|$)' "${REPO_ROOT}/ci/deps.txt" | tr '\n' ' ')"
fi
docker build \
--build-arg "EXTRA_PKGS=${EXTRA_PKGS}" \
-t "bread-ci:${PRODUCT}" \
-f "${CI_DIR}/Containerfile" "${CI_DIR}"
docker run --rm \
-v "${REPO_ROOT}:/workspace" \
-v "bread-ci-cargo-registry:/root/.cargo/registry" \
-v "bread-ci-cargo-git:/root/.cargo/git" \
-v "bread-ci-${PRODUCT}-target:/cargo-target" \
-w /workspace \
-e CARGO_TARGET_DIR=/cargo-target \
"bread-ci:${PRODUCT}" \
bash -c '
set -euo pipefail
"$@"
mkdir -p /workspace/target
cp -a /cargo-target/. /workspace/target/
' bash "$@"

View file

@ -38,18 +38,107 @@ only if:
`archlinux:latest` container and `curl -X PUT`s the resulting
`.pkg.tar.zst` to `https://git.breadway.dev/api/packages/Breadway/arch/os`.
A repo can be on **both** channels (most GUI/daemon apps are — see
breadbar, breadbox, breadcrumbs, bread, breadpad, breadpaper), **bakery
only** (breadclip, breadmon, breadsearch, breadshot, bread-theme, bakery
itself), **pacman only** (breadlock, breadhelp — both are OS-integration
pieces where package-manager rigor matters more than a curl-script), or
**neither** (dev-only / not yet released; no bakery.toml, no PKGBUILD, no
release or package workflow — just the repo itself, e.g. breadarr today).
A repo can be on **both** channels, **bakery only** (bread, breadbar,
breadbox, breadcrumbs, breadpad, breadpaper, breadclip, breadmon,
breadsearch, breadshot, breadhelp, bos-settings, bread-theme, breadcast,
breadarr, bakery itself), **pacman only** (breadlock — installs a
root-owned `/etc/pam.d/breadlock` PAM service file with no per-user
equivalent, so it can never move to bakery), or **neither** (dev-only /
not yet released). Desktop apps dropped pacman packaging; bakery-channel
install is the supported path. `bakery` still carries a leftover
`package.yml` / `packaging/arch/PKGBUILD` from when it was also published
to the `[breadway]` pacman repo.
`bos` is a fourth, deliberately special case: it ships as an ISO, not a
binary, via its own `release-iso.yml`. It is never on either channel and
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`, `beta`, and `dev`. Don't confuse "track" with
"channel" above — channel is *how* a binary reaches a user (bakery vs.
pacman); track is *which build* of a bakery-channel package they get.
There is no per-track branch anymore — every bakery-channel repo has exactly
one long-lived branch, `main`. Tracks are driven entirely by *what you push*,
not *which branch you push to*:
| Track | Index URL | Artifact root | Trigger |
|---|---|---|---|
| stable | `dl.breadway.dev/index.json` | `/srv/breadway-dl/<pkg>/<ver>/` | push tag `vX.Y.Z` |
| beta | `dl.breadway.dev/beta/index.json` | `/srv/breadway-dl/beta/<pkg>/<ver>/` | push tag `vX.Y.Z-rc.N` |
| dev | `dl.breadway.dev/dev/index.json` | `/srv/breadway-dl/dev/<pkg>/<ver>/` | push to branch `main` |
`scripts/gen-index.sh` takes a `TRACK` env var (default `stable`) to select
which subtree it reads/writes — this didn't need to change. Dev/beta builds
skip the GitHub Release upload step entirely (no release-per-commit spam) —
`dl.breadway.dev` is their only distribution point.
**Why no beta/dev branches**: the old model had `dev`/`beta`/`main` as three
separate branches, with `beta` cut from `dev` periodically and `main`
supposed to move forward only via a `beta` merge. In practice `main` rotted
silently in most repos — the "merge beta into main" step was a manual,
easy-to-forget action across a dozen-plus repos with no team and no
calendar enforcement, and it also collided with a real Forgejo Actions
gotcha: tag-triggered workflows resolve *which version of the workflow
YAML to run* from the repo's default branch, not the tagged commit's
branch, so a stale `main` could silently run stale release logic even when
the tag itself pointed at fresh code. Collapsing everything onto one
branch removes the class of bug entirely — there's nothing left to fall
out of sync.
**The full lifecycle** (see also `CONTRIBUTING.md`): day-to-day work lands
on `feature/<name>` or `fix/<issue>` branches, merged into `main`. `main`
publishes a fresh dev-track build on every push — this is the "test for a
while, fix forward with another push" loop. When you want to stabilize
before a real release, tag a release candidate directly off whatever
commit on `main` you're happy with: `git tag vX.Y.Z-rc.1 && git push
origin vX.Y.Z-rc.1` (both remotes). "Freezing" is just pausing pushes to
`main` while the RC gets tested, not a branch operation — cut `-rc.2`,
`-rc.3`, etc. for further fixes without needing to touch any branch. Once
an RC has gone without issues, tag the real release the same way, dropping
the `-rc.N` suffix (`vX.Y.Z`) — that's what fires `release.yml`.
Auto-versioning: `dev` computes its build version from the latest published
*stable* `vX.Y.Z` tag (via `git ls-remote --tags`, filtered to exclude any
tag containing a `-`, not `Cargo.toml``Cargo.toml` can drift stale
relative to the actual last release) plus a `-dev.<timestamp>+<sha>`
suffix. `beta` needs no computation at all — the RC tag itself
(`X.Y.Z-rc.N`) is already valid semver and is used as the version verbatim.
`bakery`'s semver check (`is_newer`), backed by the real `semver` crate,
already orders these correctly with zero special-casing: a prerelease
identifier sorts below the same version without one, and `dev` < `rc`
alphabetically, giving `X.Y.Z-dev... < X.Y.Z-rc.N < X.Y.Z` for the same
base version.
**Bakery package version honesty**: `bakery --version` is compiled from
this repo's `[workspace.package] version` (`CARGO_PKG_VERSION`); `bakery
list` reports the *tagged* package version from the index. Those two must
match at tag time — set `workspace.package.version` to `X.Y.Z` *before*
pushing `vX.Y.Z` or `vX.Y.Z-rc.N`, and never jump a git tag without that
Cargo.toml bump. The `v0.3.1``v0.7.1` tag jump that left Cargo.toml at
`0.3.1` is the bug this rule exists to prevent. Dev-track auto-versioning
keys off the latest stable tag rather than Cargo.toml so a stale
workspace version cannot publish a dev build that sorts *older* than
installed bakery; that fallback is not permission to leave the workspace
version stale.
Adding dev/beta to a bakery-channel repo: copy `dev-bakery.yml` /
`rc-bakery.yml` (or `bread`'s `dev-release.yml` / `rc-release.yml` if the
repo isn't part of this monorepo) from `bread-ecosystem`/`bread`, and swap
the repo/binary names the same way the checklist below describes for
`release.yml`. No branch setup needed beyond the repo's single `main`. Not
every bakery-channel repo needs beta/dev on day one — `gen-index.sh`
silently skips any product with no release dir under a given track's tree,
same as it already does for an unreleased product on stable.
Client side: `bakery track show` / `bakery track set <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
@ -66,10 +155,11 @@ missing it; that gap is intentional and about to be moot everywhere.
- **Bakery**: write `bakery.toml`, add a `[[products]]` entry to
`bread-ecosystem/registry/bread-ecosystem.toml`, copy a sibling's
`release.yml` (prefer one with the same shape: single binary vs. binary +
systemd service — compare against `bread/release.yml` if there's a
service to install, `breadmon/release.yml` if not) and swap the repo
name / binary name / `PKG_DIR`.
`dev-release.yml` / `rc-release.yml` / `release.yml` trio (prefer one with
the same shape: single binary vs. binary + systemd service — compare
against `bread`'s if there's a service to install, `breadmon`'s if not)
and swap the repo name / binary name / `PKG_DIR`. No branch setup beyond
the repo's single `main`.
- **Pacman**: write `packaging/PKGBUILD` (or `packaging/arch/PKGBUILD`),
copy a sibling's `package.yml` and swap the repo/package name and
`system_deps``pacman -Syu` package list.
@ -82,13 +172,21 @@ missing it; that gap is intentional and about to be moot everywhere.
## Current state (as of this pass)
| Repo | bakery | pacman | notes |
|---|---|---|---|
| bread-ecosystem (bakery product) | yes | yes | `release-bakery.yml` recovered from a dead `.github/workflows/release.yml` that referenced a `hestia` self-hosted runner GitHub never had registered |
| bread-ecosystem (bread-theme product) | yes | no | |
| bread, breadbar, breadbox, breadcrumbs, breadpad, breadpaper | yes | yes | complete, used as templates |
| breadclip, breadmon, breadsearch, breadshot | yes | no | complete |
| breadlock, breadhelp | no | yes | breadlock's `bakery.toml` was removed as orphaned; its README wrongly claimed it was a registry entry |
| bos-settings | yes | yes | was missing both the registry entry and `release.yml`; both added |
| bos | no | no | ISO-only via `release-iso.yml`; had an erroneous `bakery.toml` copy-pasted from bos-settings, removed |
| breadarr | no | no | had an orphaned `bakery.toml` with no registry entry and zero workflows; removed. Not yet assigned a channel — do that deliberately when it's ready to ship, don't infer it from a stray config file |
| Repo | bakery | pacman | tracks | notes |
|---|---|---|---|---|
| bread-ecosystem (bakery product) | yes | leftover `package.yml` | stable, beta, dev | bakery-channel (`curl -fsSL https://get.breadway.dev \| sh`) is the supported install; `package.yml` + `packaging/arch/PKGBUILD` remain from when bakery was also published to `[breadway]` |
| bread-ecosystem (bread-theme product) | yes | no | stable, beta, dev | single-trunk model |
| bread, breadbar, breadbox, breadcrumbs, breadpad, breadpaper | yes | no | stable, beta, dev | pacman packaging (PKGBUILD + `package.yml`) dropped — bakery-only, single-trunk model |
| breadclip, breadmon, breadsearch, breadshot | yes | no | stable, beta, dev | single-trunk model |
| breadhelp, bos-settings | yes | no | stable, beta, dev | bakery-channel desktop/settings apps; not pacman |
| breadlock | no | yes | n/a | deliberate, permanent exception — installs a root-owned `/etc/pam.d/breadlock` PAM service file with no per-user equivalent, so it can never move to bakery |
| bos | no | no | n/a | ISO-only via `release-iso.yml`; ships via a manual local build (`build-local.sh`), not a CI track — see its own branch note below |
| breadcast | yes | no | stable, beta, dev | bakery product; not included in the BOS ISO |
| breadarr | yes | no | stable, beta, dev | bakery product; homelab, not shipped on BOS |
`bos` doesn't follow the tracks table above (it has no `dev`/`beta`/`stable`
publish cadence — ISO builds are deliberate and manual) but does share the
single-`main`-branch model for the same rot-avoidance reason. It additionally
carries a `stable` branch that CI fast-forwards to whatever commit the latest
`vX.Y.Z` tag points at — a marker only, never merged into by hand, so it
can't drift the way a manually-promoted branch did before.

View file

@ -1,6 +1,8 @@
# Maintainer: Breadway <plasticbread849@gmail.com>
pkgname=bakery
# Template only — package.yml sed-replaces this from the git tag at build
# time. 0.2.3 is not the current bakery release.
pkgver=0.2.3
pkgrel=1
pkgdesc="Package manager for the bread ecosystem"

View file

@ -71,3 +71,20 @@ description = "Screenshot utility for the bread ecosystem"
name = "bos-settings"
repo = "Breadway/bos-settings"
description = "System settings app for Bread OS"
[[products]]
name = "breadhelp"
repo = "Breadway/breadhelp"
description = "Onboarding and help center for Bread OS"
[[products]]
name = "breadcast"
repo = "Breadway/breadcast"
description = "Cast your screen to any Chromecast/Google TV or DLNA renderer — daemon + GTK4 popup"
notes = "Bakery product; not included in the BOS ISO"
[[products]]
name = "breadarr"
repo = "Breadway/breadarr"
description = "Single-daemon Sonarr+Radarr+Prowlarr replacement — release watching, matching, grabbing, importing, and a terminal UI, no web UI"
notes = "Homelab, not shipped on BOS"

View file

@ -14,9 +14,20 @@
#
# scripts/doctor-channels.sh ~/Projects
#
# Also checks every registry product's Forgejo repo for the
# BAKERY_MINISIGN_SEC_KEY_PATH Actions secret (via the Forgejo API) — a
# split-out product repo silently missing this secret is exactly the gotcha
# that bit breadcast's onboarding: its release workflows would either fail
# the hard-fail guard (dev/rc-style workflows) or, worse, publish unsigned
# (older release.yml-style workflows without that guard). Skipped with a
# warning (not a failure) if ~/.config/forgejo/token doesn't exist, so this
# still runs for anyone without API access — set SKIP_SECRETS_CHECK=1 to
# skip it deliberately (e.g. offline).
#
# Exits 0 if no drift found, 1 if any repo has drift (so it's CI-friendly).
#
# Requires: python3 (tomllib, stdlib since 3.11)
# Requires: python3 (tomllib, stdlib since 3.11); curl + a Forgejo token at
# ~/.config/forgejo/token for the secrets check (soft-skipped without one).
set -euo pipefail
@ -72,7 +83,10 @@ for dir in "${BASE_DIR}"/*/; do
# 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
# Prefix match, not exact: bread-ecosystem-breadcast, bread-ecosystem-onboard,
# etc. are all worktree checkouts of this same multi-product repo, not
# separate products the registry-membership check below applies to.
[[ "${name}" == bread-ecosystem* ]] && continue
checked=$((checked + 1))
has_bakery_toml=0
@ -125,6 +139,55 @@ done
echo
echo "checked ${checked} repos under ${BASE_DIR}"
# --- Secrets check ---------------------------------------------------------
TOKEN_FILE="${HOME}/.config/forgejo/token"
if [[ -n "${SKIP_SECRETS_CHECK:-}" ]]; then
echo "skipping secrets check (SKIP_SECRETS_CHECK set)"
elif [[ ! -f "${TOKEN_FILE}" ]]; then
echo "skipping secrets check (no token at ${TOKEN_FILE})"
else
TOKEN="$(cat "${TOKEN_FILE}")"
FORGEJO_API="https://git.breadway.dev/api/v1"
# Full "owner/repo" slugs, deduplicated (bakery + bread-theme both point
# at Breadway/bread-ecosystem, checking it twice is wasted API calls).
mapfile -t registry_slugs < <(python3 -c "
import tomllib
with open('${REGISTRY}', 'rb') as f:
d = tomllib.load(f)
seen = set()
for p in d['products']:
if p['repo'] not in seen:
seen.add(p['repo'])
print(p['repo'])
")
secrets_missing=0
for slug in "${registry_slugs[@]}"; do
has_key="$(curl -s -H "Authorization: token ${TOKEN}" \
"${FORGEJO_API}/repos/${slug}/actions/secrets" \
| python3 -c "
import json, sys
try:
secrets = json.load(sys.stdin)
except json.JSONDecodeError:
secrets = []
print(1 if any(s.get('name') == 'BAKERY_MINISIGN_SEC_KEY_PATH' for s in secrets) else 0)
" 2>/dev/null || echo 0)"
if [[ "${has_key}" != 1 ]]; then
echo "${slug}: missing BAKERY_MINISIGN_SEC_KEY_PATH Actions secret — release CI will fail closed (or worse, publish unsigned on an older workflow shape) until it's set"
secrets_missing=1
fi
done
if [[ "${secrets_missing}" == 0 ]]; then
echo "no missing signing secrets found across ${#registry_slugs[@]} registry repos"
else
drift=1
fi
fi
if [[ "${drift}" == 0 ]]; then
echo "no channel drift found"
else

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
@ -48,6 +67,41 @@ build_package_json() {
local version
version="$(basename "${version_dir}")"
# Locate bakery.toml. The release workflow copies it into the version dir
# alongside the binaries (${version_dir}/bakery.toml). Fall back to a
# sibling repo checkout for local dev use. Done before the binaries loop
# below so license_file/desktop_file (if declared) can be excluded from
# it by name — otherwise they'd get swept up as "binaries" with no
# checksum, the same gotcha this loop's other exclusions guard against.
local bakery_toml="${version_dir}/bakery.toml"
if [[ ! -f "${bakery_toml}" ]]; then
bakery_toml="${SCRIPT_DIR}/../${name}/bakery.toml"
fi
if [[ ! -f "${bakery_toml}" ]]; then
echo "ERROR: bakery.toml not found for ${name} — the release workflow must copy it to \${PKG_ROOT}/${name}/\${VERSION}/bakery.toml" >&2
return 1
fi
local license_file_name desktop_file_name data_archive_name
license_file_name="$(python3 -c "
import tomllib
with open('${bakery_toml}', 'rb') as f:
d = tomllib.load(f)
print(d.get('license_file', ''))
" 2>/dev/null || true)"
desktop_file_name="$(python3 -c "
import tomllib
with open('${bakery_toml}', 'rb') as f:
d = tomllib.load(f)
print(d.get('desktop_file', ''))
" 2>/dev/null || true)"
data_archive_name="$(python3 -c "
import tomllib
with open('${bakery_toml}', 'rb') as f:
d = tomllib.load(f)
print(d.get('data_archive', ''))
" 2>/dev/null || true)"
# Collect all binaries in the version dir (executables only; skip metadata files).
local binaries_json="[]"
for bin_path in "${version_dir}"/*; do
@ -56,6 +110,10 @@ build_package_json() {
[[ "${bin_path}" == *.service ]] && continue
[[ "${bin_path}" == *.css ]] && continue
[[ "${bin_path}" == *.txt ]] && continue
[[ "${bin_path}" == *.minisig ]] && continue
[[ -n "${license_file_name}" && "${bin_path}" == "${version_dir}/${license_file_name}" ]] && continue
[[ -n "${desktop_file_name}" && "${bin_path}" == "${version_dir}/${desktop_file_name}" ]] && continue
[[ -n "${data_archive_name}" && "${bin_path}" == "${version_dir}/${data_archive_name}" ]] && continue
[[ -f "${bin_path}" ]] || continue
local bin_name
bin_name="$(basename "${bin_path}")"
@ -64,8 +122,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 \
@ -77,18 +144,6 @@ build_package_json() {
binaries_json="$(jq -n --argjson arr "${binaries_json}" --argjson e "${entry}" '$arr + [$e]')"
done
# Locate bakery.toml. The release workflow copies it into the version dir
# alongside the binaries (${version_dir}/bakery.toml). Fall back to a
# sibling repo checkout for local dev use.
local bakery_toml="${version_dir}/bakery.toml"
if [[ ! -f "${bakery_toml}" ]]; then
bakery_toml="${SCRIPT_DIR}/../${name}/bakery.toml"
fi
if [[ ! -f "${bakery_toml}" ]]; then
echo "ERROR: bakery.toml not found for ${name} — release.yml must copy it to \${DL_DIR}/${name}/\${VERSION}/bakery.toml" >&2
return 1
fi
local description system_deps optional_system_deps bread_deps services config post_install
description="$(python3 -c "
@ -184,6 +239,47 @@ with open('${bakery_toml}', 'rb') as f:
print(json.dumps(d.get('install', {}).get('post_install', [])))
" 2>/dev/null || echo "[]")"
# license_file / desktop_file: plain filename fields in bakery.toml
# (names already read above, before the binaries loop), same "artifact
# in the version dir, sha256 computed here" pattern as config.example.
# Empty string (not null) when unset, matching how the rest of this
# script signals "field absent" to jq below.
license_file="${license_file_name}"
license_file_sha256=""
if [[ -n "${license_file}" ]]; then
license_path="${version_dir}/${license_file}"
if [[ -f "${license_path}" ]]; then
license_file_sha256="$(sha256sum "${license_path}" | awk '{print $1}')"
else
echo " warning: license_file '${license_file}' not found at ${license_path}" >&2
license_file=""
fi
fi
desktop_file="${desktop_file_name}"
desktop_file_sha256=""
if [[ -n "${desktop_file}" ]]; then
desktop_path="${version_dir}/${desktop_file}"
if [[ -f "${desktop_path}" ]]; then
desktop_file_sha256="$(sha256sum "${desktop_path}" | awk '{print $1}')"
else
echo " warning: desktop_file '${desktop_file}' not found at ${desktop_path}" >&2
desktop_file=""
fi
fi
data_archive="${data_archive_name}"
data_archive_sha256=""
if [[ -n "${data_archive}" ]]; then
data_archive_path="${version_dir}/${data_archive}"
if [[ -f "${data_archive_path}" ]]; then
data_archive_sha256="$(sha256sum "${data_archive_path}" | awk '{print $1}')"
else
echo " warning: data_archive '${data_archive}' not found at ${data_archive_path}" >&2
data_archive=""
fi
fi
jq -n \
--arg name "${name}" \
--arg description "${description}" \
@ -195,6 +291,12 @@ print(json.dumps(d.get('install', {}).get('post_install', [])))
--argjson services "${services}" \
--argjson config "${config}" \
--argjson post_install "${post_install}" \
--arg license_file "${license_file}" \
--arg license_file_sha256 "${license_file_sha256}" \
--arg desktop_file "${desktop_file}" \
--arg desktop_file_sha256 "${desktop_file_sha256}" \
--arg data_archive "${data_archive}" \
--arg data_archive_sha256 "${data_archive_sha256}" \
'{
name: $name,
description: $description,
@ -205,7 +307,13 @@ print(json.dumps(d.get('install', {}).get('post_install', [])))
bread_deps: $bread_deps,
services: $services,
config: $config,
post_install: $post_install
post_install: $post_install,
license_file: (if $license_file == "" then null else $license_file end),
license_file_sha256: (if $license_file_sha256 == "" then null else $license_file_sha256 end),
desktop_file: (if $desktop_file == "" then null else $desktop_file end),
desktop_file_sha256: (if $desktop_file_sha256 == "" then null else $desktop_file_sha256 end),
data_archive: (if $data_archive == "" then null else $data_archive end),
data_archive_sha256: (if $data_archive_sha256 == "" then null else $data_archive_sha256 end)
}'
}
@ -225,7 +333,8 @@ jq -n \
--arg generated_at "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
--argjson packages "${packages_json}" \
'{version: $version, generated_at: $generated_at, packages: $packages}' \
> "${OUT}"
> "${OUT}.tmp"
mv -f "${OUT}.tmp" "${OUT}"
echo "wrote ${OUT}"
@ -252,7 +361,7 @@ if [[ -n "${MINISIGN_SEC_KEY:-}" ]]; 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")
sign_args=(-S -s "${MINISIGN_SEC_KEY}" -m "${OUT}" -x "${OUT}.minisig.tmp")
if [[ -n "${MINISIGN_SEC_KEY_PASSWORD:-}" ]]; then
MINISIGN_PASSWORD="${MINISIGN_SEC_KEY_PASSWORD}" minisign "${sign_args[@]}" </dev/null
else
@ -260,6 +369,7 @@ if [[ -n "${MINISIGN_SEC_KEY:-}" ]]; then
# normally generated, since there's no human to type a passphrase).
minisign -W "${sign_args[@]}" </dev/null
fi
mv -f "${OUT}.minisig.tmp" "${OUT}.minisig"
echo "signed ${OUT} -> ${OUT}.minisig"
else
echo "WARNING: MINISIGN_SEC_KEY not set — index.json was NOT signed." >&2

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

@ -0,0 +1,77 @@
#!/usr/bin/env bash
# Rewrite the marked Products table in README.md from
# registry/bread-ecosystem.toml (the source of truth).
#
# Markers (must exist in README.md):
# <!-- gen-readme-products:start -->
# ...generated markdown...
# <!-- gen-readme-products:end -->
#
# Optional per-product `notes` in the registry is appended to the
# description after an em-dash (used for "homelab, not BOS" / "not in ISO").
#
# Usage: scripts/gen-readme-products.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REGISTRY="${SCRIPT_DIR}/registry/bread-ecosystem.toml"
README="${SCRIPT_DIR}/README.md"
START="<!-- gen-readme-products:start -->"
END="<!-- gen-readme-products:end -->"
if [[ ! -f "${REGISTRY}" ]]; then
echo "error: registry not found at ${REGISTRY}" >&2
exit 2
fi
if [[ ! -f "${README}" ]]; then
echo "error: README not found at ${README}" >&2
exit 2
fi
python3 - "${REGISTRY}" "${README}" "${START}" "${END}" <<'PY'
import sys
from pathlib import Path
try:
import tomllib
except ImportError: # pragma: no cover — 3.11+ is required
import tomli as tomllib # type: ignore
registry_path, readme_path, start, end = sys.argv[1:]
with open(registry_path, "rb") as f:
registry = tomllib.load(f)
products = registry.get("products") or []
if not products:
print("error: registry has no [[products]]", file=sys.stderr)
sys.exit(1)
lines = ["| Package | Description |", "|---------|-------------|"]
for product in products:
name = product["name"]
desc = str(product.get("description") or "").replace("|", "\\|")
notes = str(product.get("notes") or "").replace("|", "\\|")
if notes:
desc = f"{desc} — {notes}"
lines.append(f"| `{name}` | {desc} |")
table = "\n".join(lines)
readme = Path(readme_path)
text = readme.read_text()
start_at = text.find(start)
end_at = text.find(end)
if start_at < 0 or end_at < 0 or end_at < start_at:
print(
f"error: README.md is missing markers {start!r} / {end!r}",
file=sys.stderr,
)
sys.exit(1)
rewritten = text[:start_at] + start + "\n\n" + table + "\n\n" + end + text[end_at + len(end):]
if rewritten != text:
readme.write_text(rewritten)
print(f"updated {readme_path} ({len(products)} products)")
else:
print(f"{readme_path} already matches the registry ({len(products)} products)")
PY

View file

@ -1,7 +1,6 @@
#!/bin/sh
# Bootstrap script: downloads and installs the `bakery` binary.
# Usage: curl https://breadway.dev/get | sh
# Or: curl -sSfL https://breadway.dev/get | sh
# Usage: curl -fsSL https://get.breadway.dev | sh
set -eu
# Pinned minisign public key for the bakery release binary. Matches the
@ -9,7 +8,7 @@ set -eu
# index.json and the bakery binary itself). Do not source this from the
# network — it must be baked into this script so a compromised dl server
# can't swap it out along with a malicious binary.
BAKERY_MINISIGN_PUBKEY="RWRh2Zr5SUinvVFCtD7S7HwGjfrye6j31Xq2mYXRdkGFDWe3yHF7W11K"
BAKERY_MINISIGN_PUBKEY="RWTBR8w/IJ+jaylOv80b52DzekKbSR2CvOVGvzB0ipGBaMhJPAOiEWq8"
BAKERY_VERSION="${BAKERY_VERSION:-latest}"
BIN_DIR="${BAKERY_BIN_DIR:-$HOME/.local/bin}"
@ -20,6 +19,14 @@ die() { echo "error: $*" >&2; exit 1; }
uname -m | grep -q x86_64 || die "bakery only supports x86_64 (got $(uname -m))"
uname -s | grep -q Linux || die "bakery only supports Linux (got $(uname -s))"
# Signature verification is mandatory. Checksum-only is not sufficient —
# the binary and its .sha256 typically come from the same server, so a
# compromised host can serve a matching pair. Fail closed if minisign
# isn't here rather than downloading something we refuse to trust.
if ! command -v minisign >/dev/null 2>&1; then
die "minisign is required to verify bakery. Install it: pacman -S minisign / apt install minisign"
fi
# Build download URLs. GitHub's "latest" redirect lives at a different path from
# versioned releases, so we handle them separately and always prefix tags with 'v'.
if [ "${BAKERY_VERSION}" = "latest" ]; then
@ -55,42 +62,33 @@ echo "downloading bakery…"
if fetch "${DL_PRIMARY}" "${TMP}" 2>/dev/null; then
echo " from dl.breadway.dev"
sig_url="${SIG_URL}"
checksum_only_fallback_note=" warning: could not fetch checksum — skipping verification"
sig_url_alt="${SIG_FALLBACK}"
elif fetch "${DL_FALLBACK}" "${TMP}" 2>/dev/null; then
echo " from GitHub (fallback)"
sig_url="${SIG_FALLBACK}"
checksum_only_fallback_note=" warning: no checksum available for GitHub fallback download"
sig_url_alt="${SIG_URL}"
else
die "failed to download bakery from both primary and fallback URLs"
fi
# Signature verification is the authoritative check: it proves the binary
# was produced by whoever holds the bakery signing key, not just that bytes
# match whatever the same (possibly compromised) server also reports as the
# checksum. Prefer it whenever both a .minisig is published and a minisign
# verifier is available on this machine.
sig_verified=0
# Signature is required. A missing .minisig is a refuse-to-install, not a
# warning — checksum-only is not a substitute.
if fetch "${sig_url}" "${TMP}.minisig" 2>/dev/null; then
if command -v minisign >/dev/null 2>&1; then
if minisign -V -q -m "${TMP}" -x "${TMP}.minisig" -P "${BAKERY_MINISIGN_PUBKEY}"; then
echo " signature verified (minisign)"
sig_verified=1
else
die "minisign signature verification FAILED — refusing to install a binary that doesn't match the pinned bakery key"
fi
else
echo " warning: 'minisign' is not installed — cannot verify the binary's" >&2
echo " warning: signature, only its checksum. Install minisign for the" >&2
echo " warning: strongest guarantee: pacman -S minisign / apt install minisign" >&2
fi
:
elif [ "${sig_url_alt}" != "${sig_url}" ] && fetch "${sig_url_alt}" "${TMP}.minisig" 2>/dev/null; then
echo " signature fetched from fallback URL"
else
echo " warning: no .minisig published for this release yet — signature not verified" >&2
die "could not fetch bakery-x86_64.minisig — refusing to install an unsigned binary"
fi
# Checksum is a secondary, best-effort check (kept for defense in depth and
# for the case where minisign isn't installed). It is not a substitute for
# signature verification: both the binary and its checksum typically come
# from the same server, so a compromised server can serve a matching pair.
if minisign -V -q -m "${TMP}" -x "${TMP}.minisig" -P "${BAKERY_MINISIGN_PUBKEY}"; then
echo " signature verified (minisign)"
else
die "minisign signature verification FAILED — refusing to install a binary that doesn't match the pinned bakery key"
fi
# Checksum is defense-in-depth only, and never enough on its own. A
# mismatch still dies; a missing .sha256 is fine once the signature passed.
if fetch "${SHA256_URL}" "${TMP}.sha256" 2>/dev/null; then
expected="$(awk '{print $1}' "${TMP}.sha256")"
actual="$(sha256sum "${TMP}" | awk '{print $1}')"
@ -98,12 +96,6 @@ if fetch "${SHA256_URL}" "${TMP}.sha256" 2>/dev/null; then
die "SHA-256 checksum mismatch (expected ${expected}, got ${actual})"
fi
echo " checksum verified"
else
echo "${checksum_only_fallback_note}"
fi
if [ "${sig_verified}" -ne 1 ]; then
echo " warning: proceeding WITHOUT a verified signature on the bakery binary" >&2
fi
chmod +x "${TMP}"

52
scripts/onboard-product.sh Executable file
View file

@ -0,0 +1,52 @@
#!/usr/bin/env bash
# onboard-product.sh — register a new product in registry/bread-ecosystem.toml.
#
# This is the one step in bringing a product under bakery that's a genuine
# write action; everything else (bakery.toml, CI workflows, the
# BAKERY_MINISIGN_SEC_KEY_PATH Actions secret) is either copied from an
# existing product repo or diagnosed by scripts/doctor-channels.sh, which
# this script runs at the end so nothing gets missed the way breadcast's
# missing secret did.
#
# Usage:
# scripts/onboard-product.sh <name> <owner/repo> <description>
#
# Example:
# scripts/onboard-product.sh breadcast Breadway/breadcast \
# "Cast your screen to any Chromecast/Google TV or DLNA renderer — daemon + GTK4 popup"
set -euo pipefail
if [[ $# -ne 3 ]]; then
echo "usage: $0 <name> <owner/repo> <description>" >&2
exit 2
fi
NAME="$1"
REPO="$2"
DESCRIPTION="$3"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REGISTRY="${SCRIPT_DIR}/registry/bread-ecosystem.toml"
if python3 -c "
import tomllib, sys
with open('${REGISTRY}', 'rb') as f:
d = tomllib.load(f)
sys.exit(0 if any(p['name'] == '${NAME}' for p in d['products']) else 1)
"; then
echo "${NAME} is already registered in ${REGISTRY}, skipping"
else
cat >> "${REGISTRY}" <<EOF
[[products]]
name = "${NAME}"
repo = "${REPO}"
description = "${DESCRIPTION}"
EOF
echo "added ${NAME} (${REPO}) to ${REGISTRY}"
fi
echo
echo "running doctor-channels.sh to check bakery.toml / CI workflows / signing secret are all in place for ${NAME}..."
bash "${SCRIPT_DIR}/scripts/doctor-channels.sh" || true

88
upgrade.md Normal file
View file

@ -0,0 +1,88 @@
**Project Overview: Bread Screenshot System**
### Goal
Add a maintainable, automated system to generate high-quality screenshots/renders of **all major UI views** across the Bread ecosystem. This will dramatically speed up UI development, visual regression testing, documentation, and marketing.
### Scope
**In Scope:**
- Automated screenshot generation for all major GUI components
- Support for different themes (pywal accents, light/dark if added later)
- Consistent naming and output structure
- Easy-to-run command (`bread capture --all` or similar)
- Integration with development workflow and CI (optional)
**Out of Scope (Phase 1):**
- Video/GIF capture
- Full automated visual diffing (can be Phase 2)
### Target Apps / Views
1. **breadbar**
- Main bar (all placements)
- Control panel (full + sections)
- WiFi popover, media popover, etc.
- Notifications
2. **breadman**
- All sidebar views (All, Upcoming, Todo, Reminder, etc.)
- Note cards in different states
- Editor / create flow
3. **breadbox**
- Main launcher view
- Different contexts
4. **bos-settings**
- All major panels
5. **breadpad** (capture popup)
6. **breadlock** (lock screen states)
7. **Widgets** (test module that renders many widget examples)
### Technical Approach (Most Idiomatic)
**Core Components:**
1. **Shared Library** (`bread-screenshots` crate in bread-ecosystem)
- Common screenshot utilities
- Window finding / targeting logic (using `gtk` or `grim`)
- Theme forcing
2. **Per-App Screenshot Mode**
- Add `--screenshot <view>` flag to each GTK app
- Special runtime mode that opens the desired view and calls capture after render
3. **Orchestrator**
- A small Rust binary (`bread-capture`) or bash + Rust hybrid
- Launches each app with proper flags, waits, captures, saves
4. **Output Structure**
```
screenshots/
├── v0.8.0/
│ ├── breadbar-main.png
│ ├── breadbar-control.png
│ ├── breadman-all.png
│ ├── breadman-todo.png
│ └── ...
└── latest/ (symlinks)
```
### Recommended Implementation Steps
1. Create `bread-screenshots` crate in bread-ecosystem
2. Add screenshot support to the most important apps first (breadman + breadbar)
3. Build the orchestrator tool
4. Add `bread capture` subcommand to the CLI
5. Document usage + add to CONTRIBUTING.md
### Benefits
- Much faster UI iteration
- Visual regression testing
- Always up-to-date marketing/docs screenshots
- Easier contributor onboarding for UI work
- Professional polish for the project