Compare commits

..

99 commits

Author SHA1 Message Date
Breadway
46b886b7bc Bump version to v0.7.5
Some checks failed
dev bread-theme / build (push) Successful in 27s
dev bakery / build (push) Successful in 46s
beta (rc) bakery / build (push) Has been skipped
beta (rc) bread-theme / build (push) Has been skipped
release bakery / build (push) Failing after 58s
Build and publish package / package (push) Successful in 1m31s
release bread-theme / build (push) Failing after 24s
2026-08-31 15:42:58 +08:00
4328e2171d Merge pull request 'Shell theming + launcher (Daylight), clippy 1.97, deepseek audit' (#4) from feature/theme-spotlight into main
All checks were successful
dev bread-theme / build (push) Successful in 32s
dev bakery / build (push) Successful in 1m2s
2026-08-31 15:41:44 +08:00
Breadway
aa326c4ecb bread-theme/bread-utils: XDG env hardening and safer temp-file writes
- `XDG_CONFIG_HOME` / `XDG_RUNTIME_DIR` are honoured only when absolute
  (XDG spec; matches `bread_utils::xdg`), in layerrules and output.
- `output::atomic_write` pid-suffixes its temp name (`.<name>.tmp.<pid>`)
  so two concurrent `bread-theme generate-output` runs writing the same
  `themes/<output>.css` can't race on one shared `.tmp`.
- `hypr::socket_path` reconstructs `/run/user/<uid>` from the process's
  real uid when `XDG_RUNTIME_DIR` is unset, instead of assuming 1000.
- `bread-theme --help` prints to stdout (pipeable); usage on an error
  path (missing args) goes to stderr.
2026-08-31 15:37:52 +08:00
Breadway
2affeabce0 bakery: a failed data-archive download must not abort the whole install
`fetch_verify_write` treats a download/checksum failure as a soft
warning and returns Ok without writing — but the `NamedTempFile` is
already created (0 bytes). Gating extraction on `exists()` let the
empty file reach `tar tvf`, which bailed with a misleading "not in
gzip format" and aborted the install. Gate on non-empty instead.
2026-08-31 15:37:52 +08:00
Breadway
2897335016 bread-polkit: only auth as an identity polkit actually offered; fail fast without the lock
- The agent prompt's username field is user-editable. It was passed
  straight to `auth::authenticate`, so a request scoped to specific
  accounts (e.g. root only) could have its PAM conversation redirected
  to any local user. `resolve_user` now accepts only the `unix-user`
  identities from `BeginAuthentication` (empty = the prefilled default);
  anything else re-shows the prompt with an explanation. PAM still has to
  clear polkit's own authorization, but this closes the foot-gun at the
  one place the identity list is known.
- A failed single-instance lock now exits(1) instead of continuing: a
  second agent would `serve_at` the same object path, and a prompt held
  by a process that couldn't take the lock is ambiguous state.
2026-08-31 15:37:52 +08:00
Breadway
a86c31291b Clear clippy 1.97 lints across bread-theme, bread-utils, bread-polkit
Toolchain drift (clippy 0.1.97): pre-existing on main, all mechanical
and behaviour-preserving.

- bread-theme/gtk.rs: `type AppCssBuilder` alias for the repeated
  `Rc<dyn Fn(&Palette) -> String>` (type_complexity ×4).
- bread-theme/lib.rs: `sort_by_key(|..| Reverse(len))` (unnecessary_sort_by).
- bread-theme/output.rs: `io::Error::other`; struct-init in a test.
- bread-utils/singleton.rs: explicit `.truncate(false)` on the lock file
  open — we only clear it after winning the lock (suspicious_open_options).
- bread-polkit/identity.rs: elide `pick_user` lifetimes.
2026-08-31 15:20:15 +08:00
Breadway
6d9912af56 shell themes: add Daylight — bottom-anchored, segmented, light builtin
Fourth compiled-in shell theme (bread-theme/src/shell/builtin.rs). Stresses
four axes no existing theme touched:

- light surfaces (new tokens.light flag, swaps which of the fixed @bg/
  @on-bg pair plays paper-surface vs ink — see Tokens::light's doc comment)
- bottom anchoring (bar.window.anchors = ["bottom", ...])
- segmented bar chrome (new bar_border = "segmented" value: the bar window
  itself draws no fill/border, three slot-group pills draw their own)
- blur disabled per-theme ([compositor.*] blur = false everywhere)

Also: a new bottom_right surface anchor (manifest.rs + types.rs), since the
three existing anchor shapes all assume a top-anchored bar's satellites
belong in the top-right corner. tokens.accent2() gives the equaliser its
own accent independent of accent_from/accent_to.
2026-08-31 15:15:36 +08:00
Breadway
80e25f23b3 shell themes: give the launcher panel its own opacity
tokens.bg_alpha governs the bar, which stays readable at 0.72 because it
covers a thin strip of wallpaper. A full launcher panel at that alpha washes
out badly over a bright wallpaper and its text becomes hard to read — which
is what breadbox looked like, since it hardcoded 0.60 and read no theme value
at all.

Adds [launcher].panel_alpha, set to the approved reference's own values: 0.95
for glass-workbench, 0.93 for liquid-motion.
2026-08-31 15:15:36 +08:00
Breadway
5b5b17fdf4 shell themes: bring chip_height and dot_widths up to the approved reference
breadbar had to hardcode past these: the manifests carried pre-review values
(chip_height 32/20/36, dot_widths [6,10,14,18]) that disagreed with what the
bar actually draws, so a chrome pass overrode them locally rather than let the
manifest dictate wrong geometry.

Updated to what the user approved on the interactive reference in
bos-ui-demos/proposed/: one chip highlight height per bar (26 island, 22 flush,
22 capsule) and Option-B dot widths [8,13,17,22]. Two tests pinned the old
numbers and are updated with the reason.

A manifest that states one thing while the app draws another is the defect
class this project has hit repeatedly; the local hardcodes in breadbar can now
be removed in favour of these.
2026-08-31 15:15:36 +08:00
Breadway
3a22b62694 bread-theme: differentiate liquid-motion/glass-workbench launcher geometry per approved demos
The two built-in launcher themes previously shared the same row radius,
padding, icon treatment, and selection fill (only mode/width/top/radius/
icon_px were themed), so breadbox's overlay rendered as one launcher
recoloured rather than two different instruments.

Adds row_radius, row_inset, row_padding_v/h, icon_radius, search_font_size,
search_padding_v/h, and selection_alpha to [launcher], all consumed by
breadbox's build_css in the paired breadbox commit. Sets liquid-motion to
the soft/roomy demo numbers (row radius 12, 28px icons, 22% selection alpha,
Recent/Apps section headers) and glass-workbench to the dense/technical demo
numbers (row radius 6, 22px icons, 28% selection alpha, flat list).

Also wires up three previously-declared-but-unconsumed tokens
(font_family, font_fallback, font_size_base) for breadbox's launcher panel
specifically — scoped consumption, not the full ecosystem-wide font system
replacement (breadbar/stylesheet() are unaffected). Updated the stale
doc comments in types.rs/mod.rs and both theme.toml files to match, and
updated the two existing launcher-geometry tests plus added a glass-workbench
equivalent.

Defaults for all new keys reproduce breadbox's pre-redesign hardcoded CSS
values, so themes that don't set them (spotlight's capsule doesn't read any
of this) are unaffected.
2026-08-31 15:15:36 +08:00
Breadway
0ec20714c5 bread-theme: mark five declared-but-unconsumed schema keys as such in schema and theme.toml
audit-schema-consumption.md's recurring defect: a key that validates
but does nothing, five separate instances (css/extra.css overlay,
tokens.font_family, tokens.font_fallback, tokens.font_size_base,
tokens.accent_to). All five are consumed only by breadbar/breadbox,
which this pass doesn't touch, so per the audit's own (a)/(b)/(c)
choice this is (b): document the gap at both the schema accessor and
every theme.toml site that sets the key, so the manifest states its
design intent honestly instead of implying the value has an effect it
doesn't.

Also documents modules.clock.format/show_date (consumed only by
ClockStyle::Plain, silently ignored by Flip/None) and
bar.window.margin.bottom (parsed, never applied by breadbar) the same
way.
2026-08-31 15:15:36 +08:00
Breadway
94ab7216c2 bread-theme: document shell.toml active as primary, BREAD_SHELL_THEME as testing-only
Neither doc site previously said which of the two theme-selection
mechanisms is meant to coordinate multiple already-running processes.
The env var is read once per process and cannot do that; shell.toml's
active key is the one every host reads, so it's the only one that can.
2026-08-31 15:15:36 +08:00
Breadway
eb446b091e bread-theme: hot-reload watch degrades gracefully and re-arms on theme switch
watch() panicked (.expect) if monitor_directory failed (e.g.
fs.inotify.max_user_watches exhaustion), contradicting the crate's own
'the shell must never fail to start because a theme file is malformed'
stance. Now logs and continues without a watch, same posture as
bread_theme::gtk::watch_theme_file's existing .ok()? handling.

watch() also resolved the active theme's directory once and pinned a
monitor there forever: switching shell.toml's active key while a host
was running left edits to the newly-active theme unobserved until
restart. Now also watches shell.toml's own directory and re-arms the
theme-directory monitor when active changes.

Public API change: shell::watch now returns ThemeWatch instead of a
bare gio::FileMonitor (still opaque, still just needs to be kept
alive). breadbar's theme.rs:525-531 stores the return value in a
RefCell<Option<gio::FileMonitor>> and wraps it in Some(monitor) - both
need updating to RefCell<Option<bread_theme:🐚:ThemeWatch>> and
*cell.borrow_mut() = monitor (no Some(..)), left to the breadbar-side
agent since this repo doesn't touch breadbar's files.

's role is now documented explicitly on watch() as a
single-process testing override, not the coordination mechanism -
shell.toml's active key is.
2026-08-31 15:15:36 +08:00
Breadway
a313eb518c bread-theme: validate surfaces.*.anchor like its width/layer siblings
anchor was the one enum-shaped schema field taken via
unwrap_or_default() with no manifest-time validation, while width and
layer both bail! on an unrecognized value (as does bar.window.anchors).
A typo'd anchor silently fell through to breadbar's own runtime
eprintln + unanchored-window fallback instead of the load-time hard
error every other typo'd key gets. Validate against the three shapes
breadbar/src/surface.rs actually implements.
2026-08-31 15:15:36 +08:00
Breadway
470d2aa7e9 bread-launcher: log history save failures, clarify LAUNCHER_APP vs event namespace
LaunchHistory::save silently swallowed write errors; now shared by two
hosts (breadbox + breadbar's capsule), a broken cache dir silently
stopped recording launches for both. Log on failure instead.

do_launch/emit_launched's app_id parameter is the caller's bread
event-namespace id (breadbox passes "box"), not LAUNCHER_APP
("breadbox", scoped to cache/history paths only) - two similarly
named but distinct identities. Make the doc comments say so explicitly
and add tests pinning the exact namespace-check relationship, since
confusing them silently drops the emitted event.
2026-08-31 15:15:36 +08:00
Breadway
cff77d473a bread-theme: clamp spring_to's interpolated value to [from,to]
spring_ease's backOut overshoot (peaks ~1.065) drove a shrinking
animation's interpolated value below its target, which could go
negative for a size request and trip GTK's height >= -1 assertion.
Previously only patched at breadbar's call sites; clamp inside
spring_to itself so the safety is automatic for every caller.
2026-08-31 15:15:36 +08:00
Breadway
410f97cb28 gitignore: exclude graphify-out and .grok local tool caches
128 files / ~111k lines of local graphify and grok cache were committed by an
over-broad `git add -A`. Neither is build output or source; both are
machine-local tool state. breadbar already excluded graphify-out; this repo
had no such rule.
2026-08-31 15:15:36 +08:00
Breadway
3326207d34 bread-launcher: fix UTF-8 panic in whole-word matching
matches_term advanced its scan with start = i + 1 — one byte past the START
of a failed match rather than past the whole match. Both i and i + tlen are
guaranteed char boundaries; i + 1 is not, so a multi-byte term that failed
the word-boundary check left start inside a character and the following
field[start..] slice panicked.

matches_term("café", "é") reproduces it: byte index 4 is not a char
boundary. The path is reached from priority_rank over real .desktop Name=
values, so any non-ASCII application name could crash the launcher's sort —
never hit locally because every installed app here happens to be ASCII.
2026-08-31 15:15:36 +08:00
Breadway
801df1e85e bread-capture: add breadbar's capsule-sections and capsule-calc views
Phase 6c screenshot coverage for spotlight's new query sections and =
calc mode, alongside the existing capsule-collapsed/capsule-expanded pair.
2026-08-31 15:14:44 +08:00
Breadway
00e8d4f28e bread-launcher: implement query modes and result sections (phase 6c)
New query module: parse_query splits a leading =/>/. into calc/cmd/url
(falling through to a literal apps query otherwise), eval_calc is a small
four-function arithmetic evaluator matching the demo's evalCalc semantics
(bad expr/err/∞ included), and builtin_commands/filter_commands cover the
> palette. matching::split_sections groups already-sorted entries into
recent/apps using LaunchHistory's existing counts, no new tracking.

ResultsList::new takes a new `sections` bool: true builds non-selectable
"Recent"/"Apps" header rows (build_header_row) ahead of their groups,
visible only in the idle empty-query view; set_query/select_next/
select_prev all learned to treat a header row (no DesktopEntry data) as
never-selectable. breadbox passes sections=false to keep its own overlay
exactly as it was.
2026-08-31 15:14:44 +08:00
Breadway
d2c68f18b0 shell: widen spotlight's launcher schema for phase 6c (modes/sections/search geometry)
[launcher].modes now lists calc/cmd/url alongside apps, sections is
enabled, and two new keys (search_width/search_radius) let an embedded
launcher theme declare its search-state capsule geometry, defaulting to
the idle value when a theme omits them. Also gives spotlight's [bar.slots]
a widget:left_of_stats entry so a Lua widget requesting that placement
(e.g. git-branch-widget.lua) has a home instead of being silently dropped.
2026-08-31 15:14:44 +08:00
Breadway
95e676e909 bread-capture: add breadbar's two spotlight-capsule screenshot views
breadbar's src/screenshot.rs gained capsule-collapsed/capsule-expanded
(Phase 6b, feature/theme-spotlight) — mirror them here so the orchestrator's
--view filter and full-suite runs actually cover them instead of rejecting
the names as unknown.
2026-08-31 15:14:44 +08:00
Breadway
8c5e42230a shell: add spotlight builtin (theme 04), dots/placeholder-clock schema, and anim::spring_to
- bread-theme:🐚 WorkspacesModule.dot_widths and ClockModule.placeholder_clock,
  resolved/validated in manifest.rs and modeled in types.rs, so a theme can
  actually configure dot-pill widths and an entry-as-clock placeholder
  instead of those staying schema-only.
- New compiled-in builtin "spotlight" (assets/shell/spotlight/), registered
  in builtin.rs so list() returns three themes: centred capsule window spec
  (anchors=[top] only, width=480, exclusive=none, keyboard=on_demand),
  workspaces.style=dots, clock.style=none+placeholder_clock, launcher.mode=
  embedded, drawer=[launcher_results]. Colours are palette token names
  (pink), never hex.
- bread-theme::anim::spring_to: a reusable TickCallback size-interpolation
  helper (GTK4 has no CSS width/height transition on a widget or layer-shell
  surface), generalized from WorkspaceTrail's existing ease/tick pattern, for
  breadbar's capsule-drawer expand/collapse.
- bread_launcher::LAUNCHER_APP: the launcher's one shared on-disk identity
  (cache/config/history), so breadbar's embedded capsule and breadbox's
  overlay window read and write the SAME cache and launch history rather
  than forking into two rankings of a user's apps.

Tests: +6 spotlight builtin tests (loads/in list/capsule window shape/
embedded mode/dots widths/flat-pink-not-hex), bread-theme --lib 63->69,
bread-launcher --lib unchanged at 18.
2026-08-31 15:14:44 +08:00
Breadway
6ffd689a4d Add bread-launcher: headless app-launcher core + GTK4 results widget
Extracts the app-launcher substance (desktop-entry parsing, fuzzy
matching/ranking, launch history, launching) out of breadbox into a
reusable ecosystem crate, so breadbar's future embedded capsule and
breadbox's overlay window can share one implementation instead of two
(THEME_SYSTEM_PLAN.md Phase 4/6a). The GTK4 results-list widget lives
behind a `gtk` feature, mirroring bread-theme's `gtk`/`adw` gating, so a
headless consumer isn't forced to link GTK.

Adds unit tests for fuzzy_score/matches_term/priority_rank, which were
previously untested pure functions inside breadbox's main.rs.
2026-08-31 15:14:44 +08:00
Breadway
96fa79c1d4 shell theme: add glass-workbench as a second compiled-in theme
Phase 5 of the shell theme system (THEME_SYSTEM_PLAN.md §11): a second
builtin, demo 02's flush edge-to-edge bar with pill workspaces, a plain
date+time clock, and cpu/ram chips instead of the media widget.

- bread-theme/assets/shell/glass-workbench/: theme.toml + CSS template,
  faithful to bos-ui-demos/02-glass-workbench.html. Accent maps to the
  `green` palette token (flat, not a gradient) rather than a hex literal,
  so pywal theming still works.
- builtin.rs: generalized from a single hardcoded liquid-motion constant
  pair to a small BuiltinTheme registry (builtin::ALL / builtin::find),
  so mod.rs's discovery/list()/resolve_builtin no longer special-case one
  id. liquid-motion stays the pinned fallback in resolve_builtin().
- manifest.rs: KNOWN_MODULES gains "cpu"/"ram".
- types.rs: new Tokens::bar_border() ("full" default vs "bottom") so a
  flush bar can ask for a single hairline instead of an island's full
  border.
- Tests: builtin loads, appears in list() alongside liquid-motion, and its
  window spec is the flush/edge shape (36px, zero margin, radius 0).

cargo test -p bread-theme --lib: 63 passing (59 prior + 4 new).
2026-08-31 15:14:44 +08:00
Breadway
53a6c59f2d bread-theme: correct [launcher] manifest to match breadbox's actual behaviour
radius (20 -> 8) and icon_px (36 -> 32) were demo-derived aspirations, not
what breadbox implements today (.launcher-bg's actual border-radius and
make_icon's actual set_pixel_size). row_anim/rule/footer/sections/modes are
kept but marked declared-but-not-yet-consumed: breadbox implements none of
row animation, a rule/divider, a footer, sections, or query modes today, so
the manifest should say so rather than imply they're live. Adds a builtin
launcher test mirroring the existing window/tokens fidelity tests.
2026-08-31 15:14:44 +08:00
Breadway
ea9758a5d5 bread-theme: generate Hyprland layer rules from the shell theme (Phase 4a)
Adds `bread-theme layerrules`, which writes the active shell theme's
[compositor] table to ~/.config/hypr/layerrules.json (atomic write). This
lets ~/.config/hypr/scripts/ui/rules.lua read theme-driven blur/transparency/
animation for the breadbar/breadbox layer-shell namespaces instead of having
them hardcoded, following THEME_SYSTEM_PLAN.md §9. rules.lua keeps its
previous hardcoded rules as a pcall-guarded fallback for when the JSON is
missing or malformed (lives outside this repo, so not part of this commit).

Also fixes a pre-existing test race: shell::tests and the new
layerrules::tests both mutate XDG_CONFIG_HOME in parallel `cargo test`
threads but previously used separate, unrelated locks (or none), so they
could observe each other's env var changes mid-test. Both now share
bread_theme::test_support::XDG_CONFIG_HOME_LOCK.
2026-08-31 15:14:44 +08:00
Breadway
92d3362a6b shell: add widget: entries to the builtin's [bar.slots] (Phase 3b)
Reproduces breadbar's now-removed fixed Lua-widget interleave
(right-of-workspaces, left/right-of-clock, left-of-stats) as explicit
widget: slot entries, so the builtin theme still renders pixel-identical
to today's bar under breadbar's new theme-driven widget placement.
`tray` deliberately has no slot entry anywhere — it stays in the
control-panel popover regardless of [bar.slots].

Updates the one test asserting the builtin's slot contents.
2026-08-31 15:14:44 +08:00
Breadway
96aa6a513b Add bread_theme::shell manifest system (Phase 1)
Implements the shell theme manifest layer from THEME_SYSTEM_PLAN.md
§4-5: ShellTheme/WindowSpec/Slots/Tokens/LayerRule types, TOML
discovery (user -> system -> compiled-in builtin), one level of
`extends` deep-merge, deny_unknown_fields validation naming the
offending key, slot module-name validation, and css() token
substitution with an extra.css overlay. load() never fails, falling
back to the compiled-in builtin and logging once.

Ships exactly one builtin manifest, liquid-motion, describing
breadbar/breadbox as they exist today (not the design-doc demo, which
disagrees with the code on bar side margin, launcher geometry, and the
easing curves). Compositor rules and surface specs are keyed by
layer-shell namespace and cover all five breadbar namespaces plus
breadbox/breadbar-panel/breadbar-dismiss.

watch() is gated behind the existing `gtk` feature (gio::FileMonitor
is a gtk4 dependency); the rest of the module is gtk-free so bread and
breadcrumbs can validate a theme without linking GTK. No consumer
changes — breadbar/breadbox still use their own hardcoded values.
2026-08-31 15:14:44 +08:00
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
97 changed files with 15108 additions and 635 deletions

View file

@ -1,13 +1,14 @@
name: dev bakery
# Publishes a dev-track build on every push to `dev` — separate from
# release-bakery.yml's tag-triggered stable releases. See docs/release-channels.md
# for the three-track policy (stable/beta/dev) this is part of.
# Publishes a dev-track build on every push to `main` (the trunk branch —
# there is no separate `dev` branch). See docs/release-channels.md for the
# release-track policy this is part of.
on:
push:
branches: ['dev']
branches: ['main']
paths:
- 'bakery/**'
- 'bread-utils/**'
- 'Cargo.toml'
- 'Cargo.lock'
- '.forgejo/workflows/dev-bakery.yml'
@ -20,7 +21,7 @@ jobs:
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch dev --depth 1 \
git clone --branch main --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build
@ -45,7 +46,7 @@ jobs:
# what's already installed and bakery would correctly refuse it.
LATEST_TAG="$(git ls-remote --tags --refs \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \
| awk -F/ '{print $NF}' | sed 's/^v//' | sort -V | tail -1)"
| awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)"
if [ -n "${LATEST_TAG}" ]; then
CUR="${LATEST_TAG}"
else

View file

@ -1,11 +1,11 @@
name: dev bread-theme
# Publishes a dev-track build on every push to `dev` — separate from
# release-bread-theme.yml's tag-triggered stable releases. See
# docs/release-channels.md for the three-track policy this is part of.
# Publishes a dev-track build on every push to `main` (the trunk branch —
# there is no separate `dev` branch). See docs/release-channels.md for the
# release-track policy this is part of.
on:
push:
branches: ['dev']
branches: ['main']
paths:
- 'bread-theme/**'
- 'Cargo.toml'
@ -20,7 +20,7 @@ jobs:
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch dev --depth 1 \
git clone --branch main --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build
@ -37,7 +37,7 @@ jobs:
# what's already installed and bakery would correctly refuse it.
LATEST_TAG="$(git ls-remote --tags --refs \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \
| awk -F/ '{print $NF}' | sed 's/^v//' | sort -V | tail -1)"
| awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)"
if [ -n "${LATEST_TAG}" ]; then
CUR="${LATEST_TAG}"
else

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

@ -1,14 +1,19 @@
name: beta bakery
name: beta (rc) bakery
# Publishes a beta-track build when a `beta-v*` tag is pushed — a deliberate
# promotion step (you pick the version string and the commit), distinct from
# dev-bakery.yml's automatic build-on-every-push. See docs/release-channels.md.
# Publishes a beta-track build 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: ['beta-v*']
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
@ -27,7 +32,8 @@ jobs:
- name: prepare artifacts
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#beta-v}"
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"
@ -42,7 +48,6 @@ jobs:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#beta-v}"
PKG_DIR="/srv/breadway-dl/beta/bakery/${VERSION}"
if [ -n "${MINISIGN_SEC_KEY:-}" ]; then
minisign -W -S -s "${MINISIGN_SEC_KEY}" -m "${PKG_DIR}/bakery-x86_64" \
@ -52,8 +57,9 @@ jobs:
echo "::warning::BAKERY_MINISIGN_SEC_KEY_PATH not set — shipping bakery-x86_64 UNSIGNED"
fi
# No GitHub Release upload — beta, like dev, is only distributed via
# dl.breadway.dev/beta/.
# No GitHub Release upload step here, unlike release-bakery.yml — beta
# builds happen on every push while the branch is frozen for testing,
# so dl.breadway.dev/beta/ is the only distribution point for this track.
- name: regenerate beta index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}

View file

@ -1,14 +1,19 @@
name: beta bread-theme
name: beta (rc) bread-theme
# Publishes a beta-track build when a `beta-v*` tag is pushed — a deliberate
# promotion step, distinct from dev-bread-theme.yml's build-on-every-push.
# See docs/release-channels.md.
# Publishes a beta-track build 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: ['beta-v*']
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
@ -24,7 +29,8 @@ jobs:
- name: prepare artifacts
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#beta-v}"
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"
@ -39,7 +45,6 @@ jobs:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#beta-v}"
PKG_DIR="/srv/breadway-dl/beta/bread-theme/${VERSION}"
if [ -n "${MINISIGN_SEC_KEY:-}" ]; then
minisign -W -S -s "${MINISIGN_SEC_KEY}" -m "${PKG_DIR}/bread-theme-x86_64" \

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:

6
.gitignore vendored
View file

@ -5,3 +5,9 @@
# scripts/get.sh for how it's consumed via MINISIGN_SEC_KEY).
*.minisign-sec
minisign.key
# Local tool caches — not build output, never belongs in the repo.
# (breadbar already excludes graphify-out; this repo did not, and 111k lines
# of it were swept in by a `git add -A`.)
graphify-out/
.grok/

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

View file

@ -1,23 +0,0 @@
# CLAUDE.md — Repo hygiene (local only, not committed)
Scope: this file covers *repo hygiene* — branching, remotes, CI, cleanup. It is not project documentation.
## Branch model
- `main` — release branch, always tag-ready. Don't commit directly to it.
- `dev` — integration branch. Land day-to-day work here first.
- Feature/fix work goes on short-lived branches off `dev` (`feature/x`, `fix/x`), merged back into `dev`, then `dev``main` when ready to release.
## Remotes
- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative.
- `github` — GitHub mirror. Push both when publishing.
## CI
- `.forgejo/workflows/package.yml`, `release-bakery.yml`, `release-bread-theme.yml` all trigger only on `push: tags: ['v*']` — pushing to `dev` or `main` runs nothing. Tag a release to trigger packaging.
- No build/lint/test CI runs on ordinary commits or PRs — test locally before merging to `dev`/`main`.
## Cleanup
- Delete feature/fix branches (local + remote) once merged. Check with `git branch --merged dev` / `git branch --merged main`.
- A `fix/audit-findings` branch and a merged `copilot/create-readme-md` branch (both local and on `origin`/`github`) were found stale and fully merged here on 2026-07-21 and removed.
## Don't
- Don't embed credentials in remote URLs — SSH or a credential helper only.

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.

680
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", "bread-launcher"]
resolver = "2"
[workspace.package]
version = "0.3.1"
version = "0.7.5"
edition = "2021"
license = "MIT"
authors = ["Breadway <plasticbread849@gmail.com>"]

110
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
├── 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
├── 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,9 +17,10 @@ ureq = { workspace = true }
sha2 = { workspace = true }
hex = { workspace = true }
clap = { workspace = true }
clap_complete = "4"
chrono = { workspace = true }
minisign-verify = { workspace = true }
semver = { workspace = true }
[dev-dependencies]
bread-utils = { path = "../bread-utils" }
fs4 = { version = "0.8", features = ["sync"] }
tempfile = "3"

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

@ -11,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 {
@ -33,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")
@ -49,16 +90,41 @@ 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!(" {}", ui::ok(&format!("{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!(" {}", ui::fail(&format!("error running doctor for {package_name}: {e}")));
ui::check_row(
false,
package_name,
name_width,
&format!("error running doctor: {e}"),
);
false
}
Ok(rep) => {
@ -75,17 +141,24 @@ pub fn report(package_name: &str, required: &[String], optional: &[String]) -> b
);
}
if rep.missing.is_empty() {
println!(" {}", ui::ok(&format!("{package_name}: all required system deps satisfied")));
ui::check_row(
true,
package_name,
name_width,
"all required system deps satisfied",
);
true
} else {
ui::check_row(
false,
package_name,
name_width,
&format!("missing: {}", rep.missing.join(", ")),
);
eprintln!(
" {}",
ui::fail(&format!(
"{package_name}: missing system deps: {}",
rep.missing.join(", ")
))
ui::dim(&format!("install with: {}", install_hint(&rep.missing)))
);
eprintln!(" install with: sudo pacman -S {}", rep.missing.join(" "));
false
}
}
@ -115,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

@ -52,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
@ -107,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 {
@ -159,38 +182,49 @@ pub fn load(force_refresh: bool, track: Track) -> Result<Index> {
match read_and_verify_cache(&cache_path, &sig_cache_path, track) {
Ok(index) => return Ok(index),
Err(err) => {
eprintln!(" warning: cached index.json failed verification ({err}), re-fetching…");
}
}
}
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: cached index.json failed verification ({err}), re-fetching…"
" warning: could not refresh {track} index ({fetch_err}) — \
using possibly-stale cached index"
);
Ok(index)
}
Err(_) => Err(fetch_err),
}
}
}
}
fetch_and_cache(&cache_path, &sig_cache_path, track)
}
fn read_and_verify_cache(
cache_path: &PathBuf,
sig_cache_path: &PathBuf,
track: Track,
) -> Result<Index> {
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).with_context(|| {
format!("cached {track} index failed signature verification")
})?;
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, track: Track) -> Result<Index> {
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? \
@ -203,11 +237,10 @@ fn fetch_and_cache(cache_path: &PathBuf, sig_cache_path: &PathBuf, track: Track)
verify_index_signature(&bytes, &sig_text)
.with_context(|| format!("freshly fetched {track} index failed signature verification"))?;
if let Some(dir) = cache_path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(cache_path, &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")
}
@ -218,11 +251,8 @@ 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")
}
/// Cache filename for `track`. `Stable` keeps the pre-track filename
@ -247,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)
}
@ -322,10 +394,7 @@ znmVfINB4jFDR2a4wuY8rOKlUBeSDOFjMkHYDXV3vxvAjK+r4V12ae9ZRQkfVtQ1YIEmFXbnJfbxywg+
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"
);
assert_eq!(cache_path(Track::Stable).file_name().unwrap(), "index.json");
}
#[test]
@ -342,12 +411,58 @@ znmVfINB4jFDR2a4wuY8rOKlUBeSDOFjMkHYDXV3vxvAjK+r4V12ae9ZRQkfVtQ1YIEmFXbnJfbxywg+
#[test]
fn stable_url_has_no_track_prefix() {
assert_eq!(primary_url(Track::Stable), format!("{}/index.json", base_url()));
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()));
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,5 +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;
@ -11,6 +12,25 @@ 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)]
@ -35,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 {
@ -64,14 +104,34 @@ impl State {
}
}
fn state_path() -> PathBuf {
dirs::state_dir()
.unwrap_or_else(|| {
fn state_base_dir() -> PathBuf {
dirs::state_dir().unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("~"))
.join(".local/state")
})
.join("bakery/installed.json")
}
fn state_path() -> PathBuf {
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)]
@ -85,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(),
}
}
@ -137,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");
}
}

View file

@ -9,20 +9,15 @@ use std::str::FromStr;
/// pacman) documented in `docs/release-channels.md` — that's an orthogonal,
/// pre-existing use of the word "channel", which is why this is called a
/// "track" instead.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, ValueEnum)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize, ValueEnum)]
#[serde(rename_all = "lowercase")]
pub enum Track {
#[default]
Stable,
Beta,
Dev,
}
impl Default for Track {
fn default() -> Self {
Track::Stable
}
}
impl Track {
pub fn as_str(&self) -> &'static str {
match self {

View file

@ -1,5 +1,6 @@
use crate::track::Track;
use std::io::IsTerminal;
use clap::builder::styling::{AnsiColor, Effects, Styles};
use std::io::{IsTerminal, Write};
pub const RESET: &str = "\x1b[0m";
pub const BOLD: &str = "\x1b[1m";
@ -9,6 +10,19 @@ 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
@ -18,21 +32,52 @@ 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 {
if colors_enabled() {
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 => format!(" {}", style("[beta]", YELLOW)),
Track::Dev => format!(" {}", style("[dev]", MAGENTA)),
Track::Beta => style("[beta]", YELLOW),
Track::Dev => style("[dev]", MAGENTA),
}
}
@ -44,6 +89,288 @@ 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::*;
@ -57,4 +384,74 @@ mod tests {
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));
}
}

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

@ -0,0 +1,262 @@
//! 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"),
// Theme 04/spotlight's embedded capsule (only rendered under
// `BREAD_SHELL_THEME=spotlight` — every other theme's [bar.slots]
// never places launcher_entry/launcher_results anywhere).
("capsule-collapsed", "capsule-collapsed.png"),
("capsule-expanded", "capsule-expanded.png"),
// Phase 6c: query sections (idle "Recent"/"Apps" headers) and
// the `=` calc mode — see breadbar's own `screenshot::KNOWN_VIEWS`
// doc comment for why the search-state width/radius change
// (item E) doesn't need a view of its own.
("capsule-sections", "capsule-sections.png"),
("capsule-calc", "capsule-calc.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 })
}

26
bread-launcher/Cargo.toml Normal file
View file

@ -0,0 +1,26 @@
[package]
name = "bread-launcher"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
description = "Headless app-launcher core (desktop-entry discovery, fuzzy matching/ranking, launch history, launching) plus an optional GTK4 results-list widget — the shared logic behind breadbox's overlay window and breadbar's embedded capsule"
repository = "https://git.breadway.dev/Breadway/bread-ecosystem"
keywords = ["launcher", "desktop-entry", "gtk4", "wayland"]
[dependencies]
serde_json = { workspace = true }
# `do_launch`/`emit_launched` publish a `bread.<app>.launched` event over
# breadd's IPC socket after a successful spawn, fire-and-forget — this was
# already breadbox's behaviour (`BreadClient::emit` never blocks or errors
# the launching caller), just relocated. Not optional: launching is core,
# headless functionality, unlike the GTK widget below.
bread-utils = { path = "../bread-utils", features = ["bread-client"] }
gtk4 = { version = "0.11", features = ["v4_12"], optional = true }
[features]
# Enable the GTK4 results-list widget (`gtk` module): row building, fuzzy
# filtering, match/history sorting, and keyboard-style selection movement.
# Optional so a headless consumer of the matching/ranking/launch core (or a
# future non-GTK host) doesn't have to pull in GTK4.
gtk = ["dep:gtk4"]

View file

@ -0,0 +1,151 @@
use std::{
fs::{self, File},
io::{BufRead, BufReader},
path::{Path, PathBuf},
};
use crate::paths::app_dirs;
#[derive(Debug, Clone)]
pub struct DesktopEntry {
/// Desktop file id (the `.desktop` filename, e.g. `firefox.desktop`).
/// Empty only if the path had no file name; callers fall back to `exec`.
pub id: String,
pub name: String,
pub exec: String,
pub icon_name: String,
pub icon_path: Option<PathBuf>, // resolved by caller from manifest
pub categories: Vec<String>,
pub wm_class: Option<String>,
pub terminal: bool,
}
pub fn strip_exec_codes(exec: &str) -> String {
let mut out = String::with_capacity(exec.len());
let mut chars = exec.chars().peekable();
while let Some(c) = chars.next() {
if c == '%' {
match chars.peek().copied() {
Some('%') => {
chars.next();
out.push('%');
}
Some(n) if n.is_ascii_alphabetic() => {
chars.next();
}
_ => out.push(c),
}
} else {
out.push(c);
}
}
out
}
/// Returns `None` for entries that should not be shown (hidden, NoDisplay, non-Application type).
pub fn parse_desktop(path: &Path) -> Option<DesktopEntry> {
let file = File::open(path).ok()?;
let mut in_entry = false;
let mut name: Option<String> = None;
let mut exec: Option<String> = None;
let mut icon: Option<String> = None;
let mut categories: Option<String> = None;
let mut wm_class: Option<String> = None;
let mut app_type: Option<String> = None;
let mut no_display = false;
let mut hidden = false;
let mut terminal = false;
for line in BufReader::new(file).lines() {
let Ok(raw) = line else { continue };
let s = raw.trim();
if s.starts_with('#') || s.is_empty() {
continue;
}
if s.starts_with('[') {
in_entry = s == "[Desktop Entry]";
continue;
}
if !in_entry {
continue;
}
if let Some(v) = s.strip_prefix("Name=") {
name.get_or_insert_with(|| v.to_string());
} else if let Some(v) = s.strip_prefix("Exec=") {
exec.get_or_insert_with(|| v.to_string());
} else if let Some(v) = s.strip_prefix("Icon=") {
icon.get_or_insert_with(|| v.to_string());
} else if let Some(v) = s.strip_prefix("Categories=") {
categories.get_or_insert_with(|| v.to_string());
} else if let Some(v) = s.strip_prefix("StartupWMClass=") {
wm_class.get_or_insert_with(|| v.to_string());
} else if let Some(v) = s.strip_prefix("Type=") {
app_type.get_or_insert_with(|| v.to_string());
} else if let Some(v) = s.strip_prefix("NoDisplay=") {
no_display = v == "true";
} else if let Some(v) = s.strip_prefix("Hidden=") {
hidden = v == "true";
} else if let Some(v) = s.strip_prefix("Terminal=") {
terminal = v == "true" || v == "1";
}
}
if no_display || hidden {
return None;
}
if app_type.as_deref() != Some("Application") {
return None;
}
let name = name?.trim().to_string();
let exec = strip_exec_codes(exec?.trim()).trim().to_string();
if name.is_empty() || exec.is_empty() {
return None;
}
let icon_name = icon.unwrap_or_default().trim().to_string();
let cats = categories
.unwrap_or_default()
.split(';')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
let id = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.filter(|s| !s.is_empty())
.unwrap_or_default();
Some(DesktopEntry {
id,
name,
exec,
icon_name,
icon_path: None,
categories: cats,
wm_class: wm_class.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()),
terminal,
})
}
/// Walk all configured application directories and return deduplicated entries.
/// Entries from later directories (user-local) override those from earlier ones.
pub fn load_all_desktop_entries() -> Vec<DesktopEntry> {
let mut seen: std::collections::HashMap<String, DesktopEntry> = std::collections::HashMap::new();
for dir in app_dirs() {
let Ok(entries) = fs::read_dir(&dir) else { continue };
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("desktop") {
continue;
}
let key = entry.file_name().to_string_lossy().into_owned();
if let Some(app) = parse_desktop(&path) {
seen.insert(key, app);
}
}
}
seen.into_values().collect()
}

297
bread-launcher/src/gtk.rs Normal file
View file

@ -0,0 +1,297 @@
//! GTK4 results-list widget: the "row-building half" of what used to be
//! breadbox's `run_ui` (`THEME_SYSTEM_PLAN.md` §3) — desktop-entry rows,
//! fuzzy filtering, match/history sorting, and keyboard-style selection
//! movement, packaged as [`ResultsList`] so any host window can embed it.
//! breadbox wraps it in a full-screen overlay window today; breadbar's
//! embedded capsule (a later phase) puts the same widget in its drawer slot.
use std::{cell::RefCell, path::Path, rc::Rc};
use gtk4::{
gdk, gio,
pango::EllipsizeMode,
prelude::*,
Align, Box as GBox, Image, Label, ListBox, ListBoxRow, Orientation, PolicyType,
ScrolledWindow, SelectionMode,
};
use crate::desktop::DesktopEntry;
use crate::history::LaunchHistory;
use crate::matching::{fuzzy_matches, fuzzy_score, split_sections};
fn make_icon(icon_name: &str, icon_path: Option<&Path>, icon_px: i32) -> Image {
// Try loading from resolved cached path via gio::File
if let Some(path) = icon_path {
let gio_file = gio::File::for_path(path);
if let Ok(texture) = gdk::Texture::from_file(&gio_file) {
let img = Image::new();
img.set_paintable(Some(&texture));
img.set_pixel_size(icon_px);
return img;
}
}
// Fall back to GTK icon theme lookup by name
let name = if icon_name.is_empty() {
"application-x-executable"
} else {
icon_name
};
let img = Image::from_icon_name(name);
img.set_pixel_size(icon_px);
img
}
fn build_row(entry: &DesktopEntry, idx: u32, icon_px: i32) -> ListBoxRow {
let row = ListBoxRow::new();
let hbox = GBox::new(Orientation::Horizontal, 0);
hbox.set_margin_start(6);
hbox.set_margin_end(6);
hbox.set_valign(Align::Center);
let icon = make_icon(&entry.icon_name, entry.icon_path.as_deref(), icon_px);
hbox.append(&icon);
let name_lbl = Label::new(Some(&entry.name));
name_lbl.add_css_class("app-name");
name_lbl.set_xalign(0.0);
name_lbl.set_hexpand(true);
name_lbl.set_ellipsize(EllipsizeMode::End);
hbox.append(&name_lbl);
if let Some(ref wm) = entry.wm_class {
let wm_lbl = Label::new(Some(wm));
wm_lbl.add_css_class("app-muted");
wm_lbl.set_xalign(1.0);
hbox.append(&wm_lbl);
}
row.set_child(Some(&hbox));
unsafe { row.set_data("entry", entry.clone()) };
unsafe { row.set_data("initial_order", idx) };
row
}
/// A non-selectable, non-activatable "Recent"/"Apps" label row (plan phase
/// 6c, `[launcher].sections`) — deliberately carries no `"entry"` row data,
/// which is exactly what [`row_entry`] (and everything downstream of it:
/// `set_query`'s filter, `select_next`/`select_prev`'s traversal) already
/// uses to tell a header apart from a real app row.
fn build_header_row(label: &str, idx: u32) -> ListBoxRow {
let row = ListBoxRow::new();
row.set_selectable(false);
row.set_activatable(false);
row.add_css_class("bread-drawer-section-header");
let lbl = Label::new(Some(label));
lbl.add_css_class("section-header-label");
lbl.set_xalign(0.0);
row.set_child(Some(&lbl));
unsafe { row.set_data("initial_order", idx) };
row
}
/// Reads the [`DesktopEntry`] a row was built from — e.g. from a
/// `ListBox::connect_row_activated` handler, which hands back a row
/// reference rather than going through [`ResultsList::selected_entry`].
pub fn row_entry(row: &ListBoxRow) -> Option<DesktopEntry> {
unsafe { row.data::<DesktopEntry>("entry").map(|p| p.as_ref().clone()) }
}
/// A scrollable, filterable, rankable list of desktop-entry rows — the
/// widget breadbox's overlay wraps today and breadbar's capsule will embed
/// next (`THEME_SYSTEM_PLAN.md` §7). A host drives it through
/// [`set_query`](Self::set_query) (wire to a search entry's `changed`
/// signal), [`select_next`](Self::select_next)/[`select_prev`](Self::select_prev)
/// (wire to arrow keys), and reads the current pick via
/// [`selected_entry`](Self::selected_entry) — `list`/`scroller` are exposed
/// directly for anything else a host needs (e.g. `connect_row_activated`
/// for click-to-launch, or placing `scroller` in a slot).
#[derive(Clone)]
pub struct ResultsList {
pub scroller: ScrolledWindow,
pub list: ListBox,
query: Rc<RefCell<String>>,
history: Rc<RefCell<LaunchHistory>>,
}
impl ResultsList {
/// Builds one row per entry (in `entries`' given order — that order is
/// also the fallback sort when the query is empty) and wires up sorting
/// against `history`'s launch counts.
///
/// `sections` (`[launcher].sections`, plan phase 6c): when true, the
/// idle (empty-query) view groups `entries` into "Recent"/"Apps"
/// [`build_header_row`]s via [`split_sections`] instead of one flat
/// list. Sections disappear the moment a query is typed — `set_query`
/// falls back to the same flat fuzzy-ranked list either way — so this
/// only changes the initial build order and the header rows' presence,
/// never the (unchanged) search behaviour. `false` reproduces the
/// exact pre-phase-6c flat list breadbox's own overlay still uses.
pub fn new(
entries: &[DesktopEntry],
icon_px: i32,
history: Rc<RefCell<LaunchHistory>>,
sections: bool,
) -> Self {
let list = ListBox::new();
list.set_selection_mode(SelectionMode::Browse);
let mut idx = 0u32;
if sections {
let (recent, apps) = split_sections(entries.to_vec(), &history.borrow());
if !recent.is_empty() {
list.append(&build_header_row("Recent", idx));
idx += 1;
for entry in &recent {
list.append(&build_row(entry, idx, icon_px));
idx += 1;
}
}
if !apps.is_empty() {
list.append(&build_header_row("Apps", idx));
idx += 1;
for entry in &apps {
list.append(&build_row(entry, idx, icon_px));
idx += 1;
}
}
} else {
for entry in entries {
list.append(&build_row(entry, idx, icon_px));
idx += 1;
}
}
let query: Rc<RefCell<String>> = Rc::new(RefCell::new(String::new()));
{
let query = Rc::clone(&query);
let history = Rc::clone(&history);
list.set_sort_func(move |row_a, row_b| {
let query = query.borrow();
if query.is_empty() {
let oa = unsafe {
row_a.data::<u32>("initial_order").map_or(u32::MAX, |p| *p.as_ref())
};
let ob = unsafe {
row_b.data::<u32>("initial_order").map_or(u32::MAX, |p| *p.as_ref())
};
return oa.cmp(&ob).into();
}
// A header row carries no "entry" data — sort it after any
// real row rather than treating the comparison as `Equal`,
// though `set_query` also hides every header outright once
// a query is non-empty, so this only matters for the
// underlying (invisible) list order, never what's shown.
match (row_entry(row_a), row_entry(row_b)) {
(Some(ea), Some(eb)) => {
let sa = fuzzy_score(&query, &ea);
let sb = fuzzy_score(&query, &eb);
let history = history.borrow();
let ca = history.count(&ea.name);
let cb = history.count(&eb.name);
sa.cmp(&sb)
.then(cb.cmp(&ca))
.then(ea.name.to_lowercase().cmp(&eb.name.to_lowercase()))
.into()
}
(None, Some(_)) => std::cmp::Ordering::Greater.into(),
(Some(_), None) => std::cmp::Ordering::Less.into(),
(None, None) => std::cmp::Ordering::Equal.into(),
}
});
}
let first_real = (0i32..)
.map_while(|i| list.row_at_index(i))
.find(|r| row_entry(r).is_some());
if let Some(first) = first_real {
list.select_row(Some(&first));
}
let scroller = ScrolledWindow::new();
scroller.set_policy(PolicyType::Never, PolicyType::Automatic);
scroller.set_max_content_height(480);
scroller.set_propagate_natural_height(true);
scroller.set_child(Some(&list));
ResultsList { scroller, list, query, history }
}
/// Re-filters (fuzzy match against name, `wm_class`, and `exec`) and
/// re-sorts by `query`, then selects the first visible row. A header
/// row (see [`build_header_row`]) only ever shows in the idle
/// (empty-query) browse view — it has no name/`wm_class`/`exec` of its
/// own to filter against.
pub fn set_query(&self, query: &str) {
*self.query.borrow_mut() = query.to_string();
let mut i = 0i32;
while let Some(row) = self.list.row_at_index(i) {
let vis = match row_entry(&row) {
Some(e) => {
fuzzy_matches(query, &e.name)
|| e.wm_class.as_deref().is_some_and(|w| fuzzy_matches(query, w))
|| fuzzy_matches(query, &e.exec)
}
None => query.is_empty(),
};
row.set_visible(vis);
i += 1;
}
self.list.invalidate_sort();
let first_vis = (0i32..)
.map_while(|j| self.list.row_at_index(j))
.find(|r| r.is_visible() && row_entry(r).is_some());
self.list.select_row(first_vis.as_ref());
}
pub fn selected_entry(&self) -> Option<DesktopEntry> {
self.list.selected_row().and_then(|r| row_entry(&r))
}
/// Moves the selection to the next visible row, if any. Skips header
/// rows even though they may be visible (the idle browse view) —
/// `set_selectable(false)` alone doesn't stop a programmatic
/// `select_row` call from landing on one.
pub fn select_next(&self) {
let cur = self.list.selected_row().map(|r| r.index()).unwrap_or(-1);
let mut i = cur + 1;
loop {
match self.list.row_at_index(i) {
Some(r) if r.is_visible() && row_entry(&r).is_some() => {
self.list.select_row(Some(&r));
break;
}
Some(_) => i += 1,
None => break,
}
}
}
/// Moves the selection to the previous visible row, if any. See
/// [`select_next`](Self::select_next) on skipping header rows.
pub fn select_prev(&self) {
let cur = self.list.selected_row().map(|r| r.index()).unwrap_or(0);
let mut i = cur - 1;
loop {
if i < 0 {
break;
}
match self.list.row_at_index(i) {
Some(r) if r.is_visible() && row_entry(&r).is_some() => {
self.list.select_row(Some(&r));
break;
}
Some(_) => i -= 1,
None => break,
}
}
}
/// Records `entry` as launched in the shared history and persists it.
/// Call before actually launching (matching breadbox's original
/// increment-then-launch ordering) — history and launching are separate
/// concerns, so this doesn't call [`crate::do_launch`] itself.
pub fn record_launch(&self, entry: &DesktopEntry) {
self.history.borrow_mut().increment(&entry.name);
self.history.borrow().save();
}
}

View file

@ -0,0 +1,126 @@
use std::{collections::HashMap, fs, path::PathBuf};
pub struct LaunchHistory {
counts: HashMap<String, u32>,
path: PathBuf,
}
impl LaunchHistory {
/// `app` picks the cache subdirectory (see [`crate::cache_dir`]) the
/// history file lives in.
pub fn load(app: &str) -> Self {
let path = crate::paths::cache_dir(app).join("history.json");
let counts = fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
LaunchHistory { counts, path }
}
pub fn count(&self, name: &str) -> u32 {
self.counts.get(name).copied().unwrap_or(0)
}
pub fn increment(&mut self, name: &str) {
*self.counts.entry(name.to_string()).or_insert(0) += 1;
}
/// Writes `counts` to `path` as JSON. Best-effort — a broken cache dir
/// (missing parent, full disk, permissions) must not stop the caller
/// from launching anything, so this never returns an error — but it now
/// logs one on failure rather than swallowing it silently. Shared by two
/// hosts (breadbox's overlay and breadbar's embedded capsule, both keyed
/// under [`crate::LAUNCHER_APP`]), so a save failure here silently stops
/// ranking history for both.
pub fn save(&self) {
match serde_json::to_string(&self.counts) {
Ok(json) => {
if let Err(err) = fs::write(&self.path, json) {
eprintln!(
"bread-launcher: failed to save launch history to {}: {err}",
self.path.display()
);
}
}
Err(err) => {
eprintln!("bread-launcher: failed to serialize launch history: {err}");
}
}
}
/// In-memory history with no backing file — [`save`](Self::save) fails
/// (an empty `path` is not writable) and now logs that failure to
/// stderr rather than swallowing it, same as any other broken-path
/// case. Lets a test (or a future in-memory host) control counts
/// directly instead of writing through `~/.cache/<app>/history.json`.
#[cfg(test)]
pub(crate) fn from_counts(counts: HashMap<String, u32>) -> Self {
LaunchHistory {
counts,
path: PathBuf::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_history_path(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"bread-launcher-history-test-{}-{name}-{}",
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();
dir.join("history.json")
}
#[test]
fn save_then_load_round_trips_counts() {
let path = temp_history_path("roundtrip");
let mut history = LaunchHistory {
counts: HashMap::new(),
path: path.clone(),
};
history.increment("firefox.desktop");
history.increment("firefox.desktop");
history.increment("kitty.desktop");
history.save();
let text = std::fs::read_to_string(&path).expect("save should have written the file");
let counts: HashMap<String, u32> = serde_json::from_str(&text).unwrap();
assert_eq!(counts.get("firefox.desktop"), Some(&2));
assert_eq!(counts.get("kitty.desktop"), Some(&1));
// load() from the same path should see the same counts.
let reloaded = LaunchHistory {
counts: serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(),
path: path.clone(),
};
assert_eq!(reloaded.count("firefox.desktop"), 2);
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
/// `save()` on an unwritable path (e.g. the parent directory doesn't
/// exist, or `path` is empty) must not panic — it's best-effort, called
/// from a launcher's shutdown path where a hard failure would be worse
/// than a lost history entry. This exercises exactly the failure branch
/// the `eprintln!` above was added for; there is no return value to
/// assert on, so "did not panic" is the contract under test.
#[test]
fn save_to_a_broken_path_does_not_panic() {
let history = LaunchHistory::from_counts(HashMap::from([("x".to_string(), 1)]));
history.save();
let history = LaunchHistory {
counts: HashMap::new(),
path: PathBuf::from("/nonexistent-dir/definitely-not-there/history.json"),
};
history.save();
}
}

View file

@ -0,0 +1,26 @@
use std::{fs, path::PathBuf};
pub struct IconCache {
pub dir: PathBuf,
}
impl IconCache {
/// `app` picks the cache subdirectory (see [`crate::cache_dir`]) — pass
/// the same name across a process's calls so `path_for` and
/// `manifest_path` agree on where icons live.
pub fn new(app: &str) -> Self {
IconCache { dir: crate::paths::cache_dir(app).join("icons") }
}
pub fn path_for(&self, icon_name: &str) -> PathBuf {
self.dir.join(format!("{}.png", icon_name))
}
pub fn manifest_path(app: &str) -> PathBuf {
crate::paths::cache_dir(app).join("manifest.json")
}
pub fn ensure_dir(&self) -> std::io::Result<()> {
fs::create_dir_all(&self.dir)
}
}

View file

@ -0,0 +1,141 @@
use std::{
env,
path::Path,
process::{Command, Stdio},
};
use bread_utils::bread_client::BreadClient;
use crate::desktop::DesktopEntry;
fn pick_terminal() -> String {
if let Ok(t) = env::var("TERMINAL") {
if !t.is_empty() {
return t;
}
}
let path_var = env::var("PATH").unwrap_or_default();
for t in ["foot", "kitty", "alacritty", "wezterm", "ghostty", "xterm"] {
if path_var.split(':').any(|d| Path::new(d).join(t).exists()) {
return t.to_string();
}
}
"xterm".to_string()
}
/// Spawns `entry`'s command (through a terminal if `entry.terminal` is set)
/// and, on a successful spawn, publishes `event` via [`emit_launched`].
///
/// `app_id` here is the caller's **bread event-namespace id** (e.g.
/// `"box"` for breadbox) — NOT [`crate::LAUNCHER_APP`] (`"breadbox"`).
/// Those are two different identities that happen to look similar:
/// `LAUNCHER_APP` only picks the shared cache/history directory (see its own
/// doc comment), while `app_id` here is threaded straight into
/// `BreadClient::connect(app_id)` and must be the caller's own namespace, or
/// `BreadClient::emit`'s `validate_app_namespace` check
/// (`event.starts_with("bread.{app_id}.")`) rejects `event` and drops it
/// with only an eprintln — passing `LAUNCHER_APP` here by mistake is exactly
/// that bug. breadbox passes its own `"box"` (see breadbox's `APP_ID`) so
/// its events publish as `bread.box.*`, matching [`emit_launched`]'s doc
/// example below.
pub fn do_launch(entry: &DesktopEntry, app_id: &str, event: &str) {
let cmd = entry.exec.trim();
let spawned = if entry.terminal {
let term = pick_terminal();
Command::new(&term)
.args(["-e", "bash", "-c", cmd])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
} else {
Command::new("bash")
.args(["-c", cmd])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
};
if spawned.is_ok() {
emit_launched(entry, app_id, event);
}
}
/// Publishes `event` under `app_id`'s bread namespace after a successful
/// spawn — e.g. breadbox calls this with `app_id = "box"` and
/// `event = "bread.box.launched"`, its own namespace. Fire-and-forget and
/// non-fatal (`BreadClient::emit` never blocks or errors this caller) —
/// breadd being absent must never affect launching itself. `app_id` must be
/// the caller's *own* namespace id, not [`crate::LAUNCHER_APP`] — see
/// [`do_launch`]'s doc comment for why those are different identities and
/// what happens if they're confused.
pub fn emit_launched(entry: &DesktopEntry, app_id: &str, event: &str) {
let id = if entry.id.is_empty() {
entry.exec.as_str()
} else {
entry.id.as_str()
};
BreadClient::connect(app_id).emit(
event,
serde_json::json!({ "id": id, "name": entry.name }),
);
}
#[cfg(test)]
mod tests {
use super::*;
/// Mirrors `bread_shared::apps::validate_app_namespace` exactly
/// (`event.starts_with(&format!("bread.{app}."))`) without pulling in
/// that crate here — this is the one check that decides whether
/// [`emit_launched`]'s event actually gets published.
fn passes_namespace_check(app_id: &str, event: &str) -> bool {
event.starts_with(&format!("bread.{app_id}."))
}
#[test]
fn documented_app_id_and_event_pair_passes_the_namespace_check() {
// breadbox's real call site (breadbox/breadbox/src/main.rs):
// APP_ID = "box", LAUNCHED_EVENT = "bread.box.launched".
assert!(
passes_namespace_check("box", "bread.box.launched"),
"do_launch/emit_launched's own doc example must actually pass \
BreadClient::emit's namespace check"
);
}
#[test]
fn launcher_app_is_not_a_valid_app_id_for_the_documented_event() {
// The historical bug this doc fix guards against: passing
// `LAUNCHER_APP` ("breadbox", the cache/history identity) as
// `app_id` instead of the caller's own namespace id ("box") would
// silently drop `bread.box.launched` — event.starts_with(
// "bread.breadbox.") is false for "bread.box.launched".
assert!(
!passes_namespace_check(crate::LAUNCHER_APP, "bread.box.launched"),
"LAUNCHER_APP must NOT satisfy the namespace check for the \
documented event if this ever passes, do_launch's doc comment \
warning about confusing the two identities is wrong"
);
}
#[test]
fn emit_launched_does_not_panic_when_breadd_is_unreachable() {
// No daemon is running in a test environment — emit_launched (and
// the BreadClient::emit it wraps) must degrade silently rather than
// panicking or blocking, for both a valid and a namespace-violating
// app_id.
let entry = DesktopEntry {
id: "firefox.desktop".to_string(),
name: "Firefox".to_string(),
exec: "firefox".to_string(),
icon_name: String::new(),
icon_path: None,
categories: vec![],
wm_class: None,
terminal: false,
};
emit_launched(&entry, "box", "bread.box.launched");
emit_launched(&entry, crate::LAUNCHER_APP, "bread.box.launched");
}
}

63
bread-launcher/src/lib.rs Normal file
View file

@ -0,0 +1,63 @@
//! Headless app-launcher core — desktop-entry discovery, fuzzy matching and
//! ranking, launch history, and process launching — plus an optional GTK4
//! results-list widget behind the `gtk` feature.
//!
//! Lives in `bread-ecosystem`, not an app repo, so breadbar (which must not
//! depend on an app repo) can embed the same launcher logic breadbox's
//! overlay window already wraps: one implementation, two hosts
//! (`THEME_SYSTEM_PLAN.md` §3, §7).
//!
//! Every path/cache/history entry point here takes an explicit `app: &str`
//! rather than hardcoding an app name, so more than one host can use this
//! crate without colliding — see [`cache_dir`]/[`config_dir`]. [`LAUNCHER_APP`]
//! is the one identity every *launcher* host (as opposed to some unrelated
//! future consumer of `cache_dir`/`config_dir`) should actually pass — see
//! its own doc comment for why.
mod desktop;
mod history;
mod icon;
mod launch;
mod matching;
mod paths;
mod query;
#[cfg(feature = "gtk")]
pub mod gtk;
pub use desktop::{load_all_desktop_entries, parse_desktop, strip_exec_codes, DesktopEntry};
pub use history::LaunchHistory;
pub use icon::IconCache;
pub use launch::{do_launch, emit_launched};
pub use matching::{
fuzzy_matches, fuzzy_score, load_sorted_entries, matches_term, priority_rank, split_sections,
};
pub use paths::{app_dirs, cache_dir, config_dir, home_dir};
pub use query::{builtin_commands, eval_calc, filter_commands, parse_query, Command, ParsedQuery, QueryKind};
/// The launcher's one shared identity, passed to [`cache_dir`]/[`config_dir`]/
/// [`IconCache::new`]/[`LaunchHistory::load`] by every host that embeds this
/// crate — breadbox's overlay window AND breadbar's embedded capsule
/// (theme 04/spotlight) alike.
///
/// This is deliberate, not a leftover of breadbox being first: theme 04's
/// whole premise is that breadbar's capsule IS the launcher wearing a
/// different shell, not a second launcher with its own history
/// (`THEME_SYSTEM_PLAN.md` §7). If each host passed its own binary name here,
/// the same physical launcher would rank a user's apps differently
/// depending on which theme happened to be active — the icon cache and
/// "most launched" ordering would silently fork in two. Sharing this
/// constant is what keeps them one launcher.
///
/// Do not pass a bare `"breadbox"` string literal at a call site instead of
/// this constant — that reads exactly like an unfixed bug (breadbar naming
/// another app's identity) and invites a later "fix" that would quietly
/// break the shared history this constant exists to guarantee.
///
/// **Not** the `app_id` for [`do_launch`]/[`emit_launched`]: those publish
/// bread-bus events, which must be namespaced under the caller's *own*
/// identity (breadbox's is `"box"`, not `"breadbox"`) or
/// `BreadClient::emit`'s namespace check silently drops them. This constant
/// is scoped to the cache/history path family only — see [`do_launch`]'s
/// doc comment for the concrete failure mode if the two get swapped.
pub const LAUNCHER_APP: &str = "breadbox";

View file

@ -0,0 +1,387 @@
use std::{collections::HashMap, path::PathBuf};
use crate::desktop::{load_all_desktop_entries, DesktopEntry};
use crate::history::LaunchHistory;
// ---- Fuzzy matching (query filter) ------------------------------------------
/// Subsequence match used to *filter* rows as the user types: every char of
/// `pattern`, in order, must appear somewhere in `text` (case-insensitive).
/// Looser than [`fuzzy_score`], which ranks the rows that pass this filter.
pub fn fuzzy_matches(pattern: &str, text: &str) -> bool {
if pattern.is_empty() {
return true;
}
let mut chars = text.chars();
for pc in pattern.chars() {
let pl = pc.to_lowercase().next().unwrap_or(pc);
if !chars
.by_ref()
.any(|tc| tc.to_lowercase().next().unwrap_or(tc) == pl)
{
return false;
}
}
true
}
/// Ranks how well `query` matches `entry` — lower is better. Exact match (by
/// name or `wm_class`) sorts first, then name-prefix, then name-contains,
/// then `wm_class`-prefix/contains, then everything else that still passed
/// [`fuzzy_matches`] (a subsequence match with no stronger relationship).
pub fn fuzzy_score(query: &str, entry: &DesktopEntry) -> u32 {
let q = query.to_lowercase();
let name = entry.name.to_lowercase();
let wm = entry.wm_class.as_deref().unwrap_or("").to_lowercase();
if name == q || wm == q {
return 0;
}
if name.starts_with(&q) {
return 1;
}
if name.contains(&q) {
return 2;
}
if wm.starts_with(&q) || wm.contains(&q) {
return 3;
}
4 // subsequence match
}
// ---- Priority ranking (empty-query ordering) --------------------------------
/// Whole-word / exact match of `term` within `field` (both lowercase). Avoids
/// "code" matching "vscodium" while still matching "Code", "code-oss", and
/// "Visual Studio Code".
pub fn matches_term(field: &str, term: &str) -> bool {
if term.is_empty() || field.is_empty() {
return false;
}
if field == term {
return true;
}
let bytes = field.as_bytes();
let tlen = term.len();
let mut start = 0;
while let Some(pos) = field[start..].find(term) {
let i = start + pos;
let before_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric();
let after = i + tlen;
let after_ok = after >= bytes.len() || !bytes[after].is_ascii_alphanumeric();
if before_ok && after_ok {
return true;
}
// Advance past the WHOLE match, not one byte past its start. Both `i`
// (a match start) and `i + tlen` (its end) are guaranteed char
// boundaries; `i + 1` is not, so a multi-byte term that failed the
// word-boundary check left `start` inside a character and the next
// `field[start..]` slice panicked outright. `matches_term("café", "é")`
// reproduced it: "byte index 4 is not a char boundary". Reached via
// priority_rank over real .desktop `Name=` values, so any non-ASCII
// app name could crash the launcher's sort.
start = i + tlen;
if start >= field.len() {
break;
}
}
false
}
/// Position of `entry` in the (already-lowercased) `priority` list, matched
/// against either its name or `wm_class`. `None` if `entry` isn't named
/// there at all.
pub fn priority_rank(entry: &DesktopEntry, priority_lower: &[String]) -> Option<usize> {
let name_l = entry.name.to_lowercase();
let wm_l = entry.wm_class.as_deref().unwrap_or("").to_lowercase();
priority_lower
.iter()
.position(|p| matches_term(&name_l, p) || matches_term(&wm_l, p))
}
/// Loads every known desktop entry, resolves each one's icon path from
/// `manifest`, and sorts them: entries named in `priority` come first (in
/// that order), then everything else by most-launched (via `history`), then
/// alphabetically.
pub fn load_sorted_entries(
manifest: &HashMap<String, PathBuf>,
priority: &[String],
history: &LaunchHistory,
) -> Vec<DesktopEntry> {
let mut entries = load_all_desktop_entries();
// Populate icon_path from manifest
for entry in &mut entries {
if let Some(path) = manifest.get(&entry.icon_name) {
if path.exists() {
entry.icon_path = Some(path.clone());
}
}
}
let priority_lower: Vec<String> = priority.iter().map(|s| s.to_lowercase()).collect();
entries.sort_by(|a, b| {
let ai = priority_rank(a, &priority_lower);
let bi = priority_rank(b, &priority_lower);
match (ai, bi) {
(Some(i), Some(j)) => i.cmp(&j),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => {
// Most-launched first, then alphabetical
history
.count(&b.name)
.cmp(&history.count(&a.name))
.then(a.name.to_lowercase().cmp(&b.name.to_lowercase()))
}
}
});
entries
}
/// The demo's own cap (`BOS.pushRecent`'s `.slice(0, 4)`) on how many
/// entries the "Recent" section shows.
pub const MAX_RECENT: usize = 4;
/// Splits `entries` (already ordered by [`load_sorted_entries`]) into a
/// "recent" section — the entries `history` has any launch count for, most-
/// launched first, capped at [`MAX_RECENT`] — and an "apps" section: every
/// other entry, in its existing relative order. No new tracking beyond
/// `LaunchHistory`'s existing counts (THEME_SYSTEM_PLAN.md phase 6c task
/// notes: "`LaunchHistory` already tracks counts for a recents list").
///
/// Only meaningful when `entries` has no priority-ranked prefix (breadbar's
/// capsule calls [`load_sorted_entries`] with an empty `priority` list) —
/// with a non-empty priority list, priority-ranked entries still sort first
/// and would be treated as "apps" here even if launched often, since this
/// function has no way to tell "sorted first because launched a lot" from
/// "sorted first because priority-ranked" apart from the count itself.
pub fn split_sections(
entries: Vec<DesktopEntry>,
history: &LaunchHistory,
) -> (Vec<DesktopEntry>, Vec<DesktopEntry>) {
let mut recent = Vec::new();
let mut apps = Vec::new();
for entry in entries {
if recent.len() < MAX_RECENT && history.count(&entry.name) > 0 {
recent.push(entry);
} else {
apps.push(entry);
}
}
(recent, apps)
}
#[cfg(test)]
mod tests {
#[test]
fn multibyte_term_that_fails_word_boundary_does_not_panic() {
// "é" occurs in "café" but is preceded by an alphanumeric, so the
// whole-word check fails and the scan must continue — landing mid
// character before the fix.
assert!(!super::matches_term("café", "é"));
assert!(!super::matches_term("naïve café", "ï"));
}
#[test]
fn multibyte_whole_word_still_matches() {
assert!(super::matches_term("café bar", "café"));
assert!(super::matches_term("día", "día"));
}
use super::*;
fn entry(name: &str, wm_class: Option<&str>) -> DesktopEntry {
DesktopEntry {
id: format!("{name}.desktop"),
name: name.to_string(),
exec: "true".to_string(),
icon_name: String::new(),
icon_path: None,
categories: Vec::new(),
wm_class: wm_class.map(|s| s.to_string()),
terminal: false,
}
}
// ---- fuzzy_matches -------------------------------------------------
#[test]
fn fuzzy_matches_empty_pattern_matches_anything() {
assert!(fuzzy_matches("", "Firefox"));
assert!(fuzzy_matches("", ""));
}
#[test]
fn fuzzy_matches_in_order_subsequence() {
assert!(fuzzy_matches("ffx", "Firefox"));
assert!(fuzzy_matches("frfx", "Firefox"));
}
#[test]
fn fuzzy_matches_is_case_insensitive() {
assert!(fuzzy_matches("FIREFOX", "firefox"));
assert!(fuzzy_matches("firefox", "FireFox"));
}
#[test]
fn fuzzy_matches_rejects_out_of_order() {
assert!(!fuzzy_matches("xfr", "Firefox"));
}
#[test]
fn fuzzy_matches_rejects_missing_chars() {
assert!(!fuzzy_matches("firefoxx", "Firefox"));
}
// ---- fuzzy_score -----------------------------------------------------
#[test]
fn fuzzy_score_exact_name_match_is_best() {
let e = entry("Firefox", None);
assert_eq!(fuzzy_score("firefox", &e), 0);
}
#[test]
fn fuzzy_score_exact_wm_class_match_is_best() {
let e = entry("Firefox Web Browser", Some("firefox"));
assert_eq!(fuzzy_score("firefox", &e), 0);
}
#[test]
fn fuzzy_score_name_prefix_beats_name_contains() {
let prefix = entry("Firefox", None);
let contains = entry("GNU IceCat (Firefox fork)", None);
assert_eq!(fuzzy_score("fire", &prefix), 1);
assert_eq!(fuzzy_score("fire", &contains), 2);
assert!(fuzzy_score("fire", &prefix) < fuzzy_score("fire", &contains));
}
#[test]
fn fuzzy_score_wm_class_beats_pure_subsequence() {
let wm_hit = entry("Web Browser", Some("firefox"));
let subseq_only = entry("Fine Iris Reflex Editor for XML", None);
assert_eq!(fuzzy_score("fire", &wm_hit), 3);
assert_eq!(fuzzy_score("fire", &subseq_only), 4);
}
// ---- matches_term ------------------------------------------------------
#[test]
fn matches_term_exact_field_matches() {
assert!(matches_term("code", "code"));
}
#[test]
fn matches_term_whole_word_within_longer_field() {
assert!(matches_term("visual studio code", "code"));
}
#[test]
fn matches_term_rejects_substring_of_a_larger_word() {
// "code" must not match inside "vscodium" — this is the whole
// reason matches_term exists instead of a plain `contains`.
assert!(!matches_term("vscodium", "code"));
}
#[test]
fn matches_term_matches_hyphenated_variant() {
assert!(matches_term("code-oss", "code"));
}
#[test]
fn matches_term_empty_term_or_field_never_matches() {
assert!(!matches_term("code", ""));
assert!(!matches_term("", "code"));
}
// ---- priority_rank -----------------------------------------------------
#[test]
fn priority_rank_matches_by_name() {
let e = entry("Firefox", None);
let priority = vec!["firefox".to_string(), "code".to_string()];
assert_eq!(priority_rank(&e, &priority), Some(0));
}
#[test]
fn priority_rank_matches_by_wm_class() {
let e = entry("Web Browser", Some("firefox"));
let priority = vec!["code".to_string(), "firefox".to_string()];
assert_eq!(priority_rank(&e, &priority), Some(1));
}
#[test]
fn priority_rank_none_when_unlisted() {
let e = entry("Nautilus", None);
let priority = vec!["firefox".to_string()];
assert_eq!(priority_rank(&e, &priority), None);
}
#[test]
fn priority_rank_does_not_match_substring_of_a_word() {
let e = entry("VSCodium", None);
let priority = vec!["code".to_string()];
assert_eq!(priority_rank(&e, &priority), None);
}
// ---- split_sections --------------------------------------------------
#[test]
fn split_sections_no_history_is_all_apps() {
let entries = vec![entry("Firefox", None), entry("GoLand", None)];
let history = LaunchHistory::from_counts(HashMap::new());
let (recent, apps) = split_sections(entries, &history);
assert!(recent.is_empty());
assert_eq!(apps.len(), 2);
}
#[test]
fn split_sections_launched_entries_go_to_recent() {
let entries = vec![
entry("Firefox", None),
entry("GoLand", None),
entry("Steam", None),
];
let mut counts = HashMap::new();
counts.insert("Firefox".to_string(), 5);
let history = LaunchHistory::from_counts(counts);
let (recent, apps) = split_sections(entries, &history);
assert_eq!(recent.iter().map(|e| &e.name).collect::<Vec<_>>(), vec!["Firefox"]);
assert_eq!(
apps.iter().map(|e| &e.name).collect::<Vec<_>>(),
vec!["GoLand", "Steam"]
);
}
#[test]
fn split_sections_caps_recent_at_max() {
let entries: Vec<DesktopEntry> = (0..(MAX_RECENT + 2))
.map(|i| entry(&format!("App{i}"), None))
.collect();
let counts = entries
.iter()
.map(|e| (e.name.clone(), 1))
.collect::<HashMap<_, _>>();
let history = LaunchHistory::from_counts(counts);
let (recent, apps) = split_sections(entries, &history);
assert_eq!(recent.len(), MAX_RECENT);
assert_eq!(apps.len(), 2);
}
#[test]
fn split_sections_preserves_relative_order_within_each_group() {
let mut counts = HashMap::new();
counts.insert("A".to_string(), 1);
counts.insert("C".to_string(), 3);
let history = LaunchHistory::from_counts(counts);
// load_sorted_entries would already have ordered these by count
// desc before calling split_sections; split_sections itself just
// partitions in whatever order it's handed, so feed it pre-sorted.
let pre_sorted = vec![entry("C", None), entry("A", None), entry("B", None)];
let (recent, apps) = split_sections(pre_sorted, &history);
assert_eq!(recent.iter().map(|e| &e.name).collect::<Vec<_>>(), vec!["C", "A"]);
assert_eq!(apps.iter().map(|e| &e.name).collect::<Vec<_>>(), vec!["B"]);
}
}

View file

@ -0,0 +1,50 @@
use std::{env, path::PathBuf};
// ---- XDG path helpers -------------------------------------------------------
pub fn home_dir() -> PathBuf {
PathBuf::from(env::var("HOME").unwrap_or_else(|_| "/tmp".into()))
}
/// `$XDG_CACHE_HOME/<app>` (or `~/.cache/<app>`). `app` is the caller's own
/// name in this scheme — e.g. breadbox passes `"breadbox"` to keep using the
/// on-disk layout it always has; a future host picks its own.
pub fn cache_dir(app: &str) -> PathBuf {
env::var("XDG_CACHE_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| home_dir().join(".cache"))
.join(app)
}
/// `$XDG_CONFIG_HOME/<app>` (or `~/.config/<app>`). See [`cache_dir`].
pub fn config_dir(app: &str) -> PathBuf {
env::var("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| home_dir().join(".config"))
.join(app)
}
/// The `applications/` directories a `.desktop` file may live in, per the
/// XDG base-directory spec (system-wide first, user-local last so later
/// entries can override earlier ones on lookup by filename).
pub fn app_dirs() -> Vec<PathBuf> {
let home = home_dir();
let mut dirs = vec![PathBuf::from("/usr/share/applications")];
let xdg_data_dirs =
env::var("XDG_DATA_DIRS").unwrap_or_else(|_| "/usr/local/share:/usr/share".into());
for d in xdg_data_dirs.split(':') {
let p = PathBuf::from(d).join("applications");
if p != dirs[0] {
dirs.push(p);
}
}
dirs.push(
env::var("XDG_DATA_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| home.join(".local/share"))
.join("applications"),
);
dirs
}

391
bread-launcher/src/query.rs Normal file
View file

@ -0,0 +1,391 @@
//! Query modes (`04-spotlight.html`'s `BOS.parseQuery`/`BOS.evalCalc`/
//! `BOS.COMMANDS`, THEME_SYSTEM_PLAN.md phase 6c): a leading `=`/`>`/`.`
//! switches the launcher from filtering apps to evaluating an arithmetic
//! expression, listing bread commands, or treating the rest of the query as
//! a URL to open. Pure/headless — no GTK here — so both breadbar's embedded
//! capsule and (per the module doc comment on `crate`) breadbox's own
//! overlay window can adopt the same parsing/eval/filter logic. A host is
//! expected to gate which prefixes it actually acts on against its theme's
//! `[launcher].modes` list — this module recognizes all four kinds
//! unconditionally and leaves that gating to the caller.
use crate::matching::fuzzy_matches;
/// Which of the four query modes a raw entry-text string names, per its
/// leading character (mirrors `BOS.parseQuery`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QueryKind {
/// No recognized prefix — the ordinary app-filter query.
Apps,
/// Leading `=` — the rest is an arithmetic expression for [`eval_calc`].
Calc,
/// Leading `>` — the rest filters [`builtin_commands`].
Cmd,
/// Leading `.` — the rest is a URL to open.
Url,
}
/// A parsed query: which mode it names, and the text after the prefix
/// character (empty string for a bare `=`/`>`/`.` with nothing typed yet).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedQuery {
pub kind: QueryKind,
pub value: String,
}
/// Splits `raw` on its leading mode character, if any. Mirrors
/// `BOS.parseQuery` exactly: only the FIRST character is checked, and it is
/// stripped along with any immediately-following whitespace.
pub fn parse_query(raw: &str) -> ParsedQuery {
let (kind, rest) = if let Some(rest) = raw.strip_prefix('=') {
(QueryKind::Calc, rest)
} else if let Some(rest) = raw.strip_prefix('>') {
(QueryKind::Cmd, rest)
} else if let Some(rest) = raw.strip_prefix('.') {
(QueryKind::Url, rest)
} else {
(QueryKind::Apps, raw)
};
ParsedQuery {
kind,
value: rest.trim().to_string(),
}
}
// ---- Calc ---------------------------------------------------------------
/// Evaluates `expr` as a small four-function arithmetic expression
/// (`+ - * / ( )`, decimal literals, unary minus) and formats the result
/// the same way `BOS.evalCalc` does: rounded to 8 decimal places, an
/// integer result prints with no trailing `.0`, a non-finite result prints
/// "∞", an expression containing anything outside `[0-9.\s+\-*/()]`
/// returns "bad expr", and anything else that fails to parse or evaluate
/// (unbalanced parens, division producing NaN, trailing garbage) returns
/// "err". `None` only for an empty/whitespace-only expression — the
/// caller's "nothing typed after `=` yet" case, which has no result to
/// show at all (the demo's own `if (!expr) return null;`).
pub fn eval_calc(expr: &str) -> Option<String> {
let expr = expr.trim();
if expr.is_empty() {
return None;
}
if !expr.chars().all(|c| c.is_ascii_digit() || " .+-*/()".contains(c)) {
return Some("bad expr".to_string());
}
let mut p = CalcParser {
bytes: expr.as_bytes(),
pos: 0,
};
let result = p.parse_expr().filter(|_| p.skip_ws() == p.bytes.len());
Some(match result {
Some(n) if !n.is_finite() => "".to_string(),
Some(n) => format_calc_result(n),
None => "err".to_string(),
})
}
/// Rounds to 8 decimal places and formats without a trailing `.0` for whole
/// numbers — `String(Math.round(n * 1e8) / 1e8)` in JS, `{}` on `f64`
/// already behaves the same way in Rust (`format!("{}", 4.0_f64)` == "4").
fn format_calc_result(n: f64) -> String {
let rounded = (n * 1e8).round() / 1e8;
// Avoid printing "-0" for a result that rounds to negative zero.
let rounded = if rounded == 0.0 { 0.0 } else { rounded };
format!("{rounded}")
}
/// Minimal recursive-descent parser: `expr := term (('+'|'-') term)*`,
/// `term := factor (('*'|'/') factor)*`, `factor := '-' factor | number |
/// '(' expr ')'`. Byte-indexed since the character set is already
/// restricted to ASCII by [`eval_calc`]'s pre-check.
struct CalcParser<'a> {
bytes: &'a [u8],
pos: usize,
}
impl<'a> CalcParser<'a> {
fn skip_ws(&mut self) -> usize {
while self.pos < self.bytes.len() && self.bytes[self.pos] == b' ' {
self.pos += 1;
}
self.pos
}
fn peek(&mut self) -> Option<u8> {
self.skip_ws();
self.bytes.get(self.pos).copied()
}
fn parse_expr(&mut self) -> Option<f64> {
let mut val = self.parse_term()?;
loop {
match self.peek() {
Some(b'+') => {
self.pos += 1;
val += self.parse_term()?;
}
Some(b'-') => {
self.pos += 1;
val -= self.parse_term()?;
}
_ => break,
}
}
Some(val)
}
fn parse_term(&mut self) -> Option<f64> {
let mut val = self.parse_factor()?;
loop {
match self.peek() {
Some(b'*') => {
self.pos += 1;
val *= self.parse_factor()?;
}
Some(b'/') => {
self.pos += 1;
val /= self.parse_factor()?;
}
_ => break,
}
}
Some(val)
}
fn parse_factor(&mut self) -> Option<f64> {
match self.peek()? {
b'-' => {
self.pos += 1;
Some(-self.parse_factor()?)
}
b'+' => {
self.pos += 1;
self.parse_factor()
}
b'(' => {
self.pos += 1;
let val = self.parse_expr()?;
if self.peek() == Some(b')') {
self.pos += 1;
Some(val)
} else {
None
}
}
c if c.is_ascii_digit() || c == b'.' => self.parse_number(),
_ => None,
}
}
fn parse_number(&mut self) -> Option<f64> {
self.skip_ws();
let start = self.pos;
let mut seen_dot = false;
let mut seen_digit = false;
while let Some(&c) = self.bytes.get(self.pos) {
if c.is_ascii_digit() {
seen_digit = true;
self.pos += 1;
} else if c == b'.' && !seen_dot {
seen_dot = true;
self.pos += 1;
} else {
break;
}
}
if !seen_digit {
return None;
}
std::str::from_utf8(&self.bytes[start..self.pos])
.ok()
.and_then(|s| s.parse::<f64>().ok())
}
}
// ---- Commands -------------------------------------------------------------
/// One `>`-mode command palette entry: a display `name` and the shell
/// command it runs (via `bash -c`, same spawn convention [`crate::do_launch`]
/// already uses for a desktop entry's `Exec=` line).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Command {
pub id: &'static str,
pub name: &'static str,
pub exec: &'static str,
}
/// A small, real bread-ecosystem command palette — deliberately not the
/// demo's placeholder set (`Lock session`/`Open settings`/`Test
/// notification` map to nothing real in this codebase). `loginctl
/// lock-session` and `bread reload` are both already-documented, safe,
/// no-argument commands (see the bread CLI reference / systemd-logind).
pub fn builtin_commands() -> &'static [Command] {
&[
Command {
id: "lock",
name: "Lock session",
exec: "loginctl lock-session",
},
Command {
id: "reload-breadd",
name: "Reload breadd",
exec: "bread reload",
},
]
}
/// Fuzzy-filters `commands` by `query` against each command's `name` (same
/// subsequence match [`crate::fuzzy_matches`] uses for app rows) — an empty
/// query matches everything, same as the app list's own empty-query case.
pub fn filter_commands(query: &str, commands: &[Command]) -> Vec<Command> {
commands
.iter()
.filter(|c| fuzzy_matches(query, c.name))
.cloned()
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
// ---- parse_query -------------------------------------------------
#[test]
fn parse_query_bare_text_is_apps() {
let p = parse_query("firefox");
assert_eq!(p.kind, QueryKind::Apps);
assert_eq!(p.value, "firefox");
}
#[test]
fn parse_query_empty_is_apps() {
let p = parse_query("");
assert_eq!(p.kind, QueryKind::Apps);
assert_eq!(p.value, "");
}
#[test]
fn parse_query_equals_is_calc() {
let p = parse_query("=2+2");
assert_eq!(p.kind, QueryKind::Calc);
assert_eq!(p.value, "2+2");
}
#[test]
fn parse_query_gt_is_cmd() {
let p = parse_query(">lock");
assert_eq!(p.kind, QueryKind::Cmd);
assert_eq!(p.value, "lock");
}
#[test]
fn parse_query_dot_is_url() {
let p = parse_query(".breadway.dev");
assert_eq!(p.kind, QueryKind::Url);
assert_eq!(p.value, "breadway.dev");
}
#[test]
fn parse_query_strips_leading_whitespace_after_prefix() {
let p = parse_query("= 2 + 2 ");
assert_eq!(p.kind, QueryKind::Calc);
assert_eq!(p.value, "2 + 2");
}
#[test]
fn parse_query_bare_prefix_has_empty_value() {
assert_eq!(parse_query("=").value, "");
assert_eq!(parse_query(">").value, "");
assert_eq!(parse_query(".").value, "");
}
// ---- eval_calc -----------------------------------------------------
#[test]
fn eval_calc_empty_expr_is_none() {
assert_eq!(eval_calc(""), None);
assert_eq!(eval_calc(" "), None);
}
#[test]
fn eval_calc_simple_addition() {
assert_eq!(eval_calc("2+2"), Some("4".to_string()));
}
#[test]
fn eval_calc_precedence() {
assert_eq!(eval_calc("2+3*4"), Some("14".to_string()));
}
#[test]
fn eval_calc_parens() {
assert_eq!(eval_calc("(2+3)*4"), Some("20".to_string()));
}
#[test]
fn eval_calc_unary_minus() {
assert_eq!(eval_calc("-5+2"), Some("-3".to_string()));
}
#[test]
fn eval_calc_decimals_round_to_8_places() {
assert_eq!(eval_calc("0.1+0.2"), Some("0.3".to_string()));
}
#[test]
fn eval_calc_division_by_zero_is_infinity_symbol() {
assert_eq!(eval_calc("1/0"), Some("".to_string()));
}
#[test]
fn eval_calc_bad_chars_is_bad_expr() {
assert_eq!(eval_calc("2+alert(1)"), Some("bad expr".to_string()));
assert_eq!(eval_calc("rm -rf /"), Some("bad expr".to_string()));
}
#[test]
fn eval_calc_unbalanced_parens_is_err() {
assert_eq!(eval_calc("(2+3"), Some("err".to_string()));
}
#[test]
fn eval_calc_trailing_garbage_is_err() {
assert_eq!(eval_calc("2+3)"), Some("err".to_string()));
}
#[test]
fn eval_calc_double_operator_is_err() {
assert_eq!(eval_calc("2++"), Some("err".to_string()));
}
#[test]
fn eval_calc_whitespace_is_tolerated() {
assert_eq!(eval_calc(" 2 + 2 "), Some("4".to_string()));
}
// ---- commands --------------------------------------------------------
#[test]
fn builtin_commands_are_non_empty() {
assert!(!builtin_commands().is_empty());
}
#[test]
fn filter_commands_empty_query_matches_all() {
let all = builtin_commands();
assert_eq!(filter_commands("", all).len(), all.len());
}
#[test]
fn filter_commands_filters_by_name_subsequence() {
let matches = filter_commands("lock", builtin_commands());
assert!(matches.iter().any(|c| c.id == "lock"));
assert!(!matches.iter().any(|c| c.id == "reload-breadd"));
}
#[test]
fn filter_commands_no_match_is_empty() {
assert!(filter_commands("zzzznotacommand", builtin_commands()).is_empty());
}
}

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

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

@ -0,0 +1,370 @@
//! 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 allowed_users: Vec<String> = users.iter().map(|u| u.name.clone()).collect();
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, &allowed_users, &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,
allowed_users: &[String],
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 }) => {
// The username field is user-editable; only accept it when
// it's one of the identities polkit offered (empty falls
// back to the prefilled user). Anything else is rejected
// and the prompt re-shown rather than starting a PAM
// conversation for an account the request never offered.
let Some(user) = resolve_user(default_user, allowed_users, &username) else {
let cookie = cookie.to_string();
invoke_ui(move || ui::show_retry(&cookie, INVALID_USER_MESSAGE));
continue;
};
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));
}
}
}
}
}
}
}
const INVALID_USER_MESSAGE: &str =
"That user is not one of the identities this request offered — use the prefilled user.";
/// Choose the username to authenticate as from the prompt input.
///
/// Empty input falls back to `default`. Non-empty input must be one of
/// `allowed` — the `unix-user` identities polkit actually offered in
/// `BeginAuthentication` — otherwise it returns `None`. Without this, a
/// request scoped to specific accounts (say, `root` only) could have its
/// editable username field redirected to an arbitrary local user, kicking
/// off a PAM conversation for an account the request never offered.
/// (`auth::authenticate`'s PAM result still has to clear polkit's own
/// authorization, but this closes the obvious foot-gun at the agent — the
/// one place the identity list is actually known.)
fn resolve_user(default: &str, allowed: &[String], input: &str) -> Option<String> {
if input.is_empty() {
return Some(default.to_string());
}
if allowed.iter().any(|u| u.as_str() == input) {
return Some(input.to_string());
}
None
}
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(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn allowed() -> Vec<String> {
vec!["root".to_string(), "1000".to_string()]
}
#[test]
fn resolve_user_falls_back_to_default_on_empty_input() {
assert_eq!(
resolve_user("root", &allowed(), ""),
Some("root".to_string())
);
}
#[test]
fn resolve_user_accepts_an_offered_identity() {
assert_eq!(
resolve_user("root", &allowed(), "1000"),
Some("1000".to_string())
);
}
#[test]
fn resolve_user_rejects_a_user_polkit_did_not_offer() {
assert_eq!(resolve_user("root", &allowed(), "alice"), None);
assert_eq!(resolve_user("root", &allowed(), "daemon"), None);
}
#[test]
fn resolve_user_is_case_exact() {
// Usernames are case-significant; "ROOT" is a different principle
// than the offered "root", so it must be rejected.
assert_eq!(resolve_user("root", &allowed(), "ROOT"), None);
}
}

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(users: &[UnixUser], current_uid: Option<u32>) -> Option<&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;

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

@ -0,0 +1,99 @@
//! 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) => {
// Don't keep running without the single-instance lock: a second
// copy would attempt to `serve_at` the same PolicyKit agent
// object path on the system bus, and a password prompt held by a
// process whose lock couldn't be taken is ambiguous state. Fail
// fast and let a wrapper/autostart retry.
eprintln!("bread-polkit: singleton lock unavailable ({e}); exiting");
std::process::exit(1);
}
};
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

@ -12,12 +12,30 @@ keywords = ["theming", "pywal", "gtk4", "wayland"]
serde = { workspace = true }
serde_json = { workspace = true }
dirs = { workspace = true }
# bread_theme::shell manifest parsing (theme.toml) — gtk-free, so `bread`
# (daemon) and `breadcrumbs` (CLI) can validate a theme without linking GTK.
toml = { workspace = true }
anyhow = { workspace = true }
tracing = { 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.

View file

@ -0,0 +1,117 @@
/* CSS template for the daylight builtin (bread-theme/src/shell/builtin.rs).
* Same scope and substitution rules as its three siblings: only the
* window/workspace/clock chrome the manifest's own concepts model, `{name}`
* tokens substituted, `@name` palette references passed through untouched.
* Declared-but-not-yet-consumed in production, same as every sibling
* template see `ShellTheme::css`'s doc comment for why (breadbar hand-
* rolls its own CSS in `breadbar::theme::load_css` instead of calling this
* method). Exercised by this crate's own tests.
*
* Source: bos-ui-demos/proposed/daylight.html's <style> block.
*
* Unlike its three siblings, this template hardcodes which of `@bg`/`@on-bg`
* plays "paper surface" versus "ink" directly (`@on-bg` as the fill, `@bg`
* as the text) rather than branching on `{light}` at substitution time
* `Tokens::substitute` only does `{name}` replacement, not conditionals, and
* this template is Daylight's alone, so there is nothing to branch FOR here.
* `breadbar::theme::load_css` is the one place that genuinely needs to pick
* between the two directions at runtime (it renders all four themes from
* one function) see its own `panel`/`ink` locals and Tokens::light's doc
* comment for the general mechanism this template only needs one side of.
*/
window.breadbar {
background-color: transparent;
border: none;
box-shadow: none;
color: @bg;
}
window.breadbar > box > centerbox { padding: 0 14px; }
window.breadbar button { min-height: 0; min-width: 0; }
/* The three detached pills (axis 3, segmented) see
* breadbar::theme::load_css's `.bar-segment` rule for the identical
* production version of this block.
*/
.bar-segment {
background-color: alpha(@on-bg, {bg_alpha});
border: 1px solid alpha(@bg, 0.10);
border-radius: {radius_bar}px;
box-shadow: 0 2px 10px alpha(@bg, 0.13);
}
.workspace-trail {
background-image: linear-gradient(90deg, @{accent_from}, @{accent_to});
background-color: @{accent_from};
border-radius: {radius_sm}px;
}
.workspace-btn {
background: transparent;
opacity: 0.36;
color: @bg;
border-radius: {radius_sm}px;
border: none;
outline: none;
box-shadow: none;
min-width: 28px;
min-height: {chip_height}px;
margin: 0;
padding: 0 7px;
font-size: 22px;
font-weight: bold;
transition: opacity 0.22s {spring_settle}, background-color 0.22s {spring_settle};
}
.workspace-btn:hover { opacity: 0.85; background: alpha(@bg, 0.08); }
.workspace-btn.occupied { opacity: 0.78; }
.workspace-btn.active { background: transparent; color: @on-accent; opacity: 1; }
.workspace-btn.active:hover { background: transparent; }
.clock-plain { padding: 0 4px; }
.clock-plain-time {
font-size: {font_size_base}px;
font-weight: 600;
letter-spacing: 0.04em;
}
.date-label {
font-size: 12px;
opacity: 0.48;
font-weight: 400;
letter-spacing: 0.04em;
}
/* Warm amber equaliser the second accent (axis 1's colour-mapping note):
* distinct from the teal workspace-trail accent above.
*/
.media-eq-bar {
background-color: @{accent2};
}
/* Light-theme overrides of the notification/OSD surfaces the demo's own
* `.notifs`/`.note`/`.osd` block, which explicitly replaces shared.css's
* dark defaults. `@on-bg` (paper) / `@bg` (ink), not `@bg` (glass) / `@on-bg`
* (light ink) the way every sibling theme's identical rule reads this is
* the one deliberate inversion this template makes relative to its
* siblings, and it is exactly what `{light}` conditions in the shared Rust
* implementation (breadbar::theme::load_css). See axis 1 in the task
* report for the full inventory this one inversion stands in for.
*/
window.breadbar-osd {
background-color: alpha(@on-bg, {bg_alpha});
color: @bg;
border-radius: {radius_pill}px;
border: 1px solid alpha(@bg, 0.10);
box-shadow: 0 8px 22px alpha(@bg, 0.16);
}
window.breadbar-panel {
background-color: alpha(@on-bg, {bg_alpha});
color: @bg;
border-radius: {radius_card}px;
border: 1px solid alpha(@bg, 0.10);
}
window.breadbar-dismiss {
background-color: alpha(#000000, 0.02);
}
.bread-widget-slot { margin-right: {pad}px; }

View file

@ -0,0 +1,281 @@
# The fourth compiled-in builtin (bread-theme/src/shell/builtin.rs), demo
# "Daylight" in bos-ui-demos/proposed/daylight.html — a bottom-anchored dock
# split into three DETACHED pills (workspaces | clock+media | stats)
# floating on an otherwise fully transparent bar, ink-on-paper: near-opaque
# white surfaces, soft drop shadows (blur is OFF — see [compositor] below),
# deep teal accent, warm amber equaliser. This is the first built-in that is
# LIGHT, the first anchored BOTTOM, the first SEGMENTED (three pills instead
# of one bar surface), and the first with blur deliberately disabled — see
# the task report for what each of those four axes required.
#
# Values are taken from bos-ui-demos/proposed/daylight.html's <style> block;
# where the demo is silent (satellite offsets, bar-adjacent chip sizing)
# this file re-derives from the dock's own edge, same method every sibling
# theme.toml already documents.
#
# Font (Outfit is not installed on the dev machine, same gap liquid-motion/
# spotlight already have — renders in fallback until ttf-outfit is
# installed) and colour (the demo's #2f6d7a teal / #c2683c amber are *flat*
# accents — mapped to this palette's `teal` / `yellow` tokens, see [tokens]'s
# own note — never a literal hex value, or pywal theming breaks).
name = "Daylight"
id = "daylight"
[tokens]
font_family = "Outfit, sans-serif"
font_fallback = "sans-serif"
font_size_base = 14
# `.bar-segment`'s radius (see `bar_border = "segmented"` below) — the
# window itself (`window.breadbar`) draws no radius at all, since it draws
# no fill/border either. Demo: `.seg { border-radius: 14px }`.
radius_bar = 14
# Notification/history card radius. Demo has no explicit `.note` radius of
# its own (its override block only touches background/border/shadow/colour
# — see shared.css's `.note { border-radius: 12px }` default) — 14px keeps
# it a close, deliberate match to this theme's own popover/segment radius
# instead of inheriting an unrelated demo's 12px.
radius_card = 14
# Chip/workspace-pill radius. Demo: `.chip`/`.wp`/`.trail` all draw a 9px
# pill (concentric inside the 14px segment with a 7px inset, per the demo's
# own comment next to `.chip`).
radius_sm = 9
radius_pill = 999
# Demo: `.pop { padding: 13px }`.
pad = 13
# `.bar-segment` fill alpha — demo: `.seg { background: rgba(255,255,255,.94) }`,
# a near-OPAQUE paper pill, not liquid-motion/glass-workbench's translucent
# glass (0.72). Also reused for the notification/OSD/panel surfaces below
# (the same `bg_alpha` token every sibling theme already reuses for more
# than just the bar).
bg_alpha = 0.94
# The demo defines both curves distinctly (`--spring`/`--settle`), like
# liquid-motion — not glass-workbench/spotlight's single shared curve.
spring = "cubic-bezier(0.22, 1.35, 0.36, 1)"
spring_settle = "cubic-bezier(0.22, 1.2, 0.36, 1)"
# Palette token NAMES, not hex — #2f6d7a is this palette's `teal`. Equal
# from/to because the demo's workspace-trail fill is flat
# (`.trail { background: var(--accent) }`), not a gradient — same reasoning
# as glass-workbench/spotlight's own flat accents. accent_from IS read (the
# Trail gradient's start stop, and now — this theme is the reason — its end
# stop too, see accent_to's own doc comment in types.rs).
accent_from = "teal"
accent_to = "teal"
# A SECOND accent, independent of accent_from/accent_to: the demo's media
# equaliser bars are warm amber (`--accent2: #c2683c`), not the workspace
# trail's deep teal — one accent value can no longer describe both, which is
# why this key exists at all (see Tokens::accent2's doc comment). Mapped to
# `yellow` (pywal ANSI color3): the demo's amber reads warmer/more orange
# than a pure hue, and `yellow` is the only remaining named accent slot not
# already claimed by `accent`/`teal`/`green`/`pink` across the four built-in
# themes that reads plausibly warm regardless of wallpaper. Consumed by
# breadbar::theme::load_css's `.media-eq-bar` rule.
accent2 = "yellow"
# Demo: `.chip { height: 26px }` / `.wp { height: 26px }` on the 40px dock —
# the same 26px liquid-motion's own Trail-style bar already uses (see
# `breadbar::theme::approved_chip_height`, which is what actually governs
# this in practice — see that function's own note on why this token is
# otherwise a restated truth, not a live input).
chip_height = 26
# No single demo number for bar-icon glyph size (the demo's `.gl` box is
# 16x16, but that is the icon's *swatch*, not the glyph-drawing size other
# themes' icon_px models) — chosen proportionally between liquid-motion's
# 24px (44px bar) and glass-workbench's 18px (36px bar) for this theme's
# 40px dock.
icon_px = 20
# THE axis-3 (segmented) knob: `window.breadbar` itself gets no fill/
# border/radius at all (drawn fully transparent); the bar's three slot-group
# containers each get their own `.bar-segment` pill surface instead. See
# Tokens::bar_border's doc comment and breadbar::theme::load_css's
# `segmented` local for exactly what this changes.
bar_border = "segmented"
# THE axis-1 (light) knob — see Tokens::light's doc comment for why this
# exists and exactly what it swaps. Every dark-assuming hardcode this flag
# fixes is listed in the task report.
light = true
[bar.window]
# THE axis-2 (bottom-anchored) knob. Full-bleed left/right (`window.
# breadbar`'s own bounds touch both screen edges, like every sibling
# theme's top-anchored bar), with `bottom_margin` floating it 12px off the
# bottom edge — the demo's own `.dock { left: 0; right: 0; bottom: 12px }`.
# The 14px horizontal inset the demo's `.dock` draws via CSS `padding`
# (not a window margin) is reproduced the same way: see `centerbox_padding`
# in breadbar::theme::load_css, keyed off `bar_border == "segmented"`
# exactly like glass-workbench's flush bar already keys its own padding off
# `bar_border == "bottom"`.
anchors = ["bottom", "left", "right"]
width = "fill"
height = 40
margin = { top = 0, left = 0, right = 0, bottom = 12 }
exclusive = "auto"
keyboard = "none"
layer = "top"
[bar.slots]
# Same widget-alias shape as liquid-motion (plan §11 phase 1's fixed
# interleave points), even though the demo itself shows no extra Lua
# widget — so a widget requesting one of these placements has a home under
# Daylight too, the same reasoning spotlight's own `widget:left_of_stats`
# documents for its slimmer slot set.
left = ["workspaces", "widget:right_of_workspaces"]
centre = ["media", "widget:left_of_clock", "clock", "widget:right_of_clock"]
right = ["widget:left_of_stats", "volume", "wifi", "battery", "control"]
drawer = []
[modules.workspaces]
style = "trail"
show_empty = true
[modules.clock]
# Demo: one time label + one date label (`.clock`/`.date`), not liquid-
# motion's per-digit flip — same reasoning as glass-workbench's own "plain"
# choice.
style = "plain"
format = "%H:%M"
show_date = true
[launcher]
mode = "overlay"
# Demo: `.bx { width: min(430px, 90%) }`.
width = 430
# NOTE — known gap, see task report: `breadbox` (the process that actually
# reads this field) is not one of this task's two repos, and `Launcher` has
# no "anchor from bottom" concept at all — `top` is unconditionally a
# distance from the TOP of the screen in every existing consumer. The demo's
# launcher rises from just above the dock (`.bx { bottom: 62px }`), which
# this schema/consumer pairing cannot express without a breadbox-side change
# this task cannot make. 55% is a best-effort placement (roughly the lower
# half of the screen, not flush against either edge) documented here as a
# stopgap, not a real fix.
top = "55%"
# Demo: `.bx { border-radius: 18px }`.
radius = 18
# Demo: `.bx .ico { width: 26px; height: 26px }`.
icon_px = 26
# Demo's rows all fade/slide in together with a small per-index stagger
# (`animation-delay: min(i,10)*20ms`) — closest existing `row_anim` value is
# glass-workbench's "stagger" (the CSS classes it reuses are already 28ms
# apart, not 20ms, but this schema has no numeric stagger-interval knob of
# its own to match the demo's exactly).
row_anim = "stagger"
# Demo: a plain 1px hairline under the search field
# (`.bx .q { border-bottom: 1px solid rgba(26,29,34,.09) }`), not liquid-
# motion's gradient rule.
rule = "hairline"
footer = "count_apps"
sections = true
# Demo supports all four query prefixes (bare/`=`/`>`/`.`), same as
# spotlight — its `<script>` reuses the identical shared.js query parser.
modes = ["apps", "calc", "cmd", "url"]
# `.bx .r { border-radius: 10px; margin: 0 7px; padding: 9px 11px }`
row_radius = 10
row_inset = 7
row_padding_v = 9
row_padding_h = 11
# `.bx .ico { border-radius: 8px }` (paired with icon_px = 26 above)
icon_radius = 8
# `.bx .q { padding: 15px 17px; font-size: 15px }`
search_font_size = 15
search_padding_v = 15
search_padding_h = 17
# Demo: `.bx { background: var(--paper) }` — fully OPAQUE white, not
# liquid-motion/glass-workbench's translucent glass. 0.97, not a bare 1.0,
# to read as a deliberate "near-opaque paper" choice rather than a rounding
# accident, matching this theme's own `bg_alpha`/pill-surface language.
panel_alpha = 0.97
# Demo: `.bx .r.sel { background: rgba(47,109,122,.15) }` — teal at 15%.
selection_alpha = 0.15
# Keyed by layer-shell namespace, matching [compositor.*] below.
#
# `bottom_right`, not `top_right`: THE OTHER axis-2 gap this theme exposed
# and fixed. Every sibling theme's bar sits at the TOP, so `top_right`
# always put breadbar-notif/breadbar-panel naturally close to the bar; a
# bottom-anchored dock has nothing in the original three-shape anchor set
# that keeps its satellites near it. `bottom_right` is a new fourth anchor
# shape (bread-theme/src/shell/manifest.rs + breadbar/src/surface.rs),
# validated the same "typo'd key is a hard error" way as its siblings.
# `offset` is `[right, bottom]`, mirroring `top_right`'s `[right, top]`
# convention. This bar's own bottom edge sits at margin.bottom(12) +
# height(40) = 52px, so 64 (52 + a 12px gap, the same gap-beyond-the-bar-
# edge relationship every sibling theme's own top_right offset already
# uses) keeps the popup just clear of the dock.
[surfaces."breadbar-notif"]
anchor = "bottom_right"
offset = [16, 64]
# 320, not the demo's own shared.css `.notifs { width: 250px }` — every
# sibling theme's breadbar-notif width already follows breadbar's real
# `set_default_width(320)` live-toast constant instead of the demo markup's
# arbitrary number (see liquid-motion/theme.toml's own note on this), and
# this theme follows that same real-app-fidelity precedent rather than the
# demo's number.
width = 320
layer = "overlay"
[surfaces."breadbar-osd"]
# Demo: `.osd { bottom: 64px }` — independent of `anchor`, this already
# matches the notif/panel offset above (both landed on 64 from the same
# "12px clear of the dock's 52px edge" reasoning, coincidentally the same
# number as the notif/panel `offset[1]`).
anchor = "bottom_centre"
offset = 64
width = 180
layer = "overlay"
[surfaces."breadbar-panel"]
anchor = "bottom_right"
offset = [16, 64]
width = "auto"
layer = "overlay"
[surfaces."breadbar-dismiss"]
# THE THIRD axis-2 consequence: `fill`'s `offset` was single-value
# (top-margin-only) before this theme — a bottom-anchored bar needs the
# scrim's GAP at the BOTTOM instead, so `breadbar/src/surface.rs::apply`'s
# "fill" arm now reads a `[top, bottom]` pair (falling back to `[value, 0]`
# for every existing single-value theme, so liquid-motion/glass-workbench/
# spotlight are unaffected). `0` here means the scrim covers all the way to
# the true top of the screen (nothing to clear up there); `52` is this bar's
# own edge (margin.bottom(12) + height(40)), leaving the dock's own
# footprint click-through the way every sibling theme's single top offset
# already leaves its own bar's footprint alone.
anchor = "fill"
offset = [0, 52]
width = "fill"
layer = "overlay"
# Appearance-only, never placement/workspace/focus (plan §12 Layer B
# boundary). THE axis-4 knob: `blur = false` everywhere, and `blur_popups`
# dropped too — the demo draws depth with `box-shadow`, not
# `backdrop-filter`, and per the task brief this also sidesteps the
# shadow-halo trap a blurred `ignore_alpha` surface has (fixed in breadbox
# by removing the shadow entirely — see that fix's history) since there is
# no blur here for a shadow to get caught inside. No `ignore_alpha`
# anywhere below either: that key only means anything to a BLURRED surface,
# and setting it on an unblurred one would be exactly the kind of
# never-consumed key this project's rules forbid.
[compositor."breadbar"]
blur = false
blur_popups = false
animation = "slide bottom"
[compositor."breadbar-osd"]
blur = false
animation = "slide bottom"
[compositor."breadbar-notif"]
blur = false
animation = "slide right"
[compositor."breadbar-panel"]
blur = false
animation = "slide right"
[compositor."breadbar-dismiss"]
no_anim = true
[compositor."breadbox"]
blur = false
# No `css = "..."` overlay — compiled-in builtin, same as its three siblings.

View file

@ -0,0 +1,86 @@
/* CSS template for the glass-workbench builtin (bread-theme/src/shell/
* builtin.rs). Same scope and substitution rules as liquid-motion.css: only
* the window/workspace/clock chrome the manifest's own concepts model, `{name}`
* tokens substituted, `@name` palette references passed through untouched.
*
* Source: bos-ui-demos/02-glass-workbench.html's <style> block. No
* per-digit flip (this theme has no `.clock-digit` markup at all plain
* style is a date label + one time label) and no gradient trail (pill style
* never makes `.workspace-trail` visible see breadbar's
* `WorkspaceTrail::place`/`stretch`, which this theme's slot config simply
* never calls).
*/
window.breadbar {
background-color: alpha(@bg, {bg_alpha});
color: @on-bg;
border-radius: {radius_bar}px;
border: none;
border-bottom: 1px solid alpha(@on-bg, 0.07);
}
window.breadbar > centerbox { padding: 0 {pad}px; }
window.breadbar button { min-height: 0; min-width: 0; }
/* Pill workspaces: solid accent fill when active, dimmed when empty no
* trail overlay is ever shown (place()/stretch() are never called for this
* style), so `.workspace-trail` itself needs no rule here.
*/
.workspace-btn {
background: transparent;
opacity: 1;
color: alpha(@on-bg, 0.4);
border-radius: {radius_sm}px;
border: none;
outline: none;
box-shadow: none;
min-width: 22px;
min-height: {chip_height}px;
margin: 0;
padding: 0 6px;
font-size: 12px;
font-weight: 600;
transition: background-color 0.22s {spring_settle}, color 0.22s {spring_settle},
opacity 0.22s {spring_settle};
}
.workspace-btn:hover { background: alpha(@on-bg, 0.08); }
.workspace-btn.occupied { color: alpha(@on-bg, 0.8); }
.workspace-btn:not(.occupied):not(.active) { opacity: 0.35; }
.workspace-btn.active {
background: @{accent_from};
color: @on-accent;
opacity: 1;
}
.workspace-btn.active:hover { background: @{accent_from}; }
.clock-plain { padding: 0 4px; }
.clock-plain-time {
font-size: {font_size_base}px;
font-weight: 600;
letter-spacing: 0.04em;
}
.date-label {
font-size: 12px;
opacity: 0.48;
font-weight: 400;
letter-spacing: 0.04em;
}
window.breadbar-osd {
background-color: alpha(@bg, 0.72);
color: @on-bg;
border-radius: {radius_pill}px;
border: 1px solid alpha(@on-bg, 0.10);
}
window.breadbar-panel {
background-color: alpha(@bg, 0.86);
color: @on-bg;
border-radius: {radius_card}px;
border: 1px solid alpha(@on-bg, 0.12);
}
window.breadbar-dismiss {
background-color: alpha(#000000, 0.02);
}
.bread-widget-slot { margin-right: {pad}px; }

View file

@ -0,0 +1,198 @@
# The second compiled-in builtin (bread-theme/src/shell/builtin.rs), demo 02
# in THEME_SYSTEM_PLAN.md — "Glass Workbench": a flush edge-to-edge bar with
# plain pill workspaces, a plain date+time clock, and cpu/ram chips instead
# of the media widget liquid-motion carries. Values are taken from
# bos-ui-demos/02-glass-workbench.html's <style> block; where the demo is
# silent on a number (bar-adjacent chip sizing, satellite offsets) this file
# scales liquid-motion's own numbers down by the same 36/44 bar-height ratio
# rather than inventing something unrelated to either source.
#
# Unlike liquid-motion, this builtin has no "as the code exists today" tether
# — it is new, so demo fidelity is the only source of truth. See Phase 5's
# task notes for the two exceptions: font (IBM Plex Sans is not installed on
# the dev machine, so this renders in fallback until the user installs
# ttf-ibm-plex — this now matters for real: breadbox's launcher panel
# consumes font_family/font_fallback below, see [tokens]'s own note) and
# colour (the demo's #7a9a88 sage is a *flat* accent, so it maps to the
# palette's `green` token for both accent_from and accent_to — never a
# literal hex value, or pywal theming breaks).
name = "Glass Workbench"
id = "glass-workbench"
[tokens]
# font_family/font_fallback/font_size_base are consumed by breadbox's
# launcher panel only (breadbox::main::build_css's entry.search/row rules —
# see Tokens::font_family's doc comment for the exact scope); breadbar's
# bar/chip/clock text still comes from bread_theme::stylesheet()'s own
# hardcoded FONT_FAMILY constant, unaffected by this key.
font_family = "IBM Plex Sans"
font_fallback = "Inter, Noto Sans, sans-serif"
font_size_base = 13
radius_bar = 0
radius_card = 10
radius_sm = 6
radius_pill = 999
pad = 12
bg_alpha = 0.72
# The demo defines exactly one easing curve (`--spring: cubic-bezier(.22,
# 1.2, .36, 1)`), not liquid-motion's overshoot/settle pair — there's no
# digit-flip or trail-stretch here to want a bounce for. Both `spring` and
# `spring_settle` point at the demo's one curve rather than inventing a
# second value the demo never specifies.
spring = "cubic-bezier(0.22, 1.2, 0.36, 1)"
spring_settle = "cubic-bezier(0.22, 1.2, 0.36, 1)"
# Palette token NAMES, not hex — #7a9a88 is this palette's `green`. Equal
# from/to because the demo's accent is flat, not a gradient. accent_from IS
# read (Pill-style workspace fill, theme.rs); accent_to is declared-but-
# not-yet-consumed regardless of style — nothing reads it outside the
# breadbar Trail gradient this theme doesn't use, and even Trail hardcodes
# its own literal gradient rather than substituting it. See
# Tokens::accent_to's doc comment.
accent_from = "green"
accent_to = "green"
# Demo: `.ws button { height: 20px }`. Liquid-motion's chip_height (32) is
# tied to its 44px island; 20px is the demo's own number for this bar's
# workspace pills, and is reused for the bar's other small chips (cpu/ram/
# wifi/battery/control) so they all sit at the same height on the thinner
# 36px flush bar.
chip_height = 22
# No demo number for bar-icon size; scaled from liquid-motion's 24px by the
# same 36/44 bar-height ratio liquid-motion itself uses for its 44px bar.
icon_px = 18
# Flush edge-to-edge bar: a full border would draw a stray line along the
# top/side screen edges a floating island doesn't have to worry about. See
# Tokens::bar_border's doc comment.
bar_border = "bottom"
[bar.window]
anchors = ["top", "left", "right"]
width = "fill"
height = 36
margin = { top = 0, left = 0, right = 0 }
exclusive = "auto"
keyboard = "none"
layer = "top"
[bar.slots]
# No media module — demo 02 has no media widget at all (plan §1 table).
# breadbar must tolerate the omission: `media_widget` is still built and
# registered under the "media" module name (main.rs), it just never appears
# in any of this theme's slot lists, so it's never appended anywhere.
left = ["workspaces"]
centre = ["clock"]
right = ["cpu", "ram", "wifi", "battery", "control"]
drawer = []
[modules.workspaces]
style = "pill"
show_empty = true
[modules.clock]
style = "plain"
format = "%H:%M"
show_date = true
[launcher]
# Glass Workbench — dense and technical, the deliberate opposite of Liquid
# Motion's soft/roomy numbers: a tight panel radius, snug row insets/padding,
# small 22px icons, a flat (no section headers) list, and a denser accent
# selection fill. Spec: bos-ui-demos/proposed/glass-workbench.html's `.bx`
# block. breadbox reads every key below now; see main.rs's build_css.
mode = "overlay"
width = 560
top = "64px"
radius = 10
icon_px = 22
row_anim = "stagger"
rule = "hairline"
footer = "count_results"
sections = false
modes = ["apps"]
# `.bx .r { border-radius: 6px; margin: 0 6px; padding: 8px 10px }`
row_radius = 6
row_inset = 6
row_padding_v = 8
row_padding_h = 10
# `.bx .ico { border-radius: 5px }` (paired with icon_px = 22 above)
icon_radius = 5
# `.bx .q { padding: 12px 15px; font-size: 14px }`
search_font_size = 14
search_padding_v = 12
search_padding_h = 15
# `.bx .r.sel { background: rgba(accent, .28) }`
# Panel opacity, separate from tokens.bg_alpha (the bar). Matches the
# approved reference: demo `.bx.gw{background:rgba(16,20,18,.95)}`. breadbox hardcoded 0.60, which washed the
# panel out over a bright wallpaper.
panel_alpha = 0.95
selection_alpha = 0.28
# Same four satellite namespaces as liquid-motion, keyed identically so the
# two manifests validate against the same [compositor.*] keyspace. Offsets
# are liquid-motion's own numbers re-derived from this bar's actual edge
# instead of the island's: liquid-motion's bar bottom edge sits at
# margin.top(12) + height(44) = 56px, and its breadbar-notif/-panel top
# offset (64) is that plus an 8px gap, while breadbar-dismiss (56) sits
# flush against it with no gap. This bar's edge is margin.top(0) +
# height(36) = 36px, so the same two relationships give 44 (36 + 8) and 36.
[surfaces."breadbar-notif"]
anchor = "top_right"
offset = [16, 44]
width = 320
layer = "overlay"
[surfaces."breadbar-osd"]
# Independent of bar height (an OSD pill near the bottom of the screen) —
# unchanged from liquid-motion.
anchor = "bottom_centre"
offset = 80
width = 180
layer = "overlay"
[surfaces."breadbar-panel"]
anchor = "top_right"
offset = [16, 44]
width = "auto"
layer = "overlay"
[surfaces."breadbar-dismiss"]
anchor = "fill"
offset = 36
width = "fill"
layer = "overlay"
# Appearance-only, never placement/workspace/focus (plan §12 Layer B
# boundary) — identical to liquid-motion's rules. Blur *strength* is global
# (plan §9 known limit), so this theme cannot independently match the demo's
# per-surface blur radii (20px bar / 16px launcher / 22px popovers / 6px
# window borders); only on/off, ignore_alpha and slide direction are
# per-namespace knobs this system actually has.
[compositor."breadbar"]
blur = true
ignore_alpha = 0.2
blur_popups = true
animation = "slide top"
[compositor."breadbar-osd"]
blur = true
ignore_alpha = 0.2
animation = "slide bottom"
[compositor."breadbar-notif"]
blur = true
ignore_alpha = 0.2
animation = "slide right"
[compositor."breadbar-panel"]
blur = true
ignore_alpha = 0.2
animation = "slide right"
[compositor."breadbar-dismiss"]
no_anim = true
[compositor."breadbox"]
blur = true
ignore_alpha = 0.2
# No `css = "..."` overlay — compiled-in builtin, same as liquid-motion.

View file

@ -0,0 +1,104 @@
/* CSS template for the liquid-motion builtin (bread-theme/src/shell/builtin.rs).
*
* Curly-brace placeholders like {radius_bar} or {spring} are substituted
* from [tokens] by ShellTheme::css / Tokens::substitute; @-prefixed palette
* references (@accent, @on-bg, ...) pass through untouched, exactly like
* `bread_theme::stylesheet()` GTK's own @define-color mechanism resolves
* them when the CSS provider is attached to a display/output.
*
* Scope: this is the window/workspace/clock chrome the manifest's own
* concepts (bar.window, modules.workspaces, modules.clock) actually model
* not a byte-for-byte copy of breadbar/src/theme.rs::load_css's full ~250
* lines (notification cards, wifi popover, control panel, media widget, ...).
* Those stay hand-written in breadbar for now; migrating them behind this
* same token-substitution mechanism is Phase 2/3 work (plan §6), once
* breadbar actually consumes ShellTheme and can verify pixel parity itself
* via --screenshot. Phase 1's job is the mechanism, exercised end-to-end
* here with a representative slice, not the full port.
*/
@keyframes row-in {
from { opacity: 0; margin-top: 8px; }
to { opacity: 1; margin-top: 0; }
}
@keyframes digit-flip {
from { opacity: 0; margin-top: 7px; }
to { opacity: 1; margin-top: 0; }
}
window.breadbar {
background-color: alpha(@bg, {bg_alpha});
color: @on-bg;
border-radius: {radius_bar}px;
border: 1px solid alpha(@on-bg, 0.08);
}
window.breadbar > centerbox { padding: 0 8px 0 6px; }
window.breadbar button { min-height: 0; min-width: 0; }
.workspace-trail {
background-image: linear-gradient(90deg, @{accent_from}, @{accent_to});
background-color: @{accent_from};
border-radius: {radius_card}px;
}
.workspace-btn {
background: transparent;
opacity: 0.36;
color: @on-bg;
border-radius: {radius_card}px;
border: none;
outline: none;
box-shadow: none;
min-width: 28px;
min-height: {chip_height}px;
margin: 0;
padding: 0 7px;
font-size: 22px;
font-weight: bold;
transition: opacity 0.22s {spring_settle}, background-color 0.22s {spring_settle};
}
.workspace-btn:hover { opacity: 0.85; background: alpha(@on-bg, 0.08); }
.workspace-btn.occupied { opacity: 0.78; }
.workspace-btn.active { background: transparent; color: @on-accent; opacity: 1; }
.workspace-btn.active:hover { background: transparent; }
.workspace-btn.ws-in { animation: row-in 0.32s {spring_settle} both; }
.clock-box { padding: 0 4px; }
.clock-label {
font-size: 24px;
font-weight: bold;
letter-spacing: 0.04em;
min-height: 0;
padding: 0;
margin-top: 3px;
}
.clock-digit {
font-size: 24px;
font-weight: bold;
letter-spacing: 0.04em;
min-width: 15px;
min-height: 0;
padding: 0;
margin: 0;
}
.clock-colon { min-width: 10px; opacity: 0.7; }
.clock-digit.flip { animation: digit-flip 0.45s {spring} both; }
window.breadbar-osd {
background-color: alpha(@bg, 0.70);
color: @on-bg;
border-radius: {radius_pill}px;
border: 1px solid alpha(@on-bg, 0.10);
}
window.breadbar-panel {
background-color: alpha(@bg, {bg_alpha});
color: @on-bg;
border-radius: 14px;
border: 1px solid alpha(@on-bg, 0.12);
}
window.breadbar-dismiss {
background-color: alpha(#000000, 0.02);
}
.bread-widget-slot { margin-right: {pad}px; }

View file

@ -0,0 +1,204 @@
# The compiled-in builtin theme (bread-theme/src/shell/builtin.rs). The bar
# side (workspaces/clock/chips/satellites) describes breadbar AS IT EXISTS
# TODAY, not the 01-liquid-motion.html demo — where the two disagree (bar
# side margin, the two easing curves), this file follows the current Rust
# source, since Phase 2's acceptance test is pixel-identical rendering
# against today's bar. The `[launcher]` table is the one exception: it now
# follows bos-ui-demos/proposed/liquid-motion.html's `.bx` block (the
# launcher-core redesign's spec) rather than breadbox's old, pre-redesign
# hardcoded CSS — see that section's own note.
#
# Sources: breadbar/src/main.rs:16-22 (BAR_* / CHIP_HEIGHT / ICON_PX consts),
# breadbar/src/theme.rs (load_css's radius/pad/spring locals and the actual
# CSS selectors), breadbar/src/{panel,osd}.rs and
# breadbar/src/notifications/{popup,history}.rs (satellite window anchors,
# margins, namespaces), bos-ui-demos/proposed/liquid-motion.html (launcher),
# ~/.config/hypr/scripts/ui/rules.lua (compositor rules).
name = "Liquid Motion"
id = "liquid-motion"
[tokens]
# font_family/font_fallback are now consumed, but only by breadbox's launcher
# panel (breadbox::main::build_css's entry.search/row font-family) — see
# Tokens::font_family's doc comment for the exact scope. breadbar's bar/chip/
# clock text still comes from bread_theme::stylesheet()'s own hardcoded
# FONT_FAMILY constant, unaffected by this key. font_size_base is likewise
# now consumed for the launcher's row font-size only.
font_family = "Outfit, sans-serif"
font_fallback = "sans-serif"
font_size_base = 14
radius_bar = 16
radius_card = 12
radius_sm = 9
# Not in the plan's §4 schema list, but a named local in theme.rs::load_css
# alongside radius_bar/radius_card/radius_sm (`radius_pill = "999px"`) — the
# OSD pill's corner radius.
radius_pill = 999
pad = 12
bg_alpha = 0.72
# Two curves theme.rs actually uses, not one: `spring` is the overshoot/bounce
# curve (clock flips, pop-ins, the workspace caret draw); `spring_settle` is
# the flatter curve used for hovers and workspace/stat-pair transitions. The
# plan's §4 example names only `spring`.
spring = "cubic-bezier(0.22, 1.35, 0.36, 1)"
spring_settle = "cubic-bezier(0.22, 1.2, 0.36, 1)"
# "accent" flows through as @accent (the workspace-trail gradient's start);
# these are palette token NAMES, not hex - pywal still drives colour.
# Declared-but-not-yet-consumed (accent_to only): breadbar's Trail-style CSS
# hardcodes the literal gradient `linear-gradient(90deg, @accent, @teal)`
# instead of substituting these two tokens (it never calls
# ShellTheme::css(), the only thing that does the substitution) — this
# theme's "teal" is what the demo intends, not what actually renders if a
# future theme changed it. See Tokens::accent_to's doc comment.
accent_from = "accent"
accent_to = "teal"
# Not in the plan's §4 schema list, but breadbar::CHIP_HEIGHT / ::ICON_PX
# today.
chip_height = 26
icon_px = 24
[bar.window]
anchors = ["top", "left", "right"]
width = "fill"
height = 44
# No `bottom` set here (or by either sibling builtin) — it's accepted by the
# schema but breadbar never applies it (no set_margin(Edge::Bottom, ...)
# call exists). See Margin::bottom's doc comment before relying on it.
margin = { top = 12, left = 16, right = 16 }
exclusive = "auto"
# breadbar never calls gtk4-layer-shell's set_keyboard_mode today, which
# defaults to KeyboardMode::None — spelled out explicitly here rather than
# left to omit-means-default, since "the shell must never fail to start
# because a theme file is malformed" cuts both ways: an explicit builtin
# value can't silently drift if the crate's own Default ever changes.
keyboard = "none"
layer = "top"
[bar.slots]
# The `widget:*` entries reproduce breadbar's pre-Phase-3b fixed Lua-widget
# interleave exactly (main.rs's now-removed widget_right_of_workspaces /
# widget_left_of_clock / widget_right_of_clock / widget_left_of_stats), so
# this manifest still renders pixel-identically to today's bar. `tray`
# deliberately has NO slot entry anywhere — it lives in the control-panel
# popover, not the bar, and is keyed directly by breadbar regardless of
# `[bar.slots]`.
left = ["workspaces", "widget:right_of_workspaces"]
centre = ["media", "widget:left_of_clock", "clock", "widget:right_of_clock"]
right = ["widget:left_of_stats", "volume", "wifi", "battery", "control"]
drawer = []
[modules.workspaces]
style = "trail"
show_empty = true
[modules.clock]
style = "flip"
# Declared-but-not-yet-consumed under style = "flip": bar::clock::time()
# hardcodes a 24h HH:MM layout regardless of `format`; only style = "plain"
# ever calls formatted(&format). Set here to state the layout this clock
# actually draws, not because anything reads it. Same for show_date — only
# ever attached to the Plain style's box.
format = "%H:%M"
show_date = false
[launcher]
# Liquid Motion / Glass Workbench redesign (bos-ui-demos/proposed/
# liquid-motion.html's `.bx` block is the spec): soft and roomy — a big
# panel radius, generous row insets/padding, 28px icons, section headers,
# and a translucent (not solid) accent selection fill. breadbox reads every
# key below now; see main.rs's build_css for the consuming CSS.
mode = "overlay"
width = 600
top = "120px"
radius = 20
icon_px = 28
row_anim = "flip"
rule = "gradient"
footer = "count_apps"
sections = true
modes = ["apps"]
# `.bx .r { border-radius: 12px; margin: 0 8px; padding: 10px 12px }`
row_radius = 12
row_inset = 8
row_padding_v = 10
row_padding_h = 12
# `.bx .ico { border-radius: 9px }` (paired with icon_px = 28 above)
icon_radius = 9
# `.bx .q { padding: 16px 18px; font-size: 16px }`
search_font_size = 16
search_padding_v = 16
search_padding_h = 18
# `.bx .r.sel { background: rgba(accent, .22) }`
# Panel opacity, separate from tokens.bg_alpha (the bar). Matches the
# approved reference: demo `.bx.lm{background:rgba(21,14,23,.93)}`. breadbox hardcoded 0.60, which washed the
# panel out over a bright wallpaper.
panel_alpha = 0.93
selection_alpha = 0.22
# Keyed by layer-shell namespace, matching [compositor.*] below, so the two
# tables share one keyspace and can be validated against each other.
# breadbar-notif's popup toast uses set_default_width(320) (its history
# sibling on the same namespace uses 360 — 320 is the live-toast surface, the
# one this positions). breadbar-panel sets no window width at all: its
# popovers size from their own CSS (.control-panel-inner / .wifi-popover-inner
# min-width), which is why its width is "auto" rather than a number.
[surfaces."breadbar-notif"]
anchor = "top_right"
offset = [16, 64]
width = 320
layer = "overlay"
[surfaces."breadbar-osd"]
anchor = "bottom_centre"
offset = 80
width = 180
layer = "overlay"
[surfaces."breadbar-panel"]
anchor = "top_right"
offset = [16, 64]
width = "auto"
layer = "overlay"
[surfaces."breadbar-dismiss"]
# Anchored to all four edges (a fullscreen click-away scrim) with only a top
# margin, so it starts below the bar rather than covering it.
anchor = "fill"
offset = 56
width = "fill"
layer = "overlay"
# Faithful to scripts/ui/rules.lua's five breadbar namespaces + breadbox.
[compositor."breadbar"]
blur = true
ignore_alpha = 0.2
blur_popups = true
animation = "slide top"
[compositor."breadbar-osd"]
blur = true
ignore_alpha = 0.2
animation = "slide bottom"
[compositor."breadbar-notif"]
blur = true
ignore_alpha = 0.2
animation = "slide right"
[compositor."breadbar-panel"]
blur = true
ignore_alpha = 0.2
animation = "slide right"
[compositor."breadbar-dismiss"]
no_anim = true
[compositor."breadbox"]
blur = true
ignore_alpha = 0.2
# No `css = "..."` overlay: the builtin is compiled in via `include_str!`
# and has no on-disk directory to resolve a relative overlay path against.
# `extra.css` is for user/system themes, which do have one — see
# `resolve_theme` in mod.rs.

View file

@ -0,0 +1,73 @@
/* CSS template for the spotlight builtin (bread-theme/src/shell/builtin.rs).
* Same scope and substitution rules as liquid-motion.css/glass-workbench.css:
* only the window/workspace/clock chrome the manifest's own concepts model.
*
* NOTE (unlike its two siblings): breadbar does not actually call
* `ShellTheme::css()` today its live stylesheet is the hand-written,
* token-driven `breadbar::theme::load_css()`, which is where the capsule's
* real chrome (dots, `.launcher-entry`, `.bread-drawer`/results rows) lives.
* This file exists so the manifest's own template mechanism stays exercised
* for all three builtins equally (and so a future `bread-theme` CLI/test
* that DOES call `.css()` sees a real spotlight template) see
* `ShellTheme::css`'s own doc comment for why that gap between "what the
* manifest models" and "what breadbar actually renders" is pre-existing,
* not new to this file.
*
* Source: bos-ui-demos/04-spotlight.html's <style> block. No trail overlay
* (dots never call `WorkspaceTrail::place`/`stretch`, same as pill) and no
* per-digit flip (`style = "none"` no clock label markup at all).
*/
window.breadbar {
background-color: alpha(@bg, {bg_alpha});
color: @on-bg;
border-radius: {radius_bar}px;
border: 1px solid alpha(@on-bg, 0.08);
}
window.breadbar > box > centerbox { padding: 0 {pad}px; }
window.breadbar button { min-height: 0; min-width: 0; }
/* Dots: colour/opacity/radius only width is per-instance data
* (`modules.workspaces.dot_widths`), set directly via `set_size_request` in
* Rust (`bar::workspaces::make_dot_button`), since GTK CSS has no
* per-instance variable width the way the demo's `[data-n]` attribute
* selectors do.
*/
.workspace-dot {
background-color: alpha(@on-bg, 0.35);
border-radius: {radius_pill}px;
border: none;
outline: none;
box-shadow: none;
min-height: 6px;
margin: 0;
padding: 0;
transition: background-color 0.25s {spring_settle}, opacity 0.25s {spring_settle};
}
.workspace-dot:hover { background-color: alpha(@on-bg, 0.55); }
.workspace-dot:not(.occupied):not(.active) { opacity: 0.35; }
.workspace-dot.active {
background-color: @{accent_from};
opacity: 1;
}
.workspace-dot.active:hover { background-color: @{accent_from}; }
window.breadbar-osd {
background-color: alpha(@bg, 0.72);
color: @on-bg;
border-radius: {radius_pill}px;
border: 1px solid alpha(@on-bg, 0.10);
}
window.breadbar-panel {
background-color: alpha(@bg, 0.86);
color: @on-bg;
border-radius: {radius_card}px;
border: 1px solid alpha(@on-bg, 0.12);
}
window.breadbar-dismiss {
background-color: alpha(#000000, 0.02);
}
.bread-widget-slot { margin-right: {pad}px; }

View file

@ -0,0 +1,213 @@
# The third compiled-in builtin (bread-theme/src/shell/builtin.rs), demo 04
# in THEME_SYSTEM_PLAN.md — "Spotlight": breadbar's bar IS the launcher, a
# centred capsule that expands into results (plan §7). Values are taken from
# bos-ui-demos/04-spotlight.html's <style> block; where the demo is silent
# (satellite offsets, bar-adjacent chip sizing) this file re-derives from the
# capsule's own top edge the same way glass-workbench re-derives from its
# flush bar's edge — see that file's header comment for the method.
#
# Two exceptions, same shape as glass-workbench's: font (Outfit is not
# installed on the dev machine, so this renders in fallback until the user
# installs ttf-outfit — same gap liquid-motion already has) and colour (the
# demo's #e87898 pink is a *flat* accent — `--accent` doubling as both the
# capsule's dot fill and its only gradient stop — so it maps to the
# palette's `pink` token for both accent_from and accent_to, never a literal
# hex value).
#
# Phase 6c (this pass): query modes (`=` calc, `>` command, `.` url) and
# result sections ("recent"/"apps") are now implemented — `modes` lists all
# four and `sections` is `true`. The demo's `.searching .capsule` state
# (480px -> 520px width, 22px -> 20px radius) is modeled via two new
# `[launcher]` keys, `search_width`/`search_radius`, read only by
# `LauncherMode::Embedded` hosts (breadbar's capsule; unread by breadbox's
# overlay window, which stays `mode = "overlay"`).
name = "Spotlight"
id = "spotlight"
[tokens]
# Declared-but-not-yet-consumed: see liquid-motion/theme.toml's identical
# note next to these three keys — neither breadbar nor breadbox reads them.
font_family = "Outfit"
font_fallback = "Varela Round, sans-serif"
font_size_base = 13
radius_bar = 22
radius_card = 12
radius_sm = 8
radius_pill = 999
pad = 14
# Demo: `background: color-mix(in oklab, var(--glass) 82%, transparent)`.
bg_alpha = 0.82
# The demo's one curve (`--spring: cubic-bezier(.22,1.35,.36,1)`) — the same
# overshoot liquid-motion calls `spring`. No separate flatter "settle" curve
# is defined in 04's stylesheet, so both accessors point at it, same
# reasoning as glass-workbench's single-curve tokens.
spring = "cubic-bezier(0.22, 1.35, 0.36, 1)"
spring_settle = "cubic-bezier(0.22, 1.35, 0.36, 1)"
# Palette token NAMES, not hex — #e87898 is this palette's `pink`. Equal
# from/to because the demo's accent here is flat, not liquid-motion's
# accent→teal gradient. accent_from IS read (the dots' active fill,
# theme.rs); accent_to is declared-but-not-yet-consumed, same as every
# other theme's — see Tokens::accent_to's doc comment.
accent_from = "pink"
accent_to = "pink"
# Demo: `.barrow { height: 36px }` with no separate workspace-chip height —
# the dots (`.dots button`) are sized entirely by `dot_widths` below, not by
# chip_height, but this token still feeds any other chip-shaped element
# (e.g. a future result-row affordance) at the bar's own row height.
chip_height = 22
# Demo: `.ico { width: 30px; height: 30px }` for a result row's icon
# swatch — the icon glyph itself sits inside that box with implicit
# padding, so the icon_px passed to bread-launcher's `ResultsList` is sized
# a little under the swatch box, same margin glass-workbench leaves between
# its 18px icon_px and larger chip_height.
icon_px = 22
# Floating centred capsule, full rounded border on every edge — same
# reasoning as liquid-motion's island, not glass-workbench's flush bar.
bar_border = "full"
[bar.window]
# Anchored top ONLY: gtk4-layer-shell centres a surface anchored to
# neither left nor right (THEME_SYSTEM_PLAN.md §7) — this is what makes the
# capsule float centred instead of spanning the screen width.
anchors = ["top"]
width = 480
height = 36
margin = { top = 16 }
# No exclusive zone: tiled clients run full-height behind the capsule,
# unlike Island/Edge's reserved strip — the capsule floats over content.
exclusive = "none"
# Hands keyboard focus over only while `launcher_entry` holds it (plan §7)
# — Island/Edge themes keep "none", unchanged.
keyboard = "on_demand"
layer = "top"
[bar.slots]
left = ["workspaces"]
centre = ["launcher_entry"]
# `widget:left_of_stats` (Phase 6c decision, see task notes): a Lua widget
# that requests this same placement (`bread-shared`'s `WidgetPlacement::
# LeftOfStats`, e.g. a real user's `git-branch-widget.lua`) would otherwise
# be silently undeliverable under spotlight — no `[bar.slots]` entry named
# any `widget:*` key at all, so `reconcile_widgets` drops it every time
# (quietly, since the Phase 6b fix, but still dropped). Deliberately just
# this one alias, not all five liquid-motion carries: spotlight has no
# clock module to anchor `left_of_clock`/`right_of_clock` against and no
# workspace-adjacent affordance next to its compact dots, so adding those
# would clutter a launcher-focused capsule with slots nothing here asks
# for. `left_of_stats` sits right where `battery` already does, matching
# liquid-motion's own ordering (`widget:left_of_stats` before its module).
right = ["widget:left_of_stats", "battery"]
drawer = ["launcher_results"]
[modules.workspaces]
style = "dots"
show_empty = true
# 0/1/2/3-or-more open windows → dot width in px
# (`04-spotlight.html`'s `.dots button[data-n="N"]` rules).
dot_widths = [8, 13, 17, 22]
[modules.clock]
# No clock label at all — `launcher_entry`'s placeholder IS the clock
# (`04-spotlight.html`: `q.placeholder = t` in `clock()`, replaced by the
# search prompt only once focused/open).
style = "none"
# Declared-but-not-yet-consumed under style = "none": no clock module is
# built at all, so there's no format/date to apply this to — see
# ClockModule::format's doc comment. Kept for the same "state actual design
# intent" reason as liquid-motion's Flip-style note.
format = "%H:%M"
show_date = false
placeholder_clock = true
[launcher]
mode = "embedded"
# Unread by an embedded launcher (breadbar's launcher_entry/launcher_results
# read [bar.window].width and this table's radius/icon_px instead of
# width/top — those two fields exist only for LauncherMode::Overlay, which
# breadbox's own window still uses). Kept at the bar's own width/22px radius
# for consistency with a reader who expects [launcher] to describe the
# launcher's shape regardless of mode.
width = 480
top = "16px"
radius = 22
icon_px = 22
row_anim = "none"
rule = "none"
footer = "count_apps"
# Phase 6c: "recent" / "apps" headers in the idle (empty-query) drawer view
# — `bread_launcher::split_sections` groups the same already-loaded/sorted
# entries `LaunchHistory`'s counts already rank, no new tracking needed.
sections = true
# `04-spotlight.html`'s three prefixes: `=` calc, `>` command, `.` url —
# `bread_launcher::parse_query`/`eval_calc`/`filter_commands` implement the
# pure logic; breadbar's capsule wiring (main.rs) gates on this list so an
# unlisted prefix character just falls through to a literal "apps" query.
modes = ["apps", "calc", "cmd", "url"]
# `04-spotlight.html`: `.searching .capsule { width: 520px; border-radius:
# 20px }` vs the idle 480px/22px above — animated via
# `bread_theme::anim::spring_to` (width) and a `.searching` CSS class
# (radius) in breadbar's capsule wiring.
search_width = 520
search_radius = 20
# Same five satellite namespaces as liquid-motion/glass-workbench, keyed
# identically. This bar's edge sits at margin.top(16) + height(36) = 52px —
# re-deriving glass-workbench's own edge+8/edge relationship (44 = 36+8,
# 36 = 36) against 52 gives 60 (52+8) and 52.
[surfaces."breadbar-notif"]
anchor = "top_right"
offset = [16, 60]
width = 320
layer = "overlay"
[surfaces."breadbar-osd"]
# Independent of bar height — unchanged from liquid-motion/glass-workbench.
anchor = "bottom_centre"
offset = 80
width = 180
layer = "overlay"
[surfaces."breadbar-panel"]
anchor = "top_right"
offset = [16, 60]
width = "auto"
layer = "overlay"
[surfaces."breadbar-dismiss"]
anchor = "fill"
offset = 52
width = "fill"
layer = "overlay"
# Appearance-only, identical in shape to liquid-motion/glass-workbench's
# rules — blur strength is still global (plan §9 known limit).
[compositor."breadbar"]
blur = true
ignore_alpha = 0.18
blur_popups = true
animation = "slide top"
[compositor."breadbar-osd"]
blur = true
ignore_alpha = 0.2
animation = "slide bottom"
[compositor."breadbar-notif"]
blur = true
ignore_alpha = 0.2
animation = "slide right"
[compositor."breadbar-panel"]
blur = true
ignore_alpha = 0.2
animation = "slide right"
[compositor."breadbar-dismiss"]
no_anim = true
[compositor."breadbox"]
blur = true
ignore_alpha = 0.2
# No `css = "..."` overlay — compiled-in builtin, same as its two siblings.

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

142
bread-theme/src/anim.rs Normal file
View file

@ -0,0 +1,142 @@
//! `anim::spring_to` — THEME_SYSTEM_PLAN.md §7/§8: GTK4 has no CSS
//! width/height transition on a widget or a layer-shell surface (unlike the
//! demos' `transition: width .45s var(--spring)` /
//! `transition: max-height .4s var(--spring)`), so a size change that wants
//! to animate has to interpolate a plain integer over the frame clock
//! instead and re-apply it (`set_size_request`, `set_default_width`, ...)
//! every frame.
//!
//! This is the same house technique `breadbar::bar::workspaces::WorkspaceTrail`
//! already uses for the workspace trail's stretch/snap (a local, un-exported
//! `ease`/`ease_overshoot` pair driven by `add_tick_callback` and
//! `Instant::elapsed`) — lifted here, generalized to a plain `i32 -> i32`
//! interpolation with a caller-supplied frame callback, so theme 04's
//! capsule-drawer expand/collapse (and any future popover that wants the
//! same effect) doesn't have to reimplement it.
use gtk4::glib::ControlFlow;
use gtk4::prelude::*;
/// Approximates `cubic-bezier(0.22, 1.35, 0.36, 1)` — the overshoot/"spring"
/// curve every builtin theme's `tokens.spring` names (`Tokens::spring`'s own
/// default). Not a literal bezier solve (same approximation
/// `bar::workspaces::ease_overshoot` uses) — exact enough that the eye can't
/// tell it apart from the real curve at animation speeds.
fn spring_ease(t: f64) -> f64 {
let t = t.clamp(0.0, 1.0);
let c = 1.35;
let t1 = t - 1.0;
1.0 + t1 * t1 * ((c + 1.0) * t1 + c)
}
/// One frame's worth of `spring_to`'s interpolation math — split out from
/// the tick callback below purely so it has a name a unit test can call
/// directly instead of re-deriving the same arithmetic.
///
/// `spring_ease` is a backOut curve that legitimately overshoots past 1.0
/// (peaks ~1.065) partway through the run — fine for a growing animation
/// (`to` > `from`), but on a *shrink* (`from` > `to`, e.g. a drawer
/// collapsing to 0) that overshoot drives the raw interpolated value BELOW
/// `to`, which can go negative for a size request and trip GTK's
/// `height >= -1` assertion. Clamping here, once, protects every caller
/// automatically instead of relying on each call site to remember
/// `h.max(0)` — this already bit breadbar once (see
/// `breadbar::main::animate_drawer_height`'s own clamp, now
/// redundant-but-harmless defense in depth on top of this).
fn frame_value(from: i32, to: i32, t: f64) -> i32 {
let eased = spring_ease(t);
let value = from as f64 + (to - from) as f64 * eased;
let lo = from.min(to) as f64;
let hi = from.max(to) as f64;
value.clamp(lo, hi).round() as i32
}
/// Interpolates from `from` to `to` over `duration_ms`, calling `on_frame`
/// with each intermediate value (and, on the final tick, the exact `to` —
/// never an off-by-rounding near-miss) via `widget`'s frame clock.
///
/// Returns the [`gtk4::TickCallbackId`] so a caller that might need to
/// interrupt an in-flight run (e.g. the drawer re-opening before its close
/// animation finished) can `.remove()` it early; a run left to finish on its
/// own needs no cleanup — the callback self-terminates by returning
/// [`ControlFlow::Break`] once `duration_ms` has elapsed, same as
/// `WorkspaceTrail`'s own tick callbacks.
pub fn spring_to(
widget: &impl IsA<gtk4::Widget>,
from: i32,
to: i32,
duration_ms: f64,
on_frame: impl FnMut(i32) + 'static,
) -> gtk4::TickCallbackId {
let started = std::time::Instant::now();
// `add_tick_callback` requires `Fn`, not `FnMut` — the caller's frame
// closure almost always needs to mutate captured state (a widget's size
// request, an `Rc<Cell<..>>` flag), so it's boxed behind a `RefCell`
// here rather than pushing `Cell`/`RefCell` plumbing onto every call
// site (every current and future caller wants `FnMut`, none want `Fn`).
let on_frame = std::cell::RefCell::new(on_frame);
widget.add_tick_callback(move |_, _| {
let elapsed = started.elapsed().as_secs_f64() * 1000.0;
let mut on_frame = on_frame.borrow_mut();
if elapsed >= duration_ms {
on_frame(to);
return ControlFlow::Break;
}
on_frame(frame_value(from, to, elapsed / duration_ms));
ControlFlow::Continue
})
}
#[cfg(test)]
mod tests {
use super::*;
/// Samples [`frame_value`] — the exact function `spring_to`'s tick
/// callback calls every frame — across the full `t` timeline at fine
/// granularity, bypassing the real frame clock (which needs a running
/// main loop the test environment doesn't have).
fn sample_all_frames(from: i32, to: i32) -> Vec<i32> {
let steps = 2000;
(0..=steps)
.map(|i| frame_value(from, to, i as f64 / steps as f64))
.collect()
}
#[test]
fn spring_ease_overshoots_past_one_for_a_backout_curve() {
// Sanity check on the premise: without a clamp, a shrink would
// legitimately go negative around this point in the curve.
assert!(spring_ease(0.8) > 1.0, "expected overshoot past 1.0");
}
#[test]
fn shrink_never_emits_a_value_outside_the_from_to_range() {
// from > to (a drawer collapsing to 0) is exactly the case the
// unclamped overshoot could drive negative.
for value in sample_all_frames(480, 0) {
assert!(
(0..=480).contains(&value),
"shrink emitted {value}, outside [0, 480]"
);
}
}
#[test]
fn grow_never_emits_a_value_outside_the_from_to_range() {
for value in sample_all_frames(0, 480) {
assert!(
(0..=480).contains(&value),
"grow emitted {value}, outside [0, 480]"
);
}
}
#[test]
fn zero_span_never_clamps_outside_the_single_point() {
// from == to: lo == hi == that point for every t, including past
// the overshoot peak.
for value in sample_all_frames(120, 120) {
assert_eq!(value, 120);
}
}
}

View file

@ -9,6 +9,10 @@
//! # 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]
//! bread-theme layerrules # write the active theme's [compositor] table
//! # to ~/.config/hypr/layerrules.json (plan §9)
use std::process::ExitCode;
@ -25,6 +29,179 @@ fn write_and_report(verb: &str) -> ExitCode {
}
}
fn print_help_to(mut w: impl std::io::Write) {
let _ = write!(
&mut w,
"bread-theme — shared stylesheet generator\n\n\
USAGE:\n\
\x20 bread-theme [generate|reload|path|print|layerrules]\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\n\
layerrules write the active shell theme's [compositor] table to\n\
\x20 {} \n\
\x20 scripts/ui/rules.lua reads it for per-namespace blur/\n\
\x20 animation, falling back to its hardcoded rules if this\n\
\x20 is missing or malformed",
bread_theme::shared_css_path().display(),
bread_theme::layerrules_path().display()
);
}
/// Usage/help text. Explicitly requested help (`--help`/`-h`) goes to
/// **stdout** so it can be piped/grepped; the same text on an error path
/// (e.g. `generate-output` with no args) goes to stderr via
/// [`print_help_err`].
fn print_help() {
print_help_to(std::io::stdout());
}
fn print_help_err() {
print_help_to(std::io::stderr());
}
fn generate_output_cmd() -> ExitCode {
let args: Vec<String> = std::env::args().skip(2).collect();
if args.iter().any(|a| matches!(a.as_str(), "-h" | "--help" | "help")) {
print_help();
return ExitCode::SUCCESS;
}
if args.is_empty() {
// Missing arguments is an error, not a help request — usage goes to
// stderr.
print_help_err();
return ExitCode::FAILURE;
}
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 layerrules_cmd() -> ExitCode {
match bread_theme::write_layerrules_active() {
Ok(path) => {
eprintln!("bread-theme: wrote {}", path.display());
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("bread-theme: failed to write layer rules: {e}");
ExitCode::FAILURE
}
}
}
fn main() -> ExitCode {
let cmd = std::env::args().nth(1).unwrap_or_else(|| "generate".into());
match cmd.as_str() {
@ -42,20 +219,16 @@ 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(),
"layerrules" => layerrules_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|layerrules)"
);
ExitCode::FAILURE
}
}

View file

@ -1,8 +1,22 @@
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;
/// Per-widget app-CSS builder: given the resolved palette for the widget's
/// monitor, produce the app stylesheet to layer on top of the theme CSS.
type AppCssBuilder = Rc<dyn Fn(&Palette) -> String>;
/// 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 +28,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 +127,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<AppCssBuilder>,
/// 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<AppCssBuilder>,
) {
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<AppCssBuilder>) {
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<AppCssBuilder>) {
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

@ -0,0 +1,211 @@
//! Generates `~/.config/hypr/layerrules.json` from the active shell theme's
//! `[compositor]` table (`THEME_SYSTEM_PLAN.md` §9). `scripts/ui/rules.lua`
//! reads this file and emits `hl.layer_rule` calls from it, keeping its own
//! hardcoded rules as a pcall-guarded fallback for when this file is missing
//! or malformed — so generation here never has to be perfect, only present.
//!
//! Scope (plan §9's appearance/placement boundary): a theme's `[compositor]`
//! table owns per-namespace *appearance* only — blur, ignore_alpha,
//! blur_popups, animation, no_anim (the [`crate::shell::LayerRule`] field
//! set). It never owns placement, workspace-assignment, or focus rules —
//! those stay Lua-side user policy (`rules.lua`'s `hl.window_rule` calls)
//! that this generator does not touch.
use std::path::PathBuf;
use crate::shell::ShellTheme;
fn config_home() -> PathBuf {
// XDG spec: `XDG_CONFIG_HOME` is only honored when it's an *absolute*
// path; a relative value must be ignored (matches `bread_utils::xdg`).
if let Ok(v) = std::env::var("XDG_CONFIG_HOME") {
let p = PathBuf::from(&v);
if p.is_absolute() {
return p;
}
}
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".config")
}
/// `~/.config/hypr/layerrules.json` (or under `$XDG_CONFIG_HOME` if set) —
/// alongside `binds.json`, `settings.json`, `monitors.json`, and
/// `autostart.json`, the established flat-JSON-under-`hypr/` convention
/// those files already use (see `~/.config/hypr/scripts/input/binds.lua` for
/// the read side of that pattern, which `scripts/ui/rules.lua` now mirrors).
pub fn layerrules_path() -> PathBuf {
config_home().join("hypr").join("layerrules.json")
}
/// Render `theme`'s `[compositor]` table as the JSON object
/// `scripts/ui/rules.lua` expects: keyed by layer-shell namespace (e.g.
/// `"breadbar"`, `"breadbox"`), each value the namespace's
/// [`crate::shell::LayerRule`] fields. `compositor_rules()` returns a
/// `BTreeMap`, so namespace order is stable (alphabetical) across runs and a
/// rewritten file diffs cleanly.
pub fn layerrules_json(theme: &ShellTheme) -> String {
serde_json::to_string_pretty(theme.compositor_rules())
.expect("LayerRule serialization is infallible (no maps/floats that can fail)")
}
/// [`layerrules_json`] + atomic write (tmp + rename), the same durability
/// [`crate::write_shared_css_from`] uses so a reload can never observe a
/// half-written file.
pub fn write_layerrules(theme: &ShellTheme) -> std::io::Result<PathBuf> {
let path = layerrules_path();
let json = layerrules_json(theme);
crate::output::atomic_write(&path, &json)?;
Ok(path)
}
/// [`write_layerrules`] from the active theme ([`crate::shell::load`], which
/// never fails — a broken active theme falls back to the builtin). Used by
/// the `bread-theme layerrules` CLI subcommand.
pub fn write_layerrules_active() -> std::io::Result<PathBuf> {
write_layerrules(&crate::shell::load())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn lock_xdg() -> std::sync::MutexGuard<'static, ()> {
// Shared with `shell::tests::isolated_xdg`, which also mutates
// XDG_CONFIG_HOME — must be the *same* lock, not a look-alike one,
// or the two modules' parallel tests race each other's env var
// reads (see `crate::test_support`'s doc comment).
crate::test_support::XDG_CONFIG_HOME_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner())
}
fn with_config_home<T>(f: impl FnOnce(&Path) -> T) -> T {
let _lock = lock_xdg();
let dir = std::env::temp_dir().join(format!(
"bread-theme-layerrules-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_CONFIG_HOME").ok();
std::env::set_var("XDG_CONFIG_HOME", &dir);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&dir)));
match old {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
let _ = std::fs::remove_dir_all(&dir);
match result {
Ok(v) => v,
Err(e) => std::panic::resume_unwind(e),
}
}
#[test]
fn layerrules_path_sits_under_config_hypr() {
with_config_home(|dir| {
assert_eq!(layerrules_path(), dir.join("hypr").join("layerrules.json"));
});
}
/// True if any file under `dir` has a name containing `.tmp.` (a
/// leftover from `output::atomic_write`'s pid-suffixed temp files).
fn has_leftover_tmp(dir: &Path) -> bool {
fn walk(p: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(p) else {
return false;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if walk(&path) {
return true;
}
} else if path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.contains(".tmp."))
{
return true;
}
}
false
}
walk(dir)
}
/// `load_named("liquid-motion")` resolves through discovery (user dir,
/// then system dir, then the compiled-in builtin) — isolate
/// `XDG_CONFIG_HOME` to an empty dir so this can't pick up a real
/// `~/.config/bread/themes/liquid-motion/theme.toml` override and land
/// on a different `[compositor]` table than the builtin's.
fn builtin_theme() -> ShellTheme {
with_config_home(|_| crate::shell::load_named("liquid-motion").unwrap())
}
#[test]
fn layerrules_json_covers_all_six_builtin_namespaces() {
let theme = builtin_theme();
let json = layerrules_json(&theme);
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
let obj = value.as_object().expect("top-level object");
for ns in [
"breadbar",
"breadbar-osd",
"breadbar-notif",
"breadbar-panel",
"breadbar-dismiss",
"breadbox",
] {
assert!(obj.contains_key(ns), "missing namespace {ns} in JSON");
}
}
#[test]
fn layerrules_json_shape_matches_breadbar_rule() {
let theme = builtin_theme();
let json = layerrules_json(&theme);
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
let bar = &value["breadbar"];
assert_eq!(bar["blur"], true);
assert_eq!(bar["ignore_alpha"], 0.2);
assert_eq!(bar["blur_popups"], true);
assert_eq!(bar["animation"], "slide top");
// no_anim is a plain bool (not Option), so it's always present, even
// when false — unlike ignore_alpha/animation which are omitted.
assert_eq!(bar["no_anim"], false);
let dismiss = &value["breadbar-dismiss"];
assert_eq!(dismiss["no_anim"], true);
// ignore_alpha/animation are unset for breadbar-dismiss, so the
// skip_serializing_if omits them entirely rather than writing null.
assert!(dismiss.get("ignore_alpha").is_none());
assert!(dismiss.get("animation").is_none());
}
#[test]
fn write_layerrules_active_writes_atomically_and_is_reloadable() {
with_config_home(|dir| {
let path = write_layerrules_active().unwrap();
assert_eq!(path, dir.join("hypr").join("layerrules.json"));
assert!(path.is_file());
// No leftover .tmp file after the atomic rename.
assert!(!has_leftover_tmp(dir));
let contents = std::fs::read_to_string(&path).unwrap();
let value: serde_json::Value = serde_json::from_str(&contents).unwrap();
assert!(value.as_object().unwrap().contains_key("breadbox"));
// Rewriting (theme switch, pywal hook, etc.) must not fail or
// leave a stale temp file behind.
let path2 = write_layerrules_active().unwrap();
assert_eq!(path, path2);
assert!(!has_leftover_tmp(dir));
});
}
}

View file

@ -1,9 +1,35 @@
pub mod palette;
#[cfg(feature = "adw")]
pub mod adw;
#[cfg(feature = "gtk")]
pub mod anim;
#[cfg(feature = "gtk")]
pub mod gtk;
mod layerrules;
mod output;
pub mod palette;
pub mod shell;
pub use layerrules::{layerrules_json, layerrules_path, write_layerrules, write_layerrules_active};
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};
/// Env-var locks shared by any test module that mutates process-global
/// state (`std::env::set_var`) — `cargo test` runs a crate's tests in
/// parallel by default, so every module touching the *same* env var must
/// serialize through the *same* lock or their mutations race each other's
/// reads. `bread_theme::output`'s own `XDG_ENV_LOCK` guards `XDG_RUNTIME_DIR`
/// specifically and stays where it is; `XDG_CONFIG_HOME_LOCK` here is the
/// one shared by `shell::tests` and `layerrules::tests`, which both point
/// `XDG_CONFIG_HOME` at an isolated temp dir.
#[cfg(test)]
pub(crate) mod test_support {
pub(crate) static XDG_CONFIG_HOME_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
}
/// Design tokens from BREAD_DESIGN_SYSTEM.md.
pub mod tokens {
pub const FONT_FAMILY: &str = "Varela Round, sans-serif";
@ -24,6 +50,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 +72,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 +84,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,7 +99,11 @@ 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 (name, value) list: the single naming all bread apps share.
@ -128,7 +169,7 @@ pub fn css_tokens() -> String {
use tokens::*;
format!(
":root {{\n\
\x20\x20--font-family: '{font}';\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\
@ -141,9 +182,18 @@ pub fn css_tokens() -> String {
\x20\x20--radius-tertiary: {r3}px;\n\
\x20\x20--radius-pill: {pill}px;\n\
}}\n",
font = FONT_FAMILY, base = FONT_SIZE_BASE, sec = FONT_SIZE_SECONDARY,
xs = SPACE_XS, sm = SPACE_SM, md = SPACE_MD, lg = SPACE_LG, xl = SPACE_XL,
r1 = RADIUS_PRIMARY, r2 = RADIUS_SECONDARY, r3 = RADIUS_TERTIARY, pill = RADIUS_PILL,
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,
)
}
@ -157,16 +207,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\
@ -175,8 +236,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\
@ -187,7 +255,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\
@ -206,7 +290,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,
@ -225,28 +309,34 @@ 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())
}
let tmp = path.with_extension("css.tmp");
std::fs::write(&tmp, render())?;
std::fs::rename(&tmp, &path)?;
Ok(path)
/// `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();
// Longest name first, so `@on-bg` is replaced before `@bg` can match its tail.
pairs.sort_by_key(|(name, _)| std::cmp::Reverse(name.len()));
let mut out = css.to_string();
for (name, value) in pairs {
out = out.replace(&format!("@{name}"), &value);
}
out
}
/// Convert a `#rrggbb` hex colour to `rgba(r, g, b, alpha)`.
@ -265,15 +355,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"));
}
@ -286,8 +385,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}"
);
}
}
@ -298,7 +407,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));
}
@ -308,13 +426,28 @@ 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]
@ -326,7 +459,10 @@ mod tests {
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!(
gtk.contains(&format!("@define-color {name} ")),
"gtk missing {name}"
);
assert!(web.contains(&format!("--{name}: ")), "web missing {name}");
}
}
@ -343,7 +479,11 @@ mod tests {
#[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;"));
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;"));
@ -373,7 +513,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}"
);
}
}
@ -382,13 +525,60 @@ 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 p = Palette {
color4: "#7aa2f7".into(),
..Default::default()
};
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]

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

@ -0,0 +1,344 @@
//! 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 {
// XDG spec: `XDG_RUNTIME_DIR` is only honored when set to a non-empty,
// *absolute* path; a relative value must be ignored.
if let Ok(rt) = std::env::var("XDG_RUNTIME_DIR") {
let p = PathBuf::from(&rt);
if !rt.is_empty() && p.is_absolute() {
return p.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)))
}
pub(crate) fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
// Pad the temp name with the pid (matching `bread_utils::atomic`) so two
// concurrent writers for the same target can't race on one shared `.tmp`
// file — e.g. two `bread-theme generate-output` runs writing the same
// `themes/<output>.css` at once.
let tmp = match path.file_name().and_then(|n| n.to_str()) {
Some(name) => path.with_file_name(format!(".{name}.tmp.{}", std::process::id())),
None => path.with_extension(format!("tmp.{}", std::process::id())),
};
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::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 p = Palette {
color4: "#7aa2f7".into(),
background: "#ffffff".into(),
..Default::default()
};
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

@ -0,0 +1,113 @@
//! The compiled-in themes (plan §11 phase 1/5: "**One** built-in manifest
//! (`liquid-motion`) describing the bar as it exists today", extended in
//! Phase 5 with `glass-workbench`, demo 02). Every file here is plain data,
//! not Rust — each `theme.toml` is the manifest text a user override would
//! otherwise supply, and each `<id>.css` is the CSS template `ShellTheme::css`
//! substitutes tokens into (see that method's doc comment for why this
//! template is a representative subset of `breadbar::theme::load_css`'s
//! full stylesheet rather than a byte-for-byte copy of it).
//!
//! All are read with `include_str!` so a broken build can't ship without
//! them, and so [`super`] never touches the filesystem for a builtin — it
//! must work identically whether or not `$XDG_CONFIG_HOME` exists at all.
pub const LIQUID_MOTION_ID: &str = "liquid-motion";
const LIQUID_MOTION_TOML: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/shell/liquid-motion/theme.toml"
));
const LIQUID_MOTION_CSS: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/shell/liquid-motion/liquid-motion.css"
));
pub const GLASS_WORKBENCH_ID: &str = "glass-workbench";
const GLASS_WORKBENCH_TOML: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/shell/glass-workbench/theme.toml"
));
const GLASS_WORKBENCH_CSS: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/shell/glass-workbench/glass-workbench.css"
));
pub const SPOTLIGHT_ID: &str = "spotlight";
const SPOTLIGHT_TOML: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/shell/spotlight/theme.toml"
));
const SPOTLIGHT_CSS: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/shell/spotlight/spotlight.css"
));
pub const DAYLIGHT_ID: &str = "daylight";
const DAYLIGHT_TOML: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/shell/daylight/theme.toml"
));
const DAYLIGHT_CSS: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/shell/daylight/daylight.css"
));
/// One compiled-in theme's identity plus its two `include_str!`ed assets.
/// `id`/`name` are also duplicated inside `toml`'s own `id =`/`name =`
/// fields — kept here too so [`all`]/[`find`] can list/look up a builtin
/// without parsing TOML first (`super::list`'s builtin fallback entry, and
/// `super::find_source`'s existence check, both run before any manifest
/// parsing happens).
pub struct BuiltinTheme {
pub id: &'static str,
pub name: &'static str,
pub toml: &'static str,
pub css: &'static str,
}
/// Every compiled-in theme, in the order [`super::list`] should present
/// them. Adding a third builtin is one entry here plus its two asset files
/// — nothing else in `mod.rs` names a specific builtin id except the always-
/// -safe fallback ([`LIQUID_MOTION_ID`], deliberately still hardcoded at
/// its one call site in `super::resolve_builtin` — see that function's doc
/// comment for why that one reference must NOT become "whichever builtin is
/// listed first").
pub const ALL: &[BuiltinTheme] = &[
BuiltinTheme {
id: LIQUID_MOTION_ID,
name: "Liquid Motion",
toml: LIQUID_MOTION_TOML,
css: LIQUID_MOTION_CSS,
},
BuiltinTheme {
id: GLASS_WORKBENCH_ID,
name: "Glass Workbench",
toml: GLASS_WORKBENCH_TOML,
css: GLASS_WORKBENCH_CSS,
},
BuiltinTheme {
id: SPOTLIGHT_ID,
name: "Spotlight",
toml: SPOTLIGHT_TOML,
css: SPOTLIGHT_CSS,
},
BuiltinTheme {
id: DAYLIGHT_ID,
name: "Daylight",
toml: DAYLIGHT_TOML,
css: DAYLIGHT_CSS,
},
];
/// Looks up a compiled-in theme by id — `None` means "not a builtin",
/// exactly like a miss in the user/system theme directories.
pub fn find(id: &str) -> Option<&'static BuiltinTheme> {
ALL.iter().find(|t| t.id == id)
}

View file

@ -0,0 +1,318 @@
//! `watch()` — only compiled under the `gtk` feature, since
//! `gio::FileMonitor` is a gtk4 dependency and the rest of `shell` is
//! deliberately gtk-free (`bread`/`breadcrumbs` link this crate without the
//! `gtk` feature at all).
use gtk4::gio;
use gtk4::prelude::*;
use std::cell::RefCell;
use std::path::PathBuf;
use std::rc::Rc;
/// The handle [`watch`] returns. Keep it alive — dropping it disarms both
/// the config-file watch and whichever theme directory is currently armed;
/// there is nothing else to call on it.
///
/// Two independent [`gio::FileMonitor`]s live behind this, not one:
/// - a fixed watch on `~/.config/bread/` (or `$XDG_CONFIG_HOME/bread/`)
/// for `shell.toml` itself, so a change to the *active* theme id is
/// noticed at all;
/// - a swappable watch on whichever theme directory is currently active,
/// re-armed onto the new directory whenever the config watch observes
/// `active` changing (see [`watch`]'s doc comment for what this does and
/// does not cover).
pub struct ThemeWatch {
_config_monitor: Option<gio::FileMonitor>,
// `Rc<RefCell<..>>` (rather than a plain field) because the config
// watch's own callback needs to replace this monitor in place when the
// active theme changes, while this struct is what keeps it alive for
// the caller.
_theme_monitor: Rc<RefCell<Option<gio::FileMonitor>>>,
}
/// Best-effort: `monitor_directory` on `dir` can fail (e.g.
/// `fs.inotify.max_user_watches` exhausted) — that must never be fatal, per
/// this crate's "the shell must never fail to start because a theme file is
/// malformed" stance (`shell` module doc). Logs once per call site and
/// returns `None` rather than panicking; the caller simply runs without a
/// live watch for whatever this was meant to cover, same as
/// `bread_theme::gtk::watch_theme_file`'s existing `.ok()?` stance for the
/// shared-stylesheet watch.
fn monitor_dir(dir: &std::path::Path, what: &str) -> Option<gio::FileMonitor> {
let _ = std::fs::create_dir_all(dir);
match gio::File::for_path(dir).monitor_directory(gio::FileMonitorFlags::WATCH_MOVES, gio::Cancellable::NONE) {
Ok(m) => Some(m),
Err(err) => {
tracing::warn!(
"bread-theme: could not create a file monitor for {what} ({}); \
continuing without live reload for it",
err
);
None
}
}
}
fn theme_dir(id: &str) -> PathBuf {
super::user_theme_path(id)
.parent()
.expect("user_theme_path always has a parent")
.to_path_buf()
}
/// (Re-)arms `theme_monitor` on `id`'s own directory, replacing whatever was
/// armed before (dropping the old [`gio::FileMonitor`] disarms it). Returns
/// the id actually armed, so the caller can remember it for the "did the
/// active id change" comparison in [`watch`]'s config-watch callback.
fn arm_theme_watch(theme_monitor: &Rc<RefCell<Option<gio::FileMonitor>>>, id: &str, f: &Rc<dyn Fn(super::ShellTheme)>) {
let dir = theme_dir(id);
let monitor = monitor_dir(&dir, &format!("theme '{id}'s directory ({})", dir.display()));
if let Some(m) = &monitor {
let f = f.clone();
m.connect_changed(move |_, _file, _other, _event| {
f(super::load());
});
}
*theme_monitor.borrow_mut() = monitor;
}
/// Fires `f` with a freshly-[`super::load`]ed [`super::ShellTheme`] whenever
/// the active theme's own directory changes on disk, AND re-arms itself onto
/// a new theme's directory when `~/.config/bread/shell.toml`'s `active` key
/// changes — so switching themes while a host is already running (e.g.
/// bos-settings rewriting `active =`) picks up live edits to the *new*
/// theme without a restart, not just edits to whichever theme happened to
/// be active when `watch()` was first called.
///
/// Watches directories, not the files themselves, for the same reason
/// `bread_theme::gtk::watch_theme_file` does (see that function's doc
/// comment): an editor or `bread-theme` doing an atomic write-tmp-then-
/// rename replaces the inode, and a monitor on the file itself dies after
/// the first replace (inotify reports `DELETE_SELF` and never re-arms).
///
/// # What this does NOT cover
///
/// `$BREAD_SHELL_THEME` is a **single-process override for testing**, not
/// the primary theme selector (`shell.toml`'s `active` key is — see the
/// `shell` module doc's "Discovery" section) — precisely because an env var
/// set for one process is invisible to any other, it cannot coordinate two
/// already-running hosts (breadbar/breadbox) onto the same theme, and this
/// watch cannot observe it changing either: it's read once per call to
/// [`super::active_theme_id`] (i.e. once per `load()`/re-arm here), and
/// there is no filesystem event to watch for a process's own environment
/// changing out from under it. A host that wants to pick up a new
/// `$BREAD_SHELL_THEME` value has to restart, same as before this fix.
pub fn watch<F: Fn(super::ShellTheme) + 'static>(f: F) -> ThemeWatch {
let f: Rc<dyn Fn(super::ShellTheme)> = Rc::new(f);
let watched_id = Rc::new(RefCell::new(super::active_theme_id()));
let theme_monitor: Rc<RefCell<Option<gio::FileMonitor>>> = Rc::new(RefCell::new(None));
arm_theme_watch(&theme_monitor, &watched_id.borrow(), &f);
let config_dir = super::config_home().join("bread");
let config_monitor = monitor_dir(
&config_dir,
&format!("the shell config directory ({})", config_dir.display()),
);
if let Some(cm) = &config_monitor {
let theme_monitor = theme_monitor.clone();
let watched_id = watched_id.clone();
let f = f.clone();
cm.connect_changed(move |_, file, other, _event| {
// Only `shell.toml` changing can move `active` — ignore any
// other file (e.g. a theme directory that happens to also live
// under this same parent) so this doesn't re-check on every
// unrelated write in ~/.config/bread/.
let is_shell_toml = |f: &gio::File| {
f.basename().and_then(|b| b.to_str().map(str::to_string)) == Some("shell.toml".to_string())
};
if !is_shell_toml(file) && !other.is_some_and(is_shell_toml) {
return;
}
let new_id = super::active_theme_id();
if new_id == *watched_id.borrow() {
// shell.toml changed but `active` didn't (or moved to the
// same value) — the already-armed theme watch still covers
// whatever changed.
return;
}
*watched_id.borrow_mut() = new_id.clone();
arm_theme_watch(&theme_monitor, &new_id, &f);
f(super::load());
});
}
ThemeWatch {
_config_monitor: config_monitor,
_theme_monitor: theme_monitor,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
/// Isolated `$XDG_CONFIG_HOME`, mirroring `shell::tests::isolated_xdg`
/// (can't reuse it directly — it's private to the parent module's own
/// `tests` submodule — but shares the same lock so the two test files
/// never race the same env vars).
struct EnvGuard {
_lock: std::sync::MutexGuard<'static, ()>,
dir: PathBuf,
old_xdg: Option<String>,
old_theme_var: Option<String>,
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.old_xdg {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
match &self.old_theme_var {
Some(v) => std::env::set_var("BREAD_SHELL_THEME", v),
None => std::env::remove_var("BREAD_SHELL_THEME"),
}
let _ = std::fs::remove_dir_all(&self.dir);
}
}
fn isolated_xdg() -> EnvGuard {
let lock = crate::test_support::XDG_CONFIG_HOME_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join(format!(
"bread-theme-hotreload-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_xdg = std::env::var("XDG_CONFIG_HOME").ok();
let old_theme_var = std::env::var("BREAD_SHELL_THEME").ok();
std::env::set_var("XDG_CONFIG_HOME", &dir);
std::env::remove_var("BREAD_SHELL_THEME");
EnvGuard {
_lock: lock,
dir,
old_xdg,
old_theme_var,
}
}
fn write_theme(xdg: &EnvGuard, id: &str, toml_body: &str) {
let dir = xdg.dir.join("bread/themes").join(id);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("theme.toml"), toml_body).unwrap();
}
fn write_shell_toml(xdg: &EnvGuard, active: &str) {
let dir = xdg.dir.join("bread");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("shell.toml"), format!("active = \"{active}\"\n")).unwrap();
}
/// Pumps the default `glib::MainContext` (where `gio::FileMonitor`
/// dispatches its `connect_changed` signal) until `done()` returns
/// true or `timeout` elapses. Real inotify events go through the OS,
/// so this polls rather than blocking on a single iteration.
fn pump_until(timeout: std::time::Duration, mut done: impl FnMut() -> bool) {
let start = std::time::Instant::now();
let ctx = gtk4::glib::MainContext::default();
while !done() && start.elapsed() < timeout {
while ctx.iteration(false) {}
std::thread::sleep(std::time::Duration::from_millis(20));
}
}
#[test]
fn watch_arms_a_monitor_for_the_currently_active_theme() {
let xdg = isolated_xdg();
write_theme(&xdg, "liquid-motion", "id = \"liquid-motion\"\n");
let watch = watch(|_| {});
assert!(
watch._theme_monitor.borrow().is_some(),
"watch() should arm a monitor for the active theme's directory"
);
assert!(
watch._config_monitor.is_some(),
"watch() should arm a monitor for the shell config directory too"
);
}
#[test]
fn editing_the_active_theme_directory_fires_the_callback() {
let xdg = isolated_xdg();
write_theme(&xdg, "custom", "id = \"custom\"\n[tokens]\npad = 1\n");
write_shell_toml(&xdg, "custom");
let calls = Rc::new(RefCell::new(0));
let calls2 = calls.clone();
let _watch = watch(move |_| *calls2.borrow_mut() += 1);
write_theme(&xdg, "custom", "id = \"custom\"\n[tokens]\npad = 2\n");
pump_until(std::time::Duration::from_secs(3), || *calls.borrow() > 0);
assert!(
*calls.borrow() > 0,
"editing the active theme's own directory should fire the watch callback"
);
}
#[test]
fn switching_the_active_theme_re_arms_onto_the_new_directory() {
// This is the coordination gap the fix closes: before it, `watch()`
// resolved the active theme's directory once and pinned a monitor
// there forever, so a `shell.toml` `active` switch left the OLD
// theme's edits observed and the NEW theme's edits invisible until
// a restart.
let xdg = isolated_xdg();
write_theme(&xdg, "theme-a", "id = \"theme-a\"\n[tokens]\npad = 1\n");
write_theme(&xdg, "theme-b", "id = \"theme-b\"\n[tokens]\npad = 1\n");
write_shell_toml(&xdg, "theme-a");
let seen_ids = Rc::new(RefCell::new(Vec::<String>::new()));
let seen_ids2 = seen_ids.clone();
let _watch = watch(move |theme| seen_ids2.borrow_mut().push(theme.id().to_string()));
// Switch active to theme-b.
write_shell_toml(&xdg, "theme-b");
pump_until(std::time::Duration::from_secs(3), || {
seen_ids.borrow().iter().any(|id| id == "theme-b")
});
assert!(
seen_ids.borrow().iter().any(|id| id == "theme-b"),
"switching shell.toml's active id should fire the callback with the new theme: {:?}",
seen_ids.borrow()
);
seen_ids.borrow_mut().clear();
// Now edit theme-b's own directory — the re-armed watch must see it.
write_theme(&xdg, "theme-b", "id = \"theme-b\"\n[tokens]\npad = 2\n");
pump_until(std::time::Duration::from_secs(3), || !seen_ids.borrow().is_empty());
assert!(
!seen_ids.borrow().is_empty(),
"after switching, editing the NEW active theme's directory must fire the callback"
);
}
#[test]
fn monitor_dir_degrades_gracefully_instead_of_panicking() {
// A directory that cannot possibly be created (its parent is a
// plain file, not a directory) — `create_dir_all` fails, and this
// must not panic either way `monitor_directory` then behaves.
let xdg = isolated_xdg();
let blocker = xdg.dir.join("not-a-directory");
std::fs::write(&blocker, b"x").unwrap();
let impossible = blocker.join("child").join("grandchild");
// Must not panic; may or may not return a monitor depending on
// glib's own behavior for a directory that doesn't exist yet, but
// this call itself is infallible from the caller's point of view.
let _ = monitor_dir(&impossible, "test");
}
}

View file

@ -0,0 +1,601 @@
//! `theme.toml` deserialization, validation, and resolution into
//! [`crate::shell::ShellTheme`].
//!
//! Two layers on purpose: `Raw*` types mirror the TOML shape exactly (every
//! field optional, `deny_unknown_fields` everywhere so a typo'd key is a
//! hard error naming that key rather than a silent no-op) and know nothing
//! about defaults; [`RawManifest::resolve`] is the one place defaults get
//! filled and string enums get validated, producing the fully-resolved
//! types in `types.rs`.
use anyhow::{anyhow, bail, Context};
use std::collections::{BTreeMap, HashMap};
use super::types::*;
/// Module names a slot entry may reference without recompiling anything —
/// plan §2 tier 1/2 (declarative slots) plus the `widget:*` escape hatch
/// (tier 3, validated separately since its suffix is open-ended). This is
/// intentionally the set the *current* theme and the plan's own schema
/// example use; Phase 3 (breadbar's module registry) is the place a new
/// built-in module name gets added for real.
const KNOWN_MODULES: &[&str] = &[
"workspaces",
"media",
"clock",
"volume",
"wifi",
"battery",
"control",
"launcher_entry",
"launcher_results",
// `02-glass-workbench` (plan §11 phase 5): plain right-side stat chips,
// reusing the same `AppInput::StatsUpdate` data the control panel's
// sys-grid already receives — see breadbar's `bar::slots` module docs.
"cpu",
"ram",
];
pub(super) fn validate_module_name(theme_id: &str, slot: &str, module: &str) -> anyhow::Result<()> {
if module.starts_with("widget:") || KNOWN_MODULES.contains(&module) {
return Ok(());
}
bail!(
"theme '{theme_id}': slot \"{slot}\" references unknown module \"{module}\" \
(known modules: {}, or widget:<lua-module-name>)",
KNOWN_MODULES.join(", ")
);
}
pub(super) fn validate_slots(raw: &RawManifest, theme_id: &str) -> anyhow::Result<()> {
let Some(bar) = &raw.bar else { return Ok(()) };
let Some(slots) = &bar.slots else {
return Ok(());
};
for (slot_name, list) in [
("left", &slots.left),
("centre", &slots.centre),
("right", &slots.right),
("drawer", &slots.drawer),
] {
for module in list {
validate_module_name(theme_id, slot_name, module)?;
}
}
Ok(())
}
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct RawManifest {
pub(super) name: Option<String>,
pub(super) id: Option<String>,
/// Present only so `extends` deserializes as a *known* field (otherwise
/// `deny_unknown_fields` would reject every theme that sets it). The
/// value itself is read straight off the raw `toml::Value` in
/// `mod.rs::resolve_theme` — before this struct exists — since the
/// merge has to happen ahead of (and separately from) deserialization.
#[allow(dead_code)]
pub(super) extends: Option<String>,
#[serde(default)]
pub(super) tokens: HashMap<String, toml::Value>,
pub(super) bar: Option<RawBar>,
pub(super) modules: Option<RawModules>,
pub(super) launcher: Option<RawLauncher>,
pub(super) surfaces: Option<HashMap<String, RawSurface>>,
pub(super) compositor: Option<HashMap<String, RawLayerRule>>,
/// Overlay CSS path, resolved relative to the theme file's own
/// directory, appended last by `ShellTheme::css`. Declared-but-not-yet-
/// consumed in production: `ShellTheme::css` (the only reader of this
/// field's resolved `extra_css`) is not called by breadbar or breadbox
/// today — see that method's doc comment. Setting this key is currently
/// a no-op for a real running shell (it IS exercised by this crate's own
/// tests). (Schema note: plan §4
/// shows `css = "extra.css"` textually after the `[compositor]` table
/// with no table header of its own between them, which in real TOML
/// would nest it *inside* `[compositor]`. Treated here as a top-level
/// field per §5's `css()` doc — see this crate's implementation notes.)
pub(super) css: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct RawBar {
pub(super) window: Option<RawWindow>,
pub(super) slots: Option<RawSlots>,
}
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct RawWindow {
pub(super) anchors: Option<Vec<String>>,
pub(super) width: Option<RawSize>,
pub(super) height: Option<i64>,
pub(super) margin: Option<RawMargin>,
pub(super) exclusive: Option<RawExclusive>,
pub(super) keyboard: Option<String>,
pub(super) layer: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
#[serde(untagged)]
pub(super) enum RawSize {
Named(String),
Px(i64),
}
#[derive(Debug, serde::Deserialize)]
#[serde(untagged)]
pub(super) enum RawExclusive {
Named(String),
Px(i64),
}
#[derive(Debug, Default, serde::Deserialize)]
#[serde(deny_unknown_fields, default)]
pub(super) struct RawMargin {
pub(super) top: i64,
pub(super) left: i64,
pub(super) right: i64,
/// See [`super::types::Margin::bottom`] — accepted and resolved, but
/// breadbar never applies it.
pub(super) bottom: i64,
}
#[derive(Debug, Default, serde::Deserialize)]
#[serde(deny_unknown_fields, default)]
pub(super) struct RawSlots {
pub(super) left: Vec<String>,
pub(super) centre: Vec<String>,
pub(super) right: Vec<String>,
pub(super) drawer: Vec<String>,
}
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct RawModules {
pub(super) workspaces: Option<RawWorkspacesModule>,
pub(super) clock: Option<RawClockModule>,
}
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct RawWorkspacesModule {
pub(super) style: Option<String>,
pub(super) show_empty: Option<bool>,
/// `[6, 10, 14, 18]`-shaped — see [`DotWidths`]. A length other than 4
/// is a hard error (validated in [`resolve_modules`]) rather than
/// silently truncated/padded, matching this file's "typo'd key is a
/// hard error" policy for enum-ish values.
pub(super) dot_widths: Option<Vec<i64>>,
}
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct RawClockModule {
pub(super) style: Option<String>,
pub(super) format: Option<String>,
pub(super) show_date: Option<bool>,
pub(super) placeholder_clock: Option<bool>,
}
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct RawLauncher {
pub(super) mode: Option<String>,
pub(super) width: Option<i64>,
pub(super) top: Option<String>,
pub(super) radius: Option<i64>,
pub(super) icon_px: Option<i64>,
pub(super) row_anim: Option<String>,
pub(super) rule: Option<String>,
pub(super) footer: Option<String>,
pub(super) sections: Option<bool>,
pub(super) modes: Option<Vec<String>>,
pub(super) search_width: Option<i64>,
pub(super) search_radius: Option<i64>,
pub(super) row_radius: Option<i64>,
pub(super) row_inset: Option<i64>,
pub(super) row_padding_v: Option<i64>,
pub(super) row_padding_h: Option<i64>,
pub(super) icon_radius: Option<i64>,
pub(super) search_font_size: Option<i64>,
pub(super) search_padding_v: Option<i64>,
pub(super) search_padding_h: Option<i64>,
pub(super) panel_alpha: Option<f64>,
pub(super) selection_alpha: Option<f64>,
}
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct RawSurface {
pub(super) anchor: Option<String>,
pub(super) offset: Option<RawOffset>,
pub(super) width: Option<RawSurfaceWidth>,
pub(super) layer: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
#[serde(untagged)]
pub(super) enum RawOffset {
Single(f64),
Pair([f64; 2]),
}
#[derive(Debug, serde::Deserialize)]
#[serde(untagged)]
pub(super) enum RawSurfaceWidth {
Named(String),
Px(i64),
}
#[derive(Debug, Default, serde::Deserialize)]
#[serde(deny_unknown_fields, default)]
pub(super) struct RawLayerRule {
pub(super) blur: Option<bool>,
pub(super) ignore_alpha: Option<f64>,
pub(super) blur_popups: Option<bool>,
pub(super) animation: Option<String>,
pub(super) no_anim: Option<bool>,
}
fn token_value(v: &toml::Value) -> anyhow::Result<TokenValue> {
match v {
toml::Value::String(s) => Ok(TokenValue::Str(s.clone())),
toml::Value::Integer(i) => Ok(TokenValue::Int(*i)),
toml::Value::Float(f) => Ok(TokenValue::Float(*f)),
toml::Value::Boolean(b) => Ok(TokenValue::Bool(*b)),
other => Err(anyhow!("must be a string, number, or bool, got {other:?}")),
}
}
fn resolve_window(theme_id: &str, w: &RawWindow) -> anyhow::Result<WindowSpec> {
let default = WindowSpec::default();
let anchors = match &w.anchors {
Some(list) => {
for a in list {
if !matches!(a.as_str(), "top" | "bottom" | "left" | "right") {
bail!(
"theme '{theme_id}': bar.window.anchors contains unknown anchor \"{a}\" \
(expected top|bottom|left|right)"
);
}
}
list.clone()
}
None => default.anchors,
};
let width = match &w.width {
Some(RawSize::Named(s)) if s == "fill" => Width::Fill,
Some(RawSize::Named(other)) => bail!(
"theme '{theme_id}': bar.window.width = \"{other}\" is not \"fill\" \
(use a bare number for a fixed width)"
),
Some(RawSize::Px(n)) => Width::Px(*n as i32),
None => default.width,
};
let height = w.height.map(|h| h as i32).unwrap_or(default.height);
let margin = w
.margin
.as_ref()
.map(|m| Margin {
top: m.top as i32,
left: m.left as i32,
right: m.right as i32,
bottom: m.bottom as i32,
})
.unwrap_or(default.margin);
let exclusive = match &w.exclusive {
Some(RawExclusive::Named(s)) if s == "auto" => Exclusive::Auto,
Some(RawExclusive::Named(s)) if s == "none" => Exclusive::None,
Some(RawExclusive::Named(other)) => bail!(
"theme '{theme_id}': bar.window.exclusive = \"{other}\" is not \"auto\" or \"none\" \
(use a bare number for a fixed exclusive zone)"
),
Some(RawExclusive::Px(n)) => Exclusive::Px(*n as i32),
None => default.exclusive,
};
let keyboard = match w.keyboard.as_deref() {
None => default.keyboard,
Some("none") => Keyboard::None,
Some("on_demand") => Keyboard::OnDemand,
Some("exclusive") => Keyboard::Exclusive,
Some(other) => bail!(
"theme '{theme_id}': bar.window.keyboard = \"{other}\" is not none|on_demand|exclusive"
),
};
let layer = match w.layer.as_deref() {
None => default.layer,
Some("top") => "top".to_string(),
Some("overlay") => "overlay".to_string(),
Some(other) => {
bail!("theme '{theme_id}': bar.window.layer = \"{other}\" is not top|overlay")
}
};
Ok(WindowSpec {
anchors,
width,
height,
margin,
exclusive,
keyboard,
layer,
})
}
fn resolve_modules(theme_id: &str, m: Option<&RawModules>) -> anyhow::Result<Modules> {
let ws = m.and_then(|m| m.workspaces.as_ref());
let style = match ws.and_then(|w| w.style.as_deref()) {
None => WorkspaceStyle::Trail,
Some("trail") => WorkspaceStyle::Trail,
Some("pill") => WorkspaceStyle::Pill,
Some("dots") => WorkspaceStyle::Dots,
Some(other) => bail!(
"theme '{theme_id}': modules.workspaces.style = \"{other}\" is not trail|pill|dots"
),
};
let show_empty = ws.and_then(|w| w.show_empty).unwrap_or(true);
let dot_widths = match ws.and_then(|w| w.dot_widths.as_ref()) {
None => DEFAULT_DOT_WIDTHS,
Some(v) if v.len() == 4 => [v[0] as i32, v[1] as i32, v[2] as i32, v[3] as i32],
Some(v) => bail!(
"theme '{theme_id}': modules.workspaces.dot_widths has {} entries, expected 4 \
(0/1/2/3-or-more open windows)",
v.len()
),
};
let ck = m.and_then(|m| m.clock.as_ref());
let cstyle = match ck.and_then(|c| c.style.as_deref()) {
None => ClockStyle::Flip,
Some("flip") => ClockStyle::Flip,
Some("plain") => ClockStyle::Plain,
Some("none") => ClockStyle::None,
Some(other) => {
bail!("theme '{theme_id}': modules.clock.style = \"{other}\" is not flip|plain|none")
}
};
let format = ck
.and_then(|c| c.format.clone())
.unwrap_or_else(|| "%H:%M".to_string());
let show_date = ck.and_then(|c| c.show_date).unwrap_or(false);
let placeholder_clock = ck.and_then(|c| c.placeholder_clock).unwrap_or(false);
Ok(Modules {
workspaces: WorkspacesModule {
style,
show_empty,
dot_widths,
},
clock: ClockModule {
style: cstyle,
format,
show_date,
placeholder_clock,
},
})
}
fn resolve_launcher(theme_id: &str, l: Option<&RawLauncher>) -> anyhow::Result<Launcher> {
let mode = match l.and_then(|l| l.mode.as_deref()) {
None => LauncherMode::Overlay,
Some("overlay") => LauncherMode::Overlay,
Some("embedded") => LauncherMode::Embedded,
Some(other) => {
bail!("theme '{theme_id}': launcher.mode = \"{other}\" is not overlay|embedded")
}
};
let width = l.and_then(|l| l.width).unwrap_or(540) as i32;
let radius = l.and_then(|l| l.radius).unwrap_or(20) as i32;
Ok(Launcher {
mode,
width,
top: l
.and_then(|l| l.top.clone())
.unwrap_or_else(|| "16%".to_string()),
radius,
icon_px: l.and_then(|l| l.icon_px).unwrap_or(36) as i32,
row_anim: l
.and_then(|l| l.row_anim.clone())
.unwrap_or_else(|| "flip".to_string()),
rule: l
.and_then(|l| l.rule.clone())
.unwrap_or_else(|| "gradient".to_string()),
footer: l
.and_then(|l| l.footer.clone())
.unwrap_or_else(|| "count_apps".to_string()),
sections: l.and_then(|l| l.sections).unwrap_or(false),
modes: l
.and_then(|l| l.modes.clone())
.unwrap_or_else(|| vec!["apps".to_string()]),
// Default to the idle value — a theme that never sets these gets
// "no change while searching", not a hardcoded widen/shrink it
// never asked for (see the field docs on `Launcher`).
search_width: l.and_then(|l| l.search_width).map(|v| v as i32).unwrap_or(width),
search_radius: l.and_then(|l| l.search_radius).map(|v| v as i32).unwrap_or(radius),
// Defaults below reproduce breadbox's pre-redesign hardcoded CSS
// literals (`row { padding: 8px 12px; border-radius: 6px; }`,
// `listbox { padding: 4px; }`, no icon swatch, a solid — not
// alpha-blended — selection fill), so a theme that omits this whole
// block of new keys (spotlight does; its capsule doesn't read them
// at all) renders the same as before this change, not a jump to
// either built-in's specific numbers.
row_radius: l.and_then(|l| l.row_radius).unwrap_or(6) as i32,
row_inset: l.and_then(|l| l.row_inset).unwrap_or(4) as i32,
row_padding_v: l.and_then(|l| l.row_padding_v).unwrap_or(8) as i32,
row_padding_h: l.and_then(|l| l.row_padding_h).unwrap_or(12) as i32,
icon_radius: l.and_then(|l| l.icon_radius).unwrap_or(0) as i32,
search_font_size: l.and_then(|l| l.search_font_size).unwrap_or(16) as i32,
search_padding_v: l.and_then(|l| l.search_padding_v).unwrap_or(12) as i32,
search_padding_h: l.and_then(|l| l.search_padding_h).unwrap_or(16) as i32,
// 0.93 default, not breadbox's historical 0.60: a launcher panel needs
// near-opacity to stay legible over a bright wallpaper.
panel_alpha: l.and_then(|l| l.panel_alpha).unwrap_or(0.93),
selection_alpha: l.and_then(|l| l.selection_alpha).unwrap_or(0.24),
})
}
fn resolve_surfaces(
theme_id: &str,
raw: Option<&HashMap<String, RawSurface>>,
) -> anyhow::Result<BTreeMap<String, Surface>> {
let mut out = BTreeMap::new();
let Some(raw) = raw else { return Ok(out) };
for (namespace, s) in raw {
let anchor = match s.anchor.as_deref() {
Some(a @ ("top_right" | "bottom_right" | "bottom_centre" | "fill")) => a.to_string(),
Some(other) => bail!(
"theme '{theme_id}': surfaces.{namespace}.anchor = \"{other}\" is not \
top_right|bottom_right|bottom_centre|fill (the only shapes breadbar's \
satellite windows implement see breadbar/src/surface.rs)"
),
None => bail!(
"theme '{theme_id}': surfaces.{namespace} has no anchor set \
(expected one of top_right|bottom_centre|fill)"
),
};
let offset = match &s.offset {
None => vec![],
Some(RawOffset::Single(v)) => vec![*v],
Some(RawOffset::Pair(v)) => v.to_vec(),
};
let width = match &s.width {
None => SurfaceWidth::Auto,
Some(RawSurfaceWidth::Named(n)) if n == "fill" => SurfaceWidth::Fill,
Some(RawSurfaceWidth::Named(n)) if n == "auto" => SurfaceWidth::Auto,
Some(RawSurfaceWidth::Named(other)) => bail!(
"theme '{theme_id}': surfaces.{namespace}.width = \"{other}\" is not \"fill\" or \"auto\" \
(use a bare number for a fixed width)"
),
Some(RawSurfaceWidth::Px(n)) => SurfaceWidth::Px(*n as i32),
};
let layer = match s.layer.as_deref() {
None | Some("overlay") => "overlay".to_string(),
Some("top") => "top".to_string(),
Some(other) => bail!(
"theme '{theme_id}': surfaces.{namespace}.layer = \"{other}\" is not top|overlay"
),
};
out.insert(
namespace.clone(),
Surface {
anchor,
offset,
width,
layer,
},
);
}
Ok(out)
}
fn resolve_compositor(raw: Option<&HashMap<String, RawLayerRule>>) -> BTreeMap<String, LayerRule> {
let mut out = BTreeMap::new();
let Some(raw) = raw else { return out };
for (namespace, r) in raw {
out.insert(
namespace.clone(),
LayerRule {
blur: r.blur.unwrap_or(false),
ignore_alpha: r.ignore_alpha,
blur_popups: r.blur_popups.unwrap_or(false),
animation: r.animation.clone(),
no_anim: r.no_anim.unwrap_or(false),
},
);
}
out
}
impl RawManifest {
/// Fill every default and validate every enum-ish string, producing a
/// fully-resolved [`super::ShellTheme`]. `requested_id` is the id this
/// manifest was looked up under (used as the id/name fallback when the
/// TOML omits `id`/`name`); `css_template` and `extra_css` are threaded
/// in by the discovery/extends logic in `mod.rs` since neither is a
/// plain TOML field (extra_css is *read from* a TOML field, `css`, but
/// resolving that path against the theme's own directory happens in the
/// caller, which is the only place that still has the directory handy).
pub(super) fn resolve(
&self,
requested_id: &str,
css_template: String,
extra_css: Option<String>,
) -> anyhow::Result<super::ShellTheme> {
let id = self.id.clone().unwrap_or_else(|| requested_id.to_string());
let name = self.name.clone().unwrap_or_else(|| id.clone());
let mut tokens_map = BTreeMap::new();
for (k, v) in &self.tokens {
let tv = token_value(v).with_context(|| format!("theme '{id}': tokens.{k}"))?;
tokens_map.insert(k.clone(), tv);
}
let tokens = Tokens::from_map(tokens_map);
let window = match self.bar.as_ref().and_then(|b| b.window.as_ref()) {
Some(w) => resolve_window(&id, w)?,
None => WindowSpec::default(),
};
let slots = self
.bar
.as_ref()
.and_then(|b| b.slots.as_ref())
.map(|s| Slots {
left: s.left.clone(),
centre: s.centre.clone(),
right: s.right.clone(),
drawer: s.drawer.clone(),
})
.unwrap_or_default();
let modules = resolve_modules(&id, self.modules.as_ref())?;
let launcher = resolve_launcher(&id, self.launcher.as_ref())?;
let surfaces = resolve_surfaces(&id, self.surfaces.as_ref())?;
let compositor = resolve_compositor(self.compositor.as_ref());
Ok(super::ShellTheme {
name,
id,
tokens,
window,
slots,
modules,
launcher,
surfaces,
compositor,
css_template,
extra_css,
})
}
}
/// Deep-merge `over` onto `base`: tables merge key-by-key recursively;
/// anything else (scalars, arrays — including slot lists) is a full
/// replacement. This is `extends`'s one-level merge (plan §4/§11):
/// `mod.rs` calls this exactly once per `load_named`, with the base's own
/// `extends` key already stripped by the caller so a chain can't go deeper
/// than one level.
pub(super) fn merge_values(base: toml::Value, over: toml::Value) -> toml::Value {
match (base, over) {
(toml::Value::Table(mut base_t), toml::Value::Table(over_t)) => {
for (k, v) in over_t {
let merged = match base_t.remove(&k) {
Some(existing) => merge_values(existing, v),
None => v,
};
base_t.insert(k, merged);
}
toml::Value::Table(base_t)
}
(_, over) => over,
}
}

1459
bread-theme/src/shell/mod.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,590 @@
//! Fully-resolved shell theme types — see `bread-theme/src/shell/mod.rs` for
//! the module overview and `manifest.rs` for how these are built from TOML.
//!
//! Every type here has all defaults filled in; there is no further "is this
//! set" branching once a `ShellTheme` exists. That resolution work happens
//! once, in `manifest.rs`, so consumers (breadbar, breadbox, bos-settings)
//! never have to know the manifest format at all.
use std::collections::BTreeMap;
/// Workspace strip rendering. Phase 1 ships only `Trail` (what breadbar draws
/// today); `Pill`/`Dots` exist now so 02/04 (plan §11 phases 5-6) are additive.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorkspaceStyle {
Trail,
Pill,
Dots,
}
/// Clock rendering. Phase 1 ships only `Flip` (today's per-digit flip clock).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClockStyle {
Flip,
Plain,
None,
}
/// Dot pill widths (px) for 0/1/2/3-or-more open windows, `style = "dots"`
/// (theme 04/spotlight). Index 3 covers "3 or more" — the demo's own dots
/// never grow past that fourth width. Unused by `Trail`/`Pill`.
pub type DotWidths = [i32; 4];
/// The demo's own numbers (`04-spotlight.html`'s `.dots button[data-n="N"]`
/// rules) — the default a theme gets if it sets `style = "dots"` but omits
/// `dot_widths`.
pub const DEFAULT_DOT_WIDTHS: DotWidths = [6, 10, 14, 18];
/// How the launcher attaches to the shell. Phase 1 ships only `Overlay`
/// (breadbox's own window); `Embedded` is theme 04's bar-drawer launcher.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LauncherMode {
Overlay,
Embedded,
}
/// `gtk4_layer_shell::KeyboardMode` mirror, kept independent of the `gtk`
/// feature so the manifest types stay usable without GTK linked in (bread,
/// breadcrumbs). Values map 1:1 onto `KeyboardMode::{None,Exclusive,OnDemand}`
/// (verified against gtk4-layer-shell 0.8.1's `src/auto/enums.rs`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Keyboard {
None,
OnDemand,
Exclusive,
}
/// `bar.window.width` / a surface's `width`: `"fill"` spans the anchored
/// edges, a bare number is a fixed/centred/hug width.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Width {
Fill,
Px(i32),
}
/// `bar.window.exclusive`: `"auto"` reserves `height + margin.top`, `"none"`
/// reserves nothing (theme 04's capsule), or a literal pixel override.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Exclusive {
Auto,
None,
Px(i32),
}
/// A satellite surface's width: unlike the bar window, a satellite can also
/// be `Auto` — sized by its own content/CSS with no `set_default_width` call
/// at all. `breadbar-panel` is exactly this today (popover content decides
/// its width via `.control-panel-inner`/`.wifi-popover-inner` min-width, not
/// the layer-shell window).
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SurfaceWidth {
Fill,
Auto,
Px(i32),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Margin {
pub top: i32,
pub left: i32,
pub right: i32,
/// Declared-but-not-yet-consumed: parsed and carried all the way
/// through resolution, but breadbar only calls
/// `gtk4_layer_shell::LayerShell::set_margin` for
/// `Edge::{Top,Left,Right}` (`breadbar/src/main.rs`) — there is no
/// `Edge::Bottom` call anywhere in that crate. All three built-in
/// themes happen to omit `margin.bottom` (defaulting to 0, this
/// struct's own `Default`), which is exactly why the gap has stayed
/// invisible: a theme author who *does* set it would see no effect.
pub bottom: i32,
}
/// `bar.window` — plan §2: window shape is data, not a closed layout enum.
/// Island/Edge/Capsule are three *values* of this struct, not three code
/// paths.
#[derive(Debug, Clone, PartialEq)]
pub struct WindowSpec {
pub anchors: Vec<String>,
pub width: Width,
pub height: i32,
pub margin: Margin,
pub exclusive: Exclusive,
pub keyboard: Keyboard,
pub layer: String,
}
impl Default for WindowSpec {
/// Generic baseline for a theme that omits `[bar.window]` entirely —
/// deliberately the plan §4 schema example's numbers, not necessarily
/// any particular shipped theme's. `liquid-motion` sets every field
/// explicitly, so it never falls through to this.
fn default() -> Self {
WindowSpec {
anchors: vec!["top".into(), "left".into(), "right".into()],
width: Width::Fill,
height: 44,
margin: Margin {
top: 12,
left: 14,
right: 14,
bottom: 0,
},
exclusive: Exclusive::Auto,
keyboard: Keyboard::None,
layer: "top".into(),
}
}
}
/// `bar.slots` — plan §2: structure is slots, not layout code. `drawer` is
/// the only thing Capsule/theme-04 adds over Island, and it's just an
/// (empty, for now) slot list, not a code path.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Slots {
pub left: Vec<String>,
pub centre: Vec<String>,
pub right: Vec<String>,
pub drawer: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspacesModule {
pub style: WorkspaceStyle,
pub show_empty: bool,
/// `style = "dots"` only — see [`DotWidths`]. Trail/Pill ignore this.
pub dot_widths: DotWidths,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClockModule {
pub style: ClockStyle,
/// Consumed only by `ClockStyle::Plain` (`breadbar::bar::clock`'s
/// `formatted()`, called from `main.rs` only in the `Plain` arm) —
/// `Flip` and `None` never read it; `Flip`'s own `time()` hardcodes a
/// 24h `HH:MM` layout regardless of this field. All three built-in
/// themes set `format = "%H:%M"` (which happens to match `Flip`'s
/// hardcoded layout, masking the gap for two of the three styles); see
/// each `theme.toml`'s own note next to it for which styles actually
/// honour this.
pub format: String,
/// Consumed only by `ClockStyle::Plain`, same scoping as
/// [`Self::format`] — `date_lbl` is only ever attached under the
/// `Plain` arm's box, so this value can never be observed under
/// `Flip`/`None`. Currently harmless in practice (every built-in theme
/// that isn't `Plain` sets this `false`, so there's nothing to ignore),
/// but the same "declared, scoped to one style" caveat as `format`
/// applies.
pub show_date: bool,
/// `style = "none"` + this `true`: no module renders a clock label of
/// its own — `launcher_entry`'s placeholder text becomes the time
/// instead (theme 04/spotlight: the capsule's entry IS the clock until
/// focused, per `04-spotlight.html`'s `q.placeholder = t`). Meaningless
/// for `Flip`/`Plain`, which already show a time some other way.
pub placeholder_clock: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Modules {
pub workspaces: WorkspacesModule,
pub clock: ClockModule,
}
// No `Eq`: `selection_alpha` is an `f64`, which doesn't implement it.
#[derive(Debug, Clone, PartialEq)]
pub struct Launcher {
pub mode: LauncherMode,
pub width: i32,
pub top: String,
pub radius: i32,
pub icon_px: i32,
pub row_anim: String,
pub rule: String,
/// Selects the footer label's noun — `"count_apps"` renders
/// `"{n} applications"`, anything else (`"count_results"` included)
/// renders `"{n} results"`. Consumed by `breadbox::main`'s footer label
/// (appended below `ResultsList::scroller`, updated on every
/// `set_query` call alongside the visible-row count).
pub footer: String,
/// Consumed by breadbar's embedded capsule (`breadbar::main::run_ui`,
/// passed straight to `ResultsList::new`) for every theme, and by
/// breadbox's own overlay (`breadbox::main::run_ui`) as of the
/// liquid-motion/glass-workbench redesign — `true` groups the idle
/// (empty-query) view into "Recent"/"Apps" headers via
/// `bread_launcher::gtk::split_sections`, `false` reproduces the flat
/// list. breadbox no longer hardcodes `false` at its `ResultsList::new`
/// call site; it reads this field.
pub sections: bool,
pub modes: Vec<String>,
/// `LauncherMode::Embedded` only (theme 04/spotlight, plan §7 phase 6c):
/// the capsule's own width while a search is in progress
/// (`04-spotlight.html`: `.searching .capsule { width: 520px }` vs the
/// idle 480px). Defaults to `width` (no widen) for a theme that omits
/// it, so an `Overlay`-mode theme — which never reads this field at all
/// — and an `Embedded` theme that just doesn't want the widen both fall
/// back to "no change" rather than a hardcoded magic number.
pub search_width: i32,
/// `LauncherMode::Embedded` only: `border-radius` while searching
/// (`04-spotlight.html`: `.searching .capsule { border-radius: 20px }`
/// vs the collapsed 22px `radius`). Same default-to-`radius` fallback
/// reasoning as `search_width`.
pub search_radius: i32,
/// Result row `border-radius` (px) — `breadbox::main::build_css`'s
/// `row { border-radius: ... }`. Liquid Motion's soft 12px vs Glass
/// Workbench's dense 6px is the clearest single signal that the two
/// themes are different instruments, not one launcher recoloured.
pub row_radius: i32,
/// Result row horizontal inset (px) from the panel's edge —
/// `row { margin: 0 {row_inset}px; }`. Mirrors the demos' `.bx .r`
/// `margin: 0 Npx` rule (liquid-motion 8px, glass-workbench 6px).
pub row_inset: i32,
/// Result row vertical padding (px) — `row { padding: {row_padding_v}px
/// {row_padding_h}px; }`'s first component.
pub row_padding_v: i32,
/// Result row horizontal padding (px) — same rule's second component.
pub row_padding_h: i32,
/// Row icon `border-radius` (px) — `breadbox::main::build_css`'s
/// `image { border-radius: ...; }`, paired with `icon_px` for the
/// swatch's size. Liquid Motion's rounder 9px vs Glass Workbench's
/// tighter 5px.
pub icon_radius: i32,
/// Search entry font-size (px) — distinct from `tokens.font_size_base`
/// (which sizes the result rows): both demos give the search field a
/// larger face than its rows (liquid-motion 16 vs its rows' 14,
/// glass-workbench 14 vs its rows' 13).
pub search_font_size: i32,
/// Search entry vertical padding (px).
pub search_padding_v: i32,
/// Search entry horizontal padding (px).
pub search_padding_h: i32,
/// Opacity of the launcher PANEL itself, distinct from `tokens.bg_alpha`
/// (which governs the thin bar). A bar can be very translucent and stay
/// readable because it is 36-44px tall over a small slice of wallpaper; a
/// full launcher panel at the same alpha washes out badly over a bright
/// wallpaper and its text becomes hard to read. The approved reference
/// uses 0.95 (glass-workbench) and 0.93 (liquid-motion); breadbox
/// previously hardcoded 0.60, which is what made it look washed out.
pub panel_alpha: f64,
/// Selected/hovered row background: `alpha(@accent, selection_alpha)`.
/// Liquid Motion's softer 0.22 vs Glass Workbench's denser 0.28 — see
/// each demo's `.bx .r.sel` rule.
pub selection_alpha: f64,
}
/// A satellite surface, keyed by layer-shell namespace in `[surfaces.*]` —
/// deliberately the same keyspace as `[compositor.*]` (see module docs)
/// rather than a role name, so the two tables can be validated against each
/// other and a namespace's positioning and compositor treatment live under
/// one lookup.
#[derive(Debug, Clone, PartialEq)]
pub struct Surface {
/// One of `top_right`/`bottom_right`/`bottom_centre`/`fill` — validated
/// in `manifest.rs::resolve_surfaces` against the shapes
/// `breadbar/src/surface.rs::apply` actually implements, the same
/// "typo'd key is a hard error" policy this field's siblings
/// (`width`, `layer`) already got. Required, not defaulted: a surface
/// entry with no anchor at all is as much a hard `theme.toml` error as
/// an unrecognized one.
///
/// `bottom_right` (daylight, plan §11 phase 7) added alongside the
/// original three: every built-in theme before it anchored its bar to
/// the TOP, so `breadbar-notif`/`breadbar-panel` popping up from
/// `top_right` always sat naturally close to the bar. A bottom-anchored
/// bar has no shape in the original three that keeps those satellites
/// near it — `top_right` would put them at the opposite corner of the
/// screen from the dock they visually belong to. `offset` is
/// `[right, bottom]` for this anchor, the same two-element convention
/// `top_right`'s `[right, top]` already uses.
pub anchor: String,
pub offset: Vec<f64>,
pub width: SurfaceWidth,
pub layer: String,
}
/// One `[compositor.*]` entry — plan §9: the per-namespace layer-shell rule
/// an app ships as its default and a theme may override. Mirrors the field
/// set `hl.layer_rule` actually accepts in `scripts/ui/rules.lua` (blur,
/// ignore_alpha, blur_popups, animation, no_anim) — that Lua API isn't in
/// hyprland-api.lua's type annotations, so this field set is evidenced by
/// working usage, not documentation (plan §12 risk 3).
///
/// Also `Serialize`: this is the per-namespace shape written to
/// `~/.config/hypr/layerrules.json` by `bread_theme::layerrules` (plan §9
/// step 3-4), which `scripts/ui/rules.lua` parses back into `hl.layer_rule`
/// calls. `Option::None` fields are omitted rather than emitted as `null` —
/// the Lua JSON reader treats a missing key and a `null` value identically
/// (assigning `nil` into a table key is a no-op), so either encoding is
/// correct, but omitting keeps the file legible for hand inspection.
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize)]
pub struct LayerRule {
pub blur: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub ignore_alpha: Option<f64>,
pub blur_popups: bool,
/// Passed through verbatim to `hl.layer_rule`'s `animation` field
/// (`"slide top"`, `"slide bottom"`, …) — kept as a plain string rather
/// than a closed enum since `hl.layer_rule`'s own field set is only
/// evidenced by working usage in `rules.lua`, not documented (plan §12
/// risk 3); a closed Rust enum here would need updating in lockstep
/// with Hyprland additions this crate has no way to know about.
#[serde(skip_serializing_if = "Option::is_none")]
pub animation: Option<String>,
pub no_anim: bool,
}
/// A raw TOML scalar carried through to [`Tokens`] for `{name}` substitution
/// in [`crate::shell::ShellTheme::css`]. Kept untyped (rather than forcing
/// every token into a `String`) so `css()` can format a number without a
/// theme author having to quote it, while `bg_alpha = 0.72` etc. still round
/// -trips as a real float for any future non-string consumer.
#[derive(Debug, Clone, PartialEq)]
pub enum TokenValue {
Str(String),
Int(i64),
Float(f64),
Bool(bool),
}
impl TokenValue {
/// Textual form used both for `{name}` substitution in CSS and for the
/// typed accessors' fallback formatting.
pub fn as_css(&self) -> String {
match self {
TokenValue::Str(s) => s.clone(),
TokenValue::Int(i) => i.to_string(),
TokenValue::Float(f) => {
if f.fract() == 0.0 {
format!("{f:.0}")
} else {
f.to_string()
}
}
TokenValue::Bool(b) => b.to_string(),
}
}
}
/// `[tokens]`, resolved. Deliberately an open bag (`BTreeMap`), not a fixed
/// struct: the schema (plan §4) names eleven fields, but a theme may define
/// arbitrary extra keys purely for `{name}` substitution in `extra.css`
/// (`radius_pill`, `chip_height`, `icon_px`, `spring_settle` below are all
/// exactly this — real values `theme.rs::load_css` uses today that the plan
/// text's `[tokens]` example didn't list). The named accessors below give
/// the documented fields typed access with sensible defaults; [`Tokens::get`]
/// and [`Tokens::substitute`] cover everything else.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Tokens {
pub(crate) map: BTreeMap<String, TokenValue>,
}
impl Tokens {
pub fn from_map(map: BTreeMap<String, TokenValue>) -> Self {
Tokens { map }
}
pub fn get(&self, key: &str) -> Option<&TokenValue> {
self.map.get(key)
}
pub fn keys(&self) -> impl Iterator<Item = &str> {
self.map.keys().map(|s| s.as_str())
}
fn str_or(&self, key: &str, default: &str) -> String {
match self.map.get(key) {
Some(v) => v.as_css(),
None => default.to_string(),
}
}
fn int_or(&self, key: &str, default: i64) -> i64 {
match self.map.get(key) {
Some(TokenValue::Int(i)) => *i,
Some(TokenValue::Float(f)) => *f as i64,
Some(TokenValue::Str(s)) => s.parse().unwrap_or(default),
_ => default,
}
}
fn float_or(&self, key: &str, default: f64) -> f64 {
match self.map.get(key) {
Some(TokenValue::Float(f)) => *f,
Some(TokenValue::Int(i)) => *i as f64,
Some(TokenValue::Str(s)) => s.parse().unwrap_or(default),
_ => default,
}
}
/// Same fallback shape as [`Self::str_or`]/[`Self::int_or`]/
/// [`Self::float_or`] for a `TokenValue::Bool`. First consumer: `light`
/// below (theme 05/daylight, plan §11 phase 7) — every existing token is
/// a string/number, this is the first bool-shaped one, hence the new
/// helper rather than reusing one of the three above.
fn bool_or(&self, key: &str, default: bool) -> bool {
match self.map.get(key) {
Some(TokenValue::Bool(b)) => *b,
Some(TokenValue::Str(s)) => s.parse().unwrap_or(default),
_ => default,
}
}
/// Consumed by `breadbox::main::build_css` for the launcher panel's
/// `font-family` (the `entry.search`/`row` rule) as of the
/// liquid-motion/glass-workbench redesign — combined with
/// [`Self::font_fallback`] into a CSS font stack. Still NOT consumed by
/// `breadbar` (bar/chip/clock text) or by [`crate::stylesheet`]'s
/// ecosystem-wide base rule, which still hardcodes
/// `crate::tokens::FONT_FAMILY` for every non-launcher widget — this is
/// a scoped, launcher-only wire-up, not the full "per-shell-theme font
/// everywhere" replacement a `stylesheet()` owner would need to decide
/// on separately.
pub fn font_family(&self) -> String {
self.str_or("font_family", crate::tokens::FONT_FAMILY)
}
/// See [`Self::font_family`] — same scoped (launcher-only) consumption,
/// appended after `font_family` in the CSS font stack breadbox builds.
pub fn font_fallback(&self) -> String {
self.str_or("font_fallback", "sans-serif")
}
/// Consumed by `breadbox::main::build_css` for the result rows'
/// `font-size` (the search entry uses `[launcher].search_font_size`
/// instead — both demos give the search field a larger face than its
/// rows). Still not read by `breadbar`.
pub fn font_size_base(&self) -> i64 {
self.int_or("font_size_base", crate::tokens::FONT_SIZE_BASE as i64)
}
pub fn radius_bar(&self) -> i64 {
self.int_or("radius_bar", crate::tokens::RADIUS_PRIMARY as i64)
}
pub fn radius_card(&self) -> i64 {
self.int_or("radius_card", crate::tokens::RADIUS_PRIMARY as i64)
}
pub fn radius_sm(&self) -> i64 {
self.int_or("radius_sm", crate::tokens::RADIUS_SECONDARY as i64)
}
/// Not in the plan §4 schema list, but a named local in
/// `theme.rs::load_css` (`radius_pill = "999px"`) alongside the three
/// siblings that are. See the [`Tokens`] doc comment.
pub fn radius_pill(&self) -> i64 {
self.int_or("radius_pill", crate::tokens::RADIUS_PILL as i64)
}
pub fn pad(&self) -> i64 {
self.int_or("pad", crate::tokens::SPACE_MD as i64)
}
pub fn bg_alpha(&self) -> f64 {
self.float_or("bg_alpha", 0.72)
}
/// The overshoot/bounce curve (`0.22, 1.35, 0.36, 1`) — clock flips,
/// pop-ins, the workspace caret draw.
pub fn spring(&self) -> String {
self.str_or("spring", "cubic-bezier(0.22, 1.35, 0.36, 1)")
}
/// The settle curve (`0.22, 1.2, 0.36, 1`) — hovers, workspace-btn
/// opacity/background transitions, OSD/notification slide-ins. Not in
/// the plan §4 schema (which names only `spring`), but `theme.rs` uses
/// it just as pervasively as the overshoot curve. See [`Tokens`] doc.
pub fn spring_settle(&self) -> String {
self.str_or("spring_settle", "cubic-bezier(0.22, 1.2, 0.36, 1)")
}
pub fn accent_from(&self) -> String {
self.str_or("accent_from", "accent")
}
/// Was declared-but-not-yet-consumed in production through theme 04
/// (spotlight): breadbar's hand-rolled Trail CSS hardcoded the literal
/// gradient `linear-gradient(90deg, @accent, @teal)` instead of
/// substituting `accent_from`/`accent_to`, silently correct only because
/// liquid-motion's own `accent_from`/`accent_to` happened to be
/// `"accent"`/`"teal"` — the exact two names the hardcode already spelled
/// out. Theme 05 (daylight, plan §11 phase 7) is the first Trail-style
/// theme to set a *different* pair (`accent_from = accent_to = "teal"`,
/// a flat fill, not a gradient), which is what finally forced
/// `breadbar::theme::load_css`'s Trail branch to read this method for
/// real instead of the two literal names — see that function's own note
/// next to `.workspace-trail`.
pub fn accent_to(&self) -> String {
let from = self.accent_from();
self.str_or("accent_to", &from)
}
/// A second, independent accent for chrome that shouldn't track the
/// primary `accent_from`/`accent_to` pair — daylight (plan §11 phase 7)
/// is the first theme that needs one: its media-widget equaliser bars
/// are warm amber while the workspace trail/active-fill accent is deep
/// teal, so one `accent_from` value can no longer describe both.
/// Defaults to `accent_from` itself, which reproduces every earlier
/// theme's actual rendering byte-for-byte (liquid-motion/glass-
/// workbench/spotlight all paint their equaliser with the same accent
/// as everything else, and none of them sets this key). Consumed by
/// `breadbar::theme::load_css`'s `.media-eq-bar` rule.
pub fn accent2(&self) -> String {
let from = self.accent_from();
self.str_or("accent2", &from)
}
/// Whether this theme's surfaces are painted ink-on-paper (near-opaque
/// LIGHT fills with dark ink) rather than the glass-on-dark look every
/// earlier theme assumed — daylight (plan §11 phase 7) is the first
/// theme to set this `true`. Defaults `false`, reproducing every
/// existing theme's rendering unchanged.
///
/// Exists because the palette's `bg`/`surface`/`overlay`/`fg` slots are
/// NEVER pywal-derived (`bread_theme::palette`'s `FIXED_*` constants —
/// deliberately, so a bright wallpaper can't turn the whole UI an
/// unreadable light colour by accident) — only the six accent slots
/// vary. That means there is no palette token whose value is a light
/// "paper" surface for a theme to reference by name; `@bg` is always
/// dark, `@on-bg` is always its computed-legible near-white ink.
/// `breadbar::theme::load_css` reads this flag to swap which of those
/// two *fixed, anti-correlated* tokens plays "surface fill" versus
/// "ink" for every translucent card/panel/hover-wash in the stylesheet
/// (`@on-bg` as the near-white fill, `@bg` as the near-black ink,
/// otherwise the reverse) — see that function's own `panel`/`ink`
/// locals. This works only because `@bg`/`@on-bg` are pinned opposite
/// constants by construction; it is not a general "pick any light
/// surface colour" mechanism, and this doc comment is the canonical
/// place that fact is written down (see the task report for the full
/// reasoning: the palette schema has no dedicated light-surface token).
pub fn light(&self) -> bool {
self.bool_or("light", false)
}
/// Workspace-pill / chip height. Not in the plan §4 schema, but
/// `breadbar::CHIP_HEIGHT` (32) today. See [`Tokens`] doc.
pub fn chip_height(&self) -> i64 {
self.int_or("chip_height", 32)
}
/// Not in the plan §4 schema, but `breadbar::ICON_PX` (24) today. See
/// [`Tokens`] doc.
pub fn icon_px(&self) -> i64 {
self.int_or("icon_px", 24)
}
/// Not in the plan §4 schema. `"full"` (default, liquid-motion's island)
/// draws a border on all four edges; `"bottom"` (glass-workbench's flush
/// edge-to-edge bar, plan §1) draws only the bottom hairline the demo's
/// `.bar { border-bottom: 1px solid #ffffff12 }` calls for — a floating
/// island's full border would otherwise render as a stray top/side line
/// flush against the screen edge. `"segmented"` (daylight, plan §11
/// phase 7) is a third value: `window.breadbar` itself gets NO fill,
/// border, or radius at all (fully transparent), and the bar's three
/// slot-group containers each carry their own pill surface instead (see
/// `breadbar::theme::load_css`'s `segmented`/`.bar-segment` locals) —
/// this is what lets one bar surface look like three detached floating
/// pills rather than one continuous strip. See [`Tokens`] doc; consumed
/// by `breadbar::theme::load_css`, not by [`crate::shell::ShellTheme::css`].
pub fn bar_border(&self) -> String {
self.str_or("bar_border", "full")
}
/// Replace every `{name}` occurrence in `template` with that token's
/// [`TokenValue::as_css`] form. Longest names are substituted first
/// (mirrors [`crate::resolve_color_names`]) so `{radius}` cannot
/// half-consume `{radius_bar}` if a theme happens to define both.
/// `@name` palette references are untouched — this only ever looks at
/// `{...}` tokens.
pub fn substitute(&self, template: &str) -> String {
let mut keys: Vec<&String> = self.map.keys().collect();
keys.sort_by_key(|k| std::cmp::Reverse(k.len()));
let mut out = template.to_string();
for k in keys {
let value = self.map[k].as_css();
out = out.replace(&format!("{{{k}}}"), &value);
}
out
}
}

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;
}
let request = json!({
"id": "0",
"method": "emit",
"params": {
fire_and_forget_emit(json!({
"event": event,
"source": self.app_id,
"kind": event,
"data": data,
}));
}
});
let Ok(line) = serde_json::to_string(&request) else {
return;
};
let Ok(mut stream) = UnixStream::connect(bread_shared::resolve_socket_path()) else {
/// Publish `bread.command.<target_app>.<verb>` as an unsourced IPC
/// emit so another bread app (or a Lua workflow) can act on it.
///
/// Fire-and-forget: same silent-if-down stance as [`emit`]. Locally
/// refuses (eprint + return, no socket) if `target_app` or `verb` is
/// empty, or if `verb` contains `.` — a verb is one segment
/// (`clear`, not `history.clear`).
///
/// The wire payload is `{ method: "emit", params: { event, data } }`
/// with **no** `source`/`kind`. Do not add those: a sourced emit
/// would have to claim the *target's* namespace (or ours), and the
/// daemon half of this integration is specifically making unsourced
/// `bread.command.<known_app>.*` legal.
pub fn command(&self, target_app: &str, verb: &str, data: Value) {
if target_app.is_empty() || verb.is_empty() || verb.contains('.') {
eprintln!(
"bread-client: refusing to send command to '{target_app}' with verb '{verb}' \
(target and verb must be non-empty; verb must be a single segment)"
);
return;
};
let _ = stream.set_write_timeout(Some(Duration::from_millis(200)));
let _ = writeln!(stream, "{line}");
}
let event = format!("bread.command.{target_app}.{verb}");
fire_and_forget_emit(json!({
"event": event,
"data": data,
}));
}
/// One-shot `health` IPC request. `None` if breadd is unreachable or
/// the response is malformed / an error.
///
/// Long-running daemons SHOULD log a warning when this returns
/// `None` (or when [`api_version`] is missing) and MUST NOT crash.
pub fn health(&self) -> Option<Value> {
self.request("health", json!({}))
}
/// `api_version` string from [`health`], or `None` if health failed
/// or the field is absent. Same SHOULD-warn / MUST-NOT-crash rule
/// as [`health`].
pub fn api_version(&self) -> Option<String> {
self.health()?
.get("api_version")
.and_then(Value::as_str)
.map(str::to_owned)
}
/// Send a one-shot IPC request and return its `result`, or `None` on any
/// 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": method,
"params": params,
});
let line = serde_json::to_string(&request).ok()?;
let mut stream = UnixStream::connect(bread_shared::resolve_socket_path()).ok()?;
stream
.set_write_timeout(Some(Duration::from_millis(200)))
.ok()?;
stream
.set_read_timeout(Some(Duration::from_millis(500)))
.ok()?;
writeln!(stream, "{line}").ok()?;
let mut response_line = String::new();
BufReader::new(stream).read_line(&mut response_line).ok()?;
if response_line.trim().is_empty() {
return None;
}
let value: Value = serde_json::from_str(&response_line).ok()?;
if value.get("error").is_some() {
return None;
}
value.get("result").cloned()
}
/// Subscribe to events matching `pattern` (glob: `*`/`**`/`?`), invoking
@ -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

@ -36,10 +36,17 @@ pub enum Socket {
/// `HYPRLAND_INSTANCE_SIGNATURE` + `XDG_RUNTIME_DIR`. Returns `None` if
/// `HYPRLAND_INSTANCE_SIGNATURE` isn't set (Hyprland isn't running, or we're
/// not inside a Hyprland session) — `XDG_RUNTIME_DIR` falls back to
/// `/run/user/1000` if unset, matching `breadmon`'s existing fallback.
/// `/run/user/<uid>` (this process's real uid, or the historical
/// `/run/user/1000` if that can't be read) when unset.
pub fn socket_path(kind: Socket) -> Option<PathBuf> {
let sig = env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?;
let rt = env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".to_string());
// `XDG_RUNTIME_DIR` is normally `/run/user/<uid>`; when it's unset,
// reconstruct that from the process's real uid instead of assuming uid
// 1000, so the socket path is right for any account.
let rt = env::var("XDG_RUNTIME_DIR")
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(fallback_runtime_dir);
let file = match kind {
Socket::Request => ".socket.sock",
Socket::Events => ".socket2.sock",
@ -47,6 +54,21 @@ pub fn socket_path(kind: Socket) -> Option<PathBuf> {
Some(PathBuf::from(format!("{rt}/hypr/{sig}/{file}")))
}
/// `XDG_RUNTIME_DIR`'s conventional `/run/user/<uid>` value, derived from
/// this process's real uid (`Uid:` line in `/proc/self/status`). Falls back
/// to the historical `/run/user/1000` if that can't be read.
fn fallback_runtime_dir() -> String {
let status = std::fs::read_to_string("/proc/self/status").unwrap_or_default();
for line in status.lines() {
if let Some(rest) = line.strip_prefix("Uid:") {
if let Some(uid) = rest.split_whitespace().next() {
return format!("/run/user/{uid}");
}
}
}
"/run/user/1000".to_string()
}
/// Send `request` (e.g. `"j/activewindow"`, `"j/monitors"`) to the socket1
/// IPC socket and return the raw response body. Blocking/synchronous — this
/// matches every current consumer (breadbox, breadclip), which call it from
@ -222,6 +244,17 @@ mod tests {
assert!(win.fullscreen.is_fullscreen());
}
#[test]
fn fallback_runtime_dir_is_user_uid_shaped() {
let dir = fallback_runtime_dir();
assert!(dir.starts_with("/run/user/"), "got {dir}");
let uid = dir.trim_start_matches("/run/user/");
assert!(
!uid.is_empty() && uid.chars().all(|c| c.is_ascii_digit()),
"fallback uid is not numeric: {uid}"
);
}
// Both env-var-dependent cases share one test function: `set_var`/
// `remove_var` are process-global, and cargo runs tests in parallel
// threads by default, so two separate #[test] fns racing on the same

View file

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

View file

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

View file

@ -80,6 +80,9 @@ pub fn try_acquire(app: &str) -> std::io::Result<Acquire> {
.read(true)
.write(true)
.create(true)
// Never truncate on open: an existing lock file may be held by a live
// instance. We only clear it (`set_len(0)`) *after* winning the lock.
.truncate(false)
.open(&path)?;
match file.try_lock() {

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,13 +38,16 @@ only if:
`archlinux:latest` container and `curl -X PUT`s the resulting
`.pkg.tar.zst` to `https://git.breadway.dev/api/packages/Breadway/arch/os`.
A repo can be on **both** channels (most GUI/daemon apps are — see
breadbar, breadbox, breadcrumbs, bread, breadpad, breadpaper), **bakery
only** (breadclip, breadmon, breadsearch, breadshot, bread-theme, bakery
itself), **pacman only** (breadlock, breadhelp — both are OS-integration
pieces where package-manager rigor matters more than a curl-script), or
**neither** (dev-only / not yet released; no bakery.toml, no PKGBUILD, no
release or package workflow — just the repo itself, e.g. breadarr today).
A repo can be on **both** channels, **bakery only** (bread, breadbar,
breadbox, breadcrumbs, breadpad, breadpaper, breadclip, breadmon,
breadsearch, breadshot, breadhelp, bos-settings, bread-theme, breadcast,
breadarr, bakery itself), **pacman only** (breadlock — installs a
root-owned `/etc/pam.d/breadlock` PAM service file with no per-user
equivalent, so it can never move to bakery), or **neither** (dev-only /
not yet released). Desktop apps dropped pacman packaging; bakery-channel
install is the supported path. `bakery` still carries a leftover
`package.yml` / `packaging/arch/PKGBUILD` from when it was also published
to the `[breadway]` pacman repo.
`bos` is a fourth, deliberately special case: it ships as an ISO, not a
binary, via its own `release-iso.yml`. It is never on either channel and
@ -53,34 +56,82 @@ should never carry a `bakery.toml` or `PKGBUILD`.
## Build tracks (stable/beta/dev) — orthogonal to channels
Within the **bakery channel only**, a repo can additionally publish up to
three **tracks**: `stable` (the existing tag-triggered `v*` flow, unchanged),
`beta` (a deliberate promotion triggered by a `beta-v*` tag), and `dev`
(published automatically on every push to the `dev` branch). Don't confuse
"track" with "channel" above — channel is *how* a binary reaches a user
(bakery vs. pacman); track is *which build* of a bakery-channel package they
get.
three **tracks**: `stable`, `beta`, and `dev`. Don't confuse "track" with
"channel" above — channel is *how* a binary reaches a user (bakery vs.
pacman); track is *which build* of a bakery-channel package they get.
Each track lives in its own subtree so they never collide:
There is no per-track branch anymore — every bakery-channel repo has exactly
one long-lived branch, `main`. Tracks are driven entirely by *what you push*,
not *which branch you push to*:
| Track | Index URL | Artifact root | Trigger |
|---|---|---|---|
| stable | `dl.breadway.dev/index.json` | `/srv/breadway-dl/<pkg>/<ver>/` | push tag `v*` |
| beta | `dl.breadway.dev/beta/index.json` | `/srv/breadway-dl/beta/<pkg>/<ver>/` | push tag `beta-v*` |
| dev | `dl.breadway.dev/dev/index.json` | `/srv/breadway-dl/dev/<pkg>/<ver>/` | push to branch `dev` |
| 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 — every existing stable release workflow needs
zero changes. Dev/beta builds skip the GitHub Release upload step entirely
(no release-per-commit spam for dev, and beta doesn't need a GitHub mirror
either) — `dl.breadway.dev` is their only distribution point.
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.
Adding beta/dev to a bakery-channel repo: copy `dev-bakery.yml` /
`beta-bakery.yml` (or `bread`'s `dev-release.yml` / `beta-release.yml` if the
**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`. Not every bakery-channel repo needs beta/dev on day one —
`gen-index.sh` silently skips any product with no release dir under a given
track's tree, same as it already does for an unreleased product on stable.
`release.yml`. No branch setup needed beyond the repo's single `main`. Not
every bakery-channel repo needs beta/dev on day one — `gen-index.sh`
silently skips any product with no release dir under a given track's tree,
same as it already does for an unreleased product on stable.
Client side: `bakery track show` / `bakery track set <stable|beta|dev>`
remembers a global track preference (`~/.local/state/bakery/installed.json`)
@ -104,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.
@ -122,12 +174,19 @@ missing it; that gap is intentional and about to be moot everywhere.
| Repo | bakery | pacman | tracks | notes |
|---|---|---|---|---|
| bread-ecosystem (bakery product) | yes | yes | stable, beta, dev | `release-bakery.yml` recovered from a dead `.github/workflows/release.yml` that referenced a `hestia` self-hosted runner GitHub never had registered |
| bread-ecosystem (bread-theme product) | yes | no | stable, beta, dev | |
| bread | yes | yes | stable, beta, dev | pilot repo for the beta/dev track rollout |
| breadbar, breadbox, breadcrumbs, breadpad, breadpaper | yes | yes | stable only | complete, used as templates; not yet rolled out to beta/dev |
| breadclip, breadmon, breadsearch, breadshot | yes | no | stable only | complete |
| breadlock, breadhelp | no | yes | n/a | breadlock's `bakery.toml` was removed as orphaned; its README wrongly claimed it was a registry entry |
| bos-settings | yes | yes | stable only | was missing both the registry entry and `release.yml`; both added |
| bos | no | no | n/a | ISO-only via `release-iso.yml`; had an erroneous `bakery.toml` copy-pasted from bos-settings, removed |
| breadarr | no | no | n/a | had an orphaned `bakery.toml` with no registry entry and zero workflows; removed. Not yet assigned a channel — do that deliberately when it's ready to ship, don't infer it from a stray config file |
| 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

@ -67,6 +67,41 @@ build_package_json() {
local version
version="$(basename "${version_dir}")"
# Locate bakery.toml. The release workflow copies it into the version dir
# alongside the binaries (${version_dir}/bakery.toml). Fall back to a
# sibling repo checkout for local dev use. Done before the binaries loop
# below so license_file/desktop_file (if declared) can be excluded from
# it by name — otherwise they'd get swept up as "binaries" with no
# checksum, the same gotcha this loop's other exclusions guard against.
local bakery_toml="${version_dir}/bakery.toml"
if [[ ! -f "${bakery_toml}" ]]; then
bakery_toml="${SCRIPT_DIR}/../${name}/bakery.toml"
fi
if [[ ! -f "${bakery_toml}" ]]; then
echo "ERROR: bakery.toml not found for ${name} — the release workflow must copy it to \${PKG_ROOT}/${name}/\${VERSION}/bakery.toml" >&2
return 1
fi
local license_file_name desktop_file_name data_archive_name
license_file_name="$(python3 -c "
import tomllib
with open('${bakery_toml}', 'rb') as f:
d = tomllib.load(f)
print(d.get('license_file', ''))
" 2>/dev/null || true)"
desktop_file_name="$(python3 -c "
import tomllib
with open('${bakery_toml}', 'rb') as f:
d = tomllib.load(f)
print(d.get('desktop_file', ''))
" 2>/dev/null || true)"
data_archive_name="$(python3 -c "
import tomllib
with open('${bakery_toml}', 'rb') as f:
d = tomllib.load(f)
print(d.get('data_archive', ''))
" 2>/dev/null || true)"
# Collect all binaries in the version dir (executables only; skip metadata files).
local binaries_json="[]"
for bin_path in "${version_dir}"/*; do
@ -76,6 +111,9 @@ build_package_json() {
[[ "${bin_path}" == *.css ]] && continue
[[ "${bin_path}" == *.txt ]] && continue
[[ "${bin_path}" == *.minisig ]] && continue
[[ -n "${license_file_name}" && "${bin_path}" == "${version_dir}/${license_file_name}" ]] && continue
[[ -n "${desktop_file_name}" && "${bin_path}" == "${version_dir}/${desktop_file_name}" ]] && continue
[[ -n "${data_archive_name}" && "${bin_path}" == "${version_dir}/${data_archive_name}" ]] && continue
[[ -f "${bin_path}" ]] || continue
local bin_name
bin_name="$(basename "${bin_path}")"
@ -106,18 +144,6 @@ build_package_json() {
binaries_json="$(jq -n --argjson arr "${binaries_json}" --argjson e "${entry}" '$arr + [$e]')"
done
# Locate bakery.toml. The release workflow copies it into the version dir
# alongside the binaries (${version_dir}/bakery.toml). Fall back to a
# sibling repo checkout for local dev use.
local bakery_toml="${version_dir}/bakery.toml"
if [[ ! -f "${bakery_toml}" ]]; then
bakery_toml="${SCRIPT_DIR}/../${name}/bakery.toml"
fi
if [[ ! -f "${bakery_toml}" ]]; then
echo "ERROR: bakery.toml not found for ${name} — the release workflow must copy it to \${PKG_ROOT}/${name}/\${VERSION}/bakery.toml" >&2
return 1
fi
local description system_deps optional_system_deps bread_deps services config post_install
description="$(python3 -c "
@ -213,6 +239,47 @@ with open('${bakery_toml}', 'rb') as f:
print(json.dumps(d.get('install', {}).get('post_install', [])))
" 2>/dev/null || echo "[]")"
# license_file / desktop_file: plain filename fields in bakery.toml
# (names already read above, before the binaries loop), same "artifact
# in the version dir, sha256 computed here" pattern as config.example.
# Empty string (not null) when unset, matching how the rest of this
# script signals "field absent" to jq below.
license_file="${license_file_name}"
license_file_sha256=""
if [[ -n "${license_file}" ]]; then
license_path="${version_dir}/${license_file}"
if [[ -f "${license_path}" ]]; then
license_file_sha256="$(sha256sum "${license_path}" | awk '{print $1}')"
else
echo " warning: license_file '${license_file}' not found at ${license_path}" >&2
license_file=""
fi
fi
desktop_file="${desktop_file_name}"
desktop_file_sha256=""
if [[ -n "${desktop_file}" ]]; then
desktop_path="${version_dir}/${desktop_file}"
if [[ -f "${desktop_path}" ]]; then
desktop_file_sha256="$(sha256sum "${desktop_path}" | awk '{print $1}')"
else
echo " warning: desktop_file '${desktop_file}' not found at ${desktop_path}" >&2
desktop_file=""
fi
fi
data_archive="${data_archive_name}"
data_archive_sha256=""
if [[ -n "${data_archive}" ]]; then
data_archive_path="${version_dir}/${data_archive}"
if [[ -f "${data_archive_path}" ]]; then
data_archive_sha256="$(sha256sum "${data_archive_path}" | awk '{print $1}')"
else
echo " warning: data_archive '${data_archive}' not found at ${data_archive_path}" >&2
data_archive=""
fi
fi
jq -n \
--arg name "${name}" \
--arg description "${description}" \
@ -224,6 +291,12 @@ print(json.dumps(d.get('install', {}).get('post_install', [])))
--argjson services "${services}" \
--argjson config "${config}" \
--argjson post_install "${post_install}" \
--arg license_file "${license_file}" \
--arg license_file_sha256 "${license_file_sha256}" \
--arg desktop_file "${desktop_file}" \
--arg desktop_file_sha256 "${desktop_file_sha256}" \
--arg data_archive "${data_archive}" \
--arg data_archive_sha256 "${data_archive_sha256}" \
'{
name: $name,
description: $description,
@ -234,7 +307,13 @@ print(json.dumps(d.get('install', {}).get('post_install', [])))
bread_deps: $bread_deps,
services: $services,
config: $config,
post_install: $post_install
post_install: $post_install,
license_file: (if $license_file == "" then null else $license_file end),
license_file_sha256: (if $license_file_sha256 == "" then null else $license_file_sha256 end),
desktop_file: (if $desktop_file == "" then null else $desktop_file end),
desktop_file_sha256: (if $desktop_file_sha256 == "" then null else $desktop_file_sha256 end),
data_archive: (if $data_archive == "" then null else $data_archive end),
data_archive_sha256: (if $data_archive_sha256 == "" then null else $data_archive_sha256 end)
}'
}
@ -254,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}"
@ -281,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
@ -289,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
@ -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
:
elif [ "${sig_url_alt}" != "${sig_url}" ] && fetch "${sig_url_alt}" "${TMP}.minisig" 2>/dev/null; then
echo " signature fetched from fallback URL"
else
die "could not fetch bakery-x86_64.minisig — refusing to install an unsigned binary"
fi
if minisign -V -q -m "${TMP}" -x "${TMP}.minisig" -P "${BAKERY_MINISIGN_PUBKEY}"; then
echo " signature verified (minisign)"
sig_verified=1
else
die "minisign signature verification FAILED — refusing to install a binary that doesn't match the pinned bakery key"
fi
else
echo " warning: 'minisign' is not installed — cannot verify the binary's" >&2
echo " warning: signature, only its checksum. Install minisign for the" >&2
echo " warning: strongest guarantee: pacman -S minisign / apt install minisign" >&2
fi
else
echo " warning: no .minisig published for this release yet — signature not verified" >&2
fi
# Checksum is a secondary, best-effort check (kept for defense in depth and
# for the case where minisign isn't installed). It is not a substitute for
# signature verification: both the binary and its checksum typically come
# from the same server, so a compromised server can serve a matching pair.
# 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