Compare commits

..

No commits in common. "main" and "v0.3.0" have entirely different histories.
main ... v0.3.0

97 changed files with 585 additions and 15889 deletions

View file

@ -1,99 +0,0 @@
name: dev bakery
# Publishes a dev-track build on every push to `main` (the trunk branch —
# there is no separate `dev` branch). See docs/release-channels.md for the
# release-track policy this is part of.
on:
push:
branches: ['main']
paths:
- 'bakery/**'
- 'bread-utils/**'
- 'Cargo.toml'
- 'Cargo.lock'
- '.forgejo/workflows/dev-bakery.yml'
jobs:
build:
runs-on: [self-hosted, hestia]
steps:
- name: checkout
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch main --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build
run: cd src && cargo build --release --locked -p bakery
- name: test
run: cd src && cargo test --release --locked -p bakery
# Auto-bumps the patch version from Cargo.toml and appends a
# timestamp+sha dev suffix — no developer discipline required, and the
# result is visibly "ahead of" the last stable patch release while
# staying valid semver (comparable within the dev track by bakery's
# `is_newer`).
- name: compute dev version
run: |
set -euo pipefail
cd src
# Base the dev version off the latest published stable tag,
# not Cargo.toml — Cargo.toml can go stale relative to the last
# real release (seen in practice: breadbox/breadpad/breadcrumbs/
# breadpaper), which would make a dev build sort as OLDER than
# what's already installed and bakery would correctly refuse it.
LATEST_TAG="$(git ls-remote --tags --refs \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \
| awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)"
if [ -n "${LATEST_TAG}" ]; then
CUR="${LATEST_TAG}"
else
CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')"
fi
IFS='.' read -r MA MI PA <<< "${CUR}"
SHA="$(git rev-parse --short HEAD)"
TS="$(date -u +%Y%m%d%H%M%S)"
echo "VERSION=${MA}.${MI}.$((PA + 1))-dev.${TS}+${SHA}" >> "$GITHUB_ENV"
- name: prepare artifacts
run: |
set -euo pipefail
PKG_DIR="/srv/breadway-dl/dev/bakery/${VERSION}"
mkdir -p "${PKG_DIR}"
cp "src/target/release/bakery" "${PKG_DIR}/bakery-x86_64"
strip "${PKG_DIR}/bakery-x86_64"
sha256sum "${PKG_DIR}/bakery-x86_64" | awk '{print $1}' \
> "${PKG_DIR}/bakery-x86_64.sha256"
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
ln -sfn "${VERSION}" "/srv/breadway-dl/dev/bakery/latest"
- name: sign dev binary
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: |
set -euo pipefail
PKG_DIR="/srv/breadway-dl/dev/bakery/${VERSION}"
if [ -n "${MINISIGN_SEC_KEY:-}" ]; then
minisign -W -S -s "${MINISIGN_SEC_KEY}" -m "${PKG_DIR}/bakery-x86_64" \
-x "${PKG_DIR}/bakery-x86_64.minisig" </dev/null
echo "signed bakery-x86_64"
else
echo "::warning::BAKERY_MINISIGN_SEC_KEY_PATH not set — shipping bakery-x86_64 UNSIGNED"
fi
# No GitHub Release upload step here, unlike release-bakery.yml — dev
# builds happen on every push and would spam a release per commit, so
# dl.breadway.dev/dev/ is the only distribution point for this track.
- name: regenerate dev index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
TRACK: dev
run: |
set -euo pipefail
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate dev index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the dev track)"
exit 1
fi
cd src && bash scripts/gen-index.sh

View file

@ -1,87 +0,0 @@
name: dev bread-theme
# Publishes a dev-track build on every push to `main` (the trunk branch —
# there is no separate `dev` branch). See docs/release-channels.md for the
# release-track policy this is part of.
on:
push:
branches: ['main']
paths:
- 'bread-theme/**'
- 'Cargo.toml'
- 'Cargo.lock'
- '.forgejo/workflows/dev-bread-theme.yml'
jobs:
build:
runs-on: [self-hosted, hestia]
steps:
- name: checkout
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch main --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build
run: cd src && cargo build --release --locked -p bread-theme --bin bread-theme
- name: compute dev version
run: |
set -euo pipefail
cd src
# Base the dev version off the latest published stable tag,
# not Cargo.toml — Cargo.toml can go stale relative to the last
# real release (seen in practice: breadbox/breadpad/breadcrumbs/
# breadpaper), which would make a dev build sort as OLDER than
# what's already installed and bakery would correctly refuse it.
LATEST_TAG="$(git ls-remote --tags --refs \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \
| awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)"
if [ -n "${LATEST_TAG}" ]; then
CUR="${LATEST_TAG}"
else
CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')"
fi
IFS='.' read -r MA MI PA <<< "${CUR}"
SHA="$(git rev-parse --short HEAD)"
TS="$(date -u +%Y%m%d%H%M%S)"
echo "VERSION=${MA}.${MI}.$((PA + 1))-dev.${TS}+${SHA}" >> "$GITHUB_ENV"
- name: prepare artifacts
run: |
set -euo pipefail
PKG_DIR="/srv/breadway-dl/dev/bread-theme/${VERSION}"
mkdir -p "${PKG_DIR}"
cp "src/target/release/bread-theme" "${PKG_DIR}/bread-theme-x86_64"
strip "${PKG_DIR}/bread-theme-x86_64"
sha256sum "${PKG_DIR}/bread-theme-x86_64" | awk '{print $1}' \
> "${PKG_DIR}/bread-theme-x86_64.sha256"
cp src/bread-theme/bakery.toml "${PKG_DIR}/bakery.toml"
ln -sfn "${VERSION}" "/srv/breadway-dl/dev/bread-theme/latest"
- name: sign dev binary
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: |
set -euo pipefail
PKG_DIR="/srv/breadway-dl/dev/bread-theme/${VERSION}"
if [ -n "${MINISIGN_SEC_KEY:-}" ]; then
minisign -W -S -s "${MINISIGN_SEC_KEY}" -m "${PKG_DIR}/bread-theme-x86_64" \
-x "${PKG_DIR}/bread-theme-x86_64.minisig" </dev/null
echo "signed bread-theme-x86_64"
else
echo "::warning::BAKERY_MINISIGN_SEC_KEY_PATH not set — shipping bread-theme-x86_64 UNSIGNED"
fi
- name: regenerate dev index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
TRACK: dev
run: |
set -euo pipefail
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate dev index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the dev track)"
exit 1
fi
cd src && bash scripts/gen-index.sh

View file

@ -0,0 +1,21 @@
name: Mirror to GitHub
on:
push:
branches: ['**']
tags: ['**']
jobs:
mirror:
runs-on: [self-hosted, hestia]
steps:
- name: Mirror to GitHub
run: |
set -euo pipefail
git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git
cd repo.git
# Mirror only branches and tags (not refs/pull/*, which GitHub rejects);
# --prune deletes GitHub refs that no longer exist on Forgejo.
git push --prune \
"https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/bread-ecosystem.git" \
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'

View file

@ -6,9 +6,6 @@ on:
jobs: jobs:
package: 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] runs-on: [self-hosted, hestia]
container: container:
image: archlinux:latest image: archlinux:latest

View file

@ -1,73 +0,0 @@
name: beta (rc) bakery
# Publishes a beta-track build for any `vX.Y.Z-rc.N` prerelease tag pushed
# to `main` — there is no separate `beta` branch; "freezing" is just
# pausing pushes to main while an RC gets tested. See
# docs/release-channels.md for the release-track policy.
on:
push:
tags: ['v*']
# No paths: filter. Tag pushes compare against an unrelated commit and
# would skip the RC publish if bakery/** wasn't in that diff; the job
# `if: contains -rc.` is the real gate.
jobs:
build:
if: ${{ contains(github.ref_name, '-rc.') }}
runs-on: [self-hosted, hestia]
steps:
- name: checkout
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build
run: cd src && cargo build --release --locked -p bakery
- name: test
run: cd src && cargo test --release --locked -p bakery
- name: prepare artifacts
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
PKG_DIR="/srv/breadway-dl/beta/bakery/${VERSION}"
mkdir -p "${PKG_DIR}"
cp "src/target/release/bakery" "${PKG_DIR}/bakery-x86_64"
strip "${PKG_DIR}/bakery-x86_64"
sha256sum "${PKG_DIR}/bakery-x86_64" | awk '{print $1}' \
> "${PKG_DIR}/bakery-x86_64.sha256"
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
ln -sfn "${VERSION}" "/srv/breadway-dl/beta/bakery/latest"
- name: sign beta binary
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: |
set -euo pipefail
PKG_DIR="/srv/breadway-dl/beta/bakery/${VERSION}"
if [ -n "${MINISIGN_SEC_KEY:-}" ]; then
minisign -W -S -s "${MINISIGN_SEC_KEY}" -m "${PKG_DIR}/bakery-x86_64" \
-x "${PKG_DIR}/bakery-x86_64.minisig" </dev/null
echo "signed bakery-x86_64"
else
echo "::warning::BAKERY_MINISIGN_SEC_KEY_PATH not set — shipping bakery-x86_64 UNSIGNED"
fi
# No GitHub Release upload step here, unlike release-bakery.yml — beta
# builds happen on every push while the branch is frozen for testing,
# so dl.breadway.dev/beta/ is the only distribution point for this track.
- name: regenerate beta index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
TRACK: beta
run: |
set -euo pipefail
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate beta index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the beta track)"
exit 1
fi
cd src && bash scripts/gen-index.sh

View file

@ -1,67 +0,0 @@
name: beta (rc) bread-theme
# Publishes a beta-track build for any `vX.Y.Z-rc.N` prerelease tag pushed
# to `main` — there is no separate `beta` branch; "freezing" is just
# pausing pushes to main while an RC gets tested. See
# docs/release-channels.md for the release-track policy.
on:
push:
tags: ['v*']
# No paths: filter. Tag pushes compare against an unrelated commit and
# would skip the RC publish if bread-theme/** wasn't in that diff; the
# job `if: contains -rc.` is the real gate.
jobs:
build:
if: ${{ contains(github.ref_name, '-rc.') }}
runs-on: [self-hosted, hestia]
steps:
- name: checkout
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build
run: cd src && cargo build --release --locked -p bread-theme --bin bread-theme
- name: prepare artifacts
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
PKG_DIR="/srv/breadway-dl/beta/bread-theme/${VERSION}"
mkdir -p "${PKG_DIR}"
cp "src/target/release/bread-theme" "${PKG_DIR}/bread-theme-x86_64"
strip "${PKG_DIR}/bread-theme-x86_64"
sha256sum "${PKG_DIR}/bread-theme-x86_64" | awk '{print $1}' \
> "${PKG_DIR}/bread-theme-x86_64.sha256"
cp src/bread-theme/bakery.toml "${PKG_DIR}/bakery.toml"
ln -sfn "${VERSION}" "/srv/breadway-dl/beta/bread-theme/latest"
- name: sign beta binary
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: |
set -euo pipefail
PKG_DIR="/srv/breadway-dl/beta/bread-theme/${VERSION}"
if [ -n "${MINISIGN_SEC_KEY:-}" ]; then
minisign -W -S -s "${MINISIGN_SEC_KEY}" -m "${PKG_DIR}/bread-theme-x86_64" \
-x "${PKG_DIR}/bread-theme-x86_64.minisig" </dev/null
echo "signed bread-theme-x86_64"
else
echo "::warning::BAKERY_MINISIGN_SEC_KEY_PATH not set — shipping bread-theme-x86_64 UNSIGNED"
fi
- name: regenerate beta index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
TRACK: beta
run: |
set -euo pipefail
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate beta index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the beta track)"
exit 1
fi
cd src && bash scripts/gen-index.sh

View file

@ -6,7 +6,6 @@ on:
jobs: jobs:
build: build:
if: ${{ !contains(github.ref_name, '-rc.') }}
runs-on: [self-hosted, hestia] runs-on: [self-hosted, hestia]
steps: steps:
- name: checkout - name: checkout
@ -59,13 +58,7 @@ jobs:
- name: regenerate index.json - name: regenerate index.json
env: env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: | run: cd src && bash scripts/gen-index.sh
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 - name: upload to GitHub Release
env: env:

View file

@ -6,7 +6,6 @@ on:
jobs: jobs:
build: build:
if: ${{ !contains(github.ref_name, '-rc.') }}
runs-on: [self-hosted, hestia] runs-on: [self-hosted, hestia]
steps: steps:
- name: checkout - name: checkout
@ -57,13 +56,7 @@ jobs:
- name: regenerate index.json - name: regenerate index.json
env: env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: | run: cd src && bash scripts/gen-index.sh
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 - name: upload to GitHub Release
env: env:

6
.gitignore vendored
View file

@ -5,9 +5,3 @@
# scripts/get.sh for how it's consumed via MINISIGN_SEC_KEY). # scripts/get.sh for how it's consumed via MINISIGN_SEC_KEY).
*.minisign-sec *.minisign-sec
minisign.key 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/

View file

@ -1,24 +0,0 @@
# 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,120 +0,0 @@
# 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.

679
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

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

118
README.md
View file

@ -4,36 +4,20 @@ A collection of Rust tools for the Linux desktop (Hyprland / Wayland / Arch).
Install any product with a single command — no Rust toolchain required. Install any product with a single command — no Rust toolchain required.
```sh ```sh
curl -fsSL https://get.breadway.dev | sh curl https://breadway.dev/get | sh
bakery install breadbar bakery install breadbar
``` ```
## Products ## 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 | | Package | Description |
|---------|-------------| |---------|-------------|
| `bakery` | Bread ecosystem package manager | | `bread` | Reactive automation daemon (`breadd`) + CLI — Lua scripting over Hyprland, udev, power, network, and Bluetooth events |
| `bread-theme` | Shared pywal-accented, fixed-dark-base theming CLI for the bread ecosystem | | `breadbar` | GTK4 status bar (workspaces, clock, CPU/RAM/battery/WiFi/Bluetooth) and D-Bus notification daemon for Hyprland |
| `bread` | Reactive automation daemon and CLI for Linux desktops | | `breadbox` | GTK4 fuzzy app launcher for Hyprland with context-aware sorting; ships an icon-sync daemon (`breadbox-sync`) |
| `breadbar` | Minimal status bar and notification daemon for Hyprland | | `breadcrumbs` | Profile-aware Wi-Fi state machine with Tailscale exit-node management and a self-healing watch daemon |
| `breadbox` | App launcher for Hyprland / Wayland | | `breadpad` | Quick-capture scratchpad popup with AI-powered note classification, reminders, recurrence, and a full note viewer (`breadman`) |
| `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 | | `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 ## Recommended keybinds
@ -84,7 +68,9 @@ spacing, radii, colour roles) the stylesheet is built from.
`bakery` is the package manager for the ecosystem. Install it with the bootstrap script: `bakery` is the package manager for the ecosystem. Install it with the bootstrap script:
```sh ```sh
curl -fsSL https://get.breadway.dev | sh curl https://breadway.dev/get | sh
# or
curl -sSfL 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. The script downloads the prebuilt `bakery` binary to `~/.local/bin/bakery` and prints a note if that directory isn't on your `PATH` yet.
@ -106,26 +92,6 @@ 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`). `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 ## System dependencies by product
`bakery doctor` checks these automatically before any install. Required deps block installation; optional deps generate a warning but never block. `bakery doctor` checks these automatically before any install. Required deps block installation; optional deps generate a warning but never block.
@ -143,66 +109,22 @@ Install all required deps with `sudo pacman -S <packages>`. Use `pacman -Q <pkg>
## Workspace ## Workspace
This repo is a Cargo workspace. Bakery-channel products shipped from here This repo is a Cargo workspace:
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/ bread-ecosystem/
├── bakery/ # package manager binary ├── bakery/ # package manager binary
├── bread-theme/ # shared pywal + fixed-dark-base theming crate ├── bread-theme/ # shared pywal + fixed-dark-base theming crate
├── bread-utils/ # shared plumbing (Hyprland IPC, singleton, XDG, BreadClient, …) ├── registry/ # bread-ecosystem.toml — product registry
├── 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/ └── scripts/
├── get.sh # curl | sh bootstrap ├── 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 ## Release pipeline
Each product repo (`Breadway/bread`, `Breadway/breadbar`, …) has Each product repo (`Breadway/bread`, `Breadway/breadbar`, …) has a
`.forgejo/workflows/release-*.yml` that triggers on `v*` tags. The workflow `.github/workflows/release.yml` that triggers on `v*` tags. The workflow
runs on a self-hosted runner on hestia, builds a stripped x86_64 binary, 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`, deposits it at `dl.breadway.dev/<pkg>/<version>/`, updates `index.json`,
and mirrors the binary to GitHub Releases as a fallback. and mirrors the binary to GitHub Releases as a fallback.
@ -210,12 +132,6 @@ and mirrors the binary to GitHub Releases as a fallback.
`bakery` always tries `dl.breadway.dev` first and transparently falls back `bakery` always tries `dl.breadway.dev` first and transparently falls back
to the GitHub Release URL recorded in the manifest. 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 ### Release artifact contract
Each product's `release.yml` **must** upload the following files alongside Each product's `release.yml` **must** upload the following files alongside

View file

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

View file

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

View file

@ -1,4 +1,3 @@
use crate::ui;
use anyhow::Result; use anyhow::Result;
use std::process::Command; use std::process::Command;
@ -11,48 +10,18 @@ pub struct DepReport {
pub fn check_deps(required: &[String], optional: &[String]) -> Result<DepReport> { pub fn check_deps(required: &[String], optional: &[String]) -> Result<DepReport> {
Ok(DepReport { Ok(DepReport {
missing: required missing: required.iter().filter(|d| !dep_present(d)).cloned().collect(),
.iter() warnings: optional.iter().filter(|d| !dep_present(d)).cloned().collect(),
.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 { fn dep_present(pkg: &str) -> bool {
// Primary: `pacman -Q` uses the exact Arch package name — no name mapping needed. // Primary: `pacman -Q` uses the exact Arch package name — no name mapping needed.
if pacman_installed(pkg) { if pacman_installed(pkg) {
return true; return true;
} }
// Fallback for environments without pacman: native PATH search then pkg-config. // Fallback for environments without pacman: native PATH search then pkg-config.
if path_has(pkg) || pkg_config_exists(pkg) { 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 { fn pacman_installed(pkg: &str) -> bool {
@ -63,17 +32,6 @@ fn pacman_installed(pkg: &str) -> bool {
.unwrap_or(false) .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). /// Check PATH without shelling out to `which` (avoids the external dependency).
fn path_has(bin: &str) -> bool { fn path_has(bin: &str) -> bool {
std::env::var_os("PATH") std::env::var_os("PATH")
@ -90,75 +48,34 @@ fn pkg_config_exists(lib: &str) -> bool {
.unwrap_or(false) .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. /// Print a formatted doctor report for a package's system deps.
/// Returns true if all *required* deps are satisfied. /// Returns true if all *required* deps are satisfied.
pub fn report( pub fn report(package_name: &str, required: &[String], optional: &[String]) -> bool {
package_name: &str,
required: &[String],
optional: &[String],
name_width: usize,
) -> bool {
if required.is_empty() && optional.is_empty() { if required.is_empty() && optional.is_empty() {
ui::check_row(true, package_name, name_width, "no system deps required"); println!(" {package_name}: no system deps required");
return true; return true;
} }
match check_deps(required, optional) { match check_deps(required, optional) {
Err(e) => { Err(e) => {
ui::check_row( eprintln!(" error running doctor for {package_name}: {e}");
false,
package_name,
name_width,
&format!("error running doctor: {e}"),
);
false false
} }
Ok(rep) => { Ok(rep) => {
for warn in &rep.warnings { for warn in &rep.warnings {
eprintln!( eprintln!(
" {}", " {package_name}: optional dep not found: {warn} \
ui::style( (install for full functionality)"
&format!(
"{package_name}: optional dep not found: {warn} \
(install for full functionality)"
),
ui::YELLOW
)
); );
} }
if rep.missing.is_empty() { if rep.missing.is_empty() {
ui::check_row( println!(" {package_name}: all required system deps satisfied");
true,
package_name,
name_width,
"all required system deps satisfied",
);
true true
} else { } else {
ui::check_row(
false,
package_name,
name_width,
&format!("missing: {}", rep.missing.join(", ")),
);
eprintln!( eprintln!(
" {}", " {package_name}: missing system deps: {}",
ui::dim(&format!("install with: {}", install_hint(&rep.missing))) rep.missing.join(", ")
); );
eprintln!(" install with: sudo pacman -S {}", rep.missing.join(" "));
false false
} }
} }
@ -188,38 +105,24 @@ mod tests {
assert!(path_has("sh")); 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] #[test]
fn missing_required_dep_detected() { 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_eq!(rep.missing.len(), 1);
assert!(rep.warnings.is_empty()); assert!(rep.warnings.is_empty());
} }
#[test] #[test]
fn missing_optional_dep_becomes_warning_not_error() { 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!(rep.missing.is_empty());
assert_eq!(rep.warnings.len(), 1); assert_eq!(rep.warnings.len(), 1);
} }

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,34 +1,13 @@
use crate::track::Track;
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use minisign_verify::{PublicKey, Signature}; use minisign_verify::{PublicKey, Signature};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime}; use std::time::{Duration, SystemTime};
const DEFAULT_BASE_URL: &str = "https://dl.breadway.dev"; const PRIMARY_URL: &str = "https://dl.breadway.dev/index.json";
const SIG_URL: &str = "https://dl.breadway.dev/index.json.minisig";
const CACHE_MAX_AGE: Duration = Duration::from_secs(24 * 3600); const CACHE_MAX_AGE: Duration = Duration::from_secs(24 * 3600);
/// The `https://dl.breadway.dev` base can be overridden for local/staging
/// testing (e.g. serving a fake index from `python3 -m http.server`) without
/// rebuilding bakery — same pattern as `main.rs`'s `BAKERY_BIN_DIR` override.
fn base_url() -> String {
std::env::var("BAKERY_INDEX_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string())
}
/// Index URL for `track`. `Stable` keeps the exact pre-track path
/// (`{base}/index.json`) so existing infra and warm caches are unaffected;
/// `Beta`/`Dev` live under a track-prefixed subpath.
fn primary_url(track: Track) -> String {
match track {
Track::Stable => format!("{}/index.json", base_url()),
Track::Beta | Track::Dev => format!("{}/{}/index.json", base_url(), track.as_str()),
}
}
fn sig_url(track: Track) -> String {
format!("{}.minisig", primary_url(track))
}
/// The bakery index-signing public key. /// The bakery index-signing public key.
/// ///
/// The matching secret key is used offline (never on this machine, never in /// The matching secret key is used offline (never on this machine, never in
@ -38,7 +17,7 @@ fn sig_url(track: Track) -> String {
/// bytes are trusted or parsed. This is the single control point: the /// bytes are trusted or parsed. This is the single control point: the
/// per-artifact `sha256` fields and `post_install` hook strings all live /// per-artifact `sha256` fields and `post_install` hook strings all live
/// inside `index.json` itself, so a valid signature transitively covers them. /// inside `index.json` itself, so a valid signature transitively covers them.
const PUBKEY: &str = "RWTBR8w/IJ+jaylOv80b52DzekKbSR2CvOVGvzB0ipGBaMhJPAOiEWq8"; const PUBKEY: &str = "RWRh2Zr5SUinvVFCtD7S7HwGjfrye6j31Xq2mYXRdkGFDWe3yHF7W11K";
/// Verify `bytes` against `sig_text` (the contents of an `index.json.minisig` /// Verify `bytes` against `sig_text` (the contents of an `index.json.minisig`
/// file) using the pinned [`PUBKEY`]. Returns an error on any failure — /// file) using the pinned [`PUBKEY`]. Returns an error on any failure —
@ -52,7 +31,8 @@ fn verify_index_signature(bytes: &[u8], sig_text: &str) -> Result<()> {
/// exercise the verification logic with a throwaway keypair instead of the /// exercise the verification logic with a throwaway keypair instead of the
/// real production key. /// real production key.
fn verify_against_key(bytes: &[u8], sig_text: &str, pubkey_b64: &str) -> Result<()> { 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 = let signature =
Signature::decode(sig_text).context("index.json.minisig is malformed or unreadable")?; Signature::decode(sig_text).context("index.json.minisig is malformed or unreadable")?;
public_key public_key
@ -106,30 +86,6 @@ pub struct Package {
pub config: Option<ConfigScaffold>, pub config: Option<ConfigScaffold>,
#[serde(default)] #[serde(default)]
pub post_install: Vec<String>, 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 { impl Package {
@ -164,8 +120,8 @@ impl Index {
} }
} }
/// Load the manifest for `track`, using the on-disk cache when it is fresh /// Load the manifest, using the on-disk cache when it is fresh enough.
/// enough. Always fetches if `force_refresh` is true. /// Always fetches if `force_refresh` is true.
/// ///
/// Every path — fresh fetch or cached read — verifies the minisign /// Every path — fresh fetch or cached read — verifies the minisign
/// signature over the raw `index.json` bytes before the JSON is parsed or /// signature over the raw `index.json` bytes before the JSON is parsed or
@ -174,73 +130,52 @@ impl Index {
/// (possibly tampered, possibly just stale-format) cache and triggers one /// (possibly tampered, possibly just stale-format) cache and triggers one
/// re-fetch from the network rather than bricking the CLI outright; if the /// re-fetch from the network rather than bricking the CLI outright; if the
/// freshly fetched copy also fails to verify, that's a hard error. /// freshly fetched copy also fails to verify, that's a hard error.
pub fn load(force_refresh: bool, track: Track) -> Result<Index> { pub fn load(force_refresh: bool) -> Result<Index> {
let cache_path = cache_path(track); let cache_path = cache_path();
let sig_cache_path = sig_cache_path(&cache_path); let sig_cache_path = sig_cache_path(&cache_path);
if !force_refresh && cache_is_fresh(&cache_path) { if !force_refresh && cache_is_fresh(&cache_path) {
match read_and_verify_cache(&cache_path, &sig_cache_path, track) { match read_and_verify_cache(&cache_path, &sig_cache_path) {
Ok(index) => return Ok(index), Ok(index) => return Ok(index),
Err(err) => { Err(err) => {
eprintln!(" warning: cached index.json failed verification ({err}), re-fetching…"); eprintln!(
" warning: cached index.json failed verification ({err}), re-fetching…"
);
} }
} }
} }
match fetch_and_cache(&cache_path, &sig_cache_path, track) { fetch_and_cache(&cache_path, &sig_cache_path)
Ok(index) => Ok(index),
Err(fetch_err) => {
// A network error shouldn't be a hard failure when a valid
// signed cache is sitting right there on disk, even if it's
// stale (or freshness was never checked because force_refresh
// was set) — fall back to it rather than bricking the CLI.
match read_and_verify_cache(&cache_path, &sig_cache_path, track) {
Ok(index) => {
eprintln!(
" warning: could not refresh {track} index ({fetch_err}) — \
using possibly-stale cached index"
);
Ok(index)
}
Err(_) => Err(fetch_err),
}
}
}
} }
fn read_and_verify_cache(cache_path: &Path, sig_cache_path: &Path, track: Track) -> Result<Index> { fn read_and_verify_cache(cache_path: &PathBuf, sig_cache_path: &PathBuf) -> Result<Index> {
let bytes = std::fs::read(cache_path).context("reading cached index")?; let bytes = std::fs::read(cache_path).context("reading cached index")?;
let sig_text = std::fs::read_to_string(sig_cache_path) let sig_text = std::fs::read_to_string(sig_cache_path)
.context("reading cached index.json.minisig (cache predates signing support)")?; .context("reading cached index.json.minisig (cache predates signing support)")?;
verify_index_signature(&bytes, &sig_text) verify_index_signature(&bytes, &sig_text)?;
.with_context(|| format!("cached {track} index failed signature verification"))?;
serde_json::from_slice(&bytes).context("parsing cached index") serde_json::from_slice(&bytes).context("parsing cached index")
} }
fn cache_is_fresh(path: &Path) -> bool { fn cache_is_fresh(path: &PathBuf) -> bool {
std::fs::metadata(path) std::fs::metadata(path)
.and_then(|m| m.modified()) .and_then(|m| m.modified())
.map(|t| SystemTime::now().duration_since(t).unwrap_or(CACHE_MAX_AGE) < CACHE_MAX_AGE) .map(|t| SystemTime::now().duration_since(t).unwrap_or(CACHE_MAX_AGE) < CACHE_MAX_AGE)
.unwrap_or(false) .unwrap_or(false)
} }
fn fetch_and_cache(cache_path: &Path, sig_cache_path: &Path, track: Track) -> Result<Index> { fn fetch_and_cache(cache_path: &PathBuf, sig_cache_path: &PathBuf) -> Result<Index> {
let bytes = fetch_bytes(&primary_url(track)).with_context(|| { let bytes = fetch_bytes(PRIMARY_URL)?;
format!( let sig_text = fetch_text(SIG_URL).context(
"fetching {track} index — has a {track} build been published yet? \
run 'bakery track set stable' to switch back"
)
})?;
let sig_text = fetch_text(&sig_url(track)).context(
"fetching index.json.minisig — the index must be signed before it can be trusted", "fetching index.json.minisig — the index must be signed before it can be trusted",
)?; )?;
verify_index_signature(&bytes, &sig_text) verify_index_signature(&bytes, &sig_text)
.with_context(|| format!("freshly fetched {track} index failed signature verification"))?; .context("freshly fetched index.json failed signature verification")?;
bread_utils::atomic::write_atomic_bytes(cache_path, &bytes, None) if let Some(dir) = cache_path.parent() {
.with_context(|| format!("writing cached {track} index"))?; std::fs::create_dir_all(dir)?;
bread_utils::atomic::write_atomic_bytes(sig_cache_path, sig_text.as_bytes(), None) }
.with_context(|| format!("writing cached {track} index signature"))?; std::fs::write(cache_path, &bytes)?;
std::fs::write(sig_cache_path, &sig_text)?;
serde_json::from_slice(&bytes).context("parsing index.json") serde_json::from_slice(&bytes).context("parsing index.json")
} }
@ -251,23 +186,17 @@ fn sig_cache_path(cache_path: &Path) -> PathBuf {
} }
fn fetch_text(url: &str) -> Result<String> { fn fetch_text(url: &str) -> Result<String> {
let bytes = fetch_bytes(url)?; ureq::get(url)
String::from_utf8(bytes).context("response is not valid UTF-8") .call()
.map_err(|e| anyhow::anyhow!("{e}"))?
.into_string()
.context("reading response body")
} }
/// Cache filename for `track`. `Stable` keeps the pre-track filename pub fn cache_path() -> PathBuf {
/// (`index.json`) so an existing warm cache survives an upgrade to a
/// track-aware bakery; `Beta`/`Dev` get their own sibling files so switching
/// tracks doesn't clobber each other's cache.
pub fn cache_path(track: Track) -> PathBuf {
let file_name = match track {
Track::Stable => "index.json".to_string(),
Track::Beta | Track::Dev => format!("index-{}.json", track.as_str()),
};
dirs::cache_dir() dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from("~/.cache")) .unwrap_or_else(|| PathBuf::from("~/.cache"))
.join("bakery") .join("bakery/index.json")
.join(file_name)
} }
/// Download a binary blob from `primary_url`, falling back to `fallback_url` /// Download a binary blob from `primary_url`, falling back to `fallback_url`
@ -277,69 +206,27 @@ pub fn fetch_binary(primary_url: &str, fallback_url: &str) -> Result<Vec<u8>> {
Ok(bytes) => Ok(bytes), Ok(bytes) => Ok(bytes),
Err(primary_err) => { Err(primary_err) => {
eprintln!( eprintln!(
" {}", " primary URL failed ({}), trying GitHub fallback…",
crate::ui::note(&format!( primary_err
"primary URL failed ({primary_err}), trying GitHub fallback…"
))
); );
fetch_bytes(fallback_url).context("both primary and GitHub fallback failed") 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>> { fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
use std::io::{IsTerminal, Read}; use std::io::Read;
let resp = ureq::get(url).call().map_err(|e| anyhow::anyhow!("{e}"))?; let resp = ureq::get(url)
.call()
.map_err(|e| anyhow::anyhow!("{e}"))?;
let status = resp.status(); let status = resp.status();
if status != 200 { if status != 200 {
bail!("HTTP {status} from {url}"); 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(); let mut buf = Vec::new();
let mut reader = resp.into_reader(); resp.into_reader()
let mut chunk = [0u8; CHUNK_SIZE]; .read_to_end(&mut buf)
let mut last_print = std::time::Instant::now(); .context("reading response")?;
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) Ok(buf)
} }
@ -389,80 +276,4 @@ znmVfINB4jFDR2a4wuY8rOKlUBeSDOFjMkHYDXV3vxvAjK+r4V12ae9ZRQkfVtQ1YIEmFXbnJfbxywg+
// it must at least parse as a valid minisign public key. // it must at least parse as a valid minisign public key.
PublicKey::from_base64(PUBKEY).expect("PUBKEY must be a valid minisign public key"); PublicKey::from_base64(PUBKEY).expect("PUBKEY must be a valid minisign public key");
} }
#[test]
fn stable_cache_path_matches_pre_track_filename() {
// Must stay exactly "index.json" so an existing warm cache from a
// pre-track bakery binary is still used after an upgrade.
assert_eq!(cache_path(Track::Stable).file_name().unwrap(), "index.json");
}
#[test]
fn beta_and_dev_cache_paths_are_distinct_siblings() {
let stable = cache_path(Track::Stable);
let beta = cache_path(Track::Beta);
let dev = cache_path(Track::Dev);
assert_ne!(stable, beta);
assert_ne!(stable, dev);
assert_ne!(beta, dev);
assert_eq!(beta.parent(), stable.parent());
assert_eq!(dev.parent(), stable.parent());
}
#[test]
fn stable_url_has_no_track_prefix() {
assert_eq!(
primary_url(Track::Stable),
format!("{}/index.json", base_url())
);
}
#[test]
fn beta_and_dev_urls_are_track_prefixed() {
assert_eq!(
primary_url(Track::Beta),
format!("{}/beta/index.json", base_url())
);
assert_eq!(
primary_url(Track::Dev),
format!("{}/dev/index.json", base_url())
);
}
fn minimal_package_json() -> &'static str {
r#"{
"name": "breadhelp",
"description": "test",
"version": "1.0.0",
"binaries": [],
"config": null
}"#
}
#[test]
fn license_and_desktop_fields_default_to_none_on_old_shape_json() {
// Simulates an index.json produced before license_file/desktop_file
// existed — must not fail to parse.
let pkg: Package = serde_json::from_str(minimal_package_json()).unwrap();
assert!(pkg.license_file.is_none());
assert!(pkg.license_file_sha256.is_none());
assert!(pkg.desktop_file.is_none());
assert!(pkg.desktop_file_sha256.is_none());
}
#[test]
fn license_and_desktop_fields_roundtrip() {
let mut pkg: Package = serde_json::from_str(minimal_package_json()).unwrap();
pkg.license_file = Some("LICENSE".to_string());
pkg.license_file_sha256 = Some("abc123".to_string());
pkg.desktop_file = Some("breadhelp.desktop".to_string());
pkg.desktop_file_sha256 = Some("def456".to_string());
let json = serde_json::to_string(&pkg).unwrap();
let restored: Package = serde_json::from_str(&json).unwrap();
assert_eq!(restored.license_file.as_deref(), Some("LICENSE"));
assert_eq!(restored.license_file_sha256.as_deref(), Some("abc123"));
assert_eq!(restored.desktop_file.as_deref(), Some("breadhelp.desktop"));
assert_eq!(restored.desktop_file_sha256.as_deref(), Some("def456"));
}
} }

View file

@ -1,546 +0,0 @@
//! 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,6 +1,4 @@
use crate::track::Track;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use fs4::FileExt;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
@ -12,34 +10,10 @@ pub struct InstalledPackage {
pub binaries: Vec<String>, pub binaries: Vec<String>,
pub services: Vec<String>, pub services: Vec<String>,
pub installed_at: 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)] #[derive(Debug, Default, Deserialize, Serialize)]
pub struct State { pub struct State {
// `#[serde(default)]` lets an installed.json written by a pre-track
// bakery binary deserialize straight into Track::Stable with no
// migration step.
#[serde(default)]
pub track: Track,
pub packages: HashMap<String, InstalledPackage>, pub packages: HashMap<String, InstalledPackage>,
} }
@ -55,36 +29,16 @@ impl State {
pub fn save(&self) -> Result<()> { pub fn save(&self) -> Result<()> {
let path = state_path(); let path = state_path();
let text = serde_json::to_string_pretty(self)?; if let Some(dir) = path.parent() {
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)?; std::fs::create_dir_all(dir)?;
} }
let lock_file = std::fs::OpenOptions::new() let text = serde_json::to_string_pretty(self)?;
.create(true) // Write to a temp file then rename for atomicity — avoids a torn write
.write(true) // if the process is killed mid-save.
.truncate(false) let tmp = path.with_extension("tmp");
.open(&lock_path) std::fs::write(&tmp, &text).context("writing installed.json.tmp")?;
.context("opening installed.json.lock")?; std::fs::rename(&tmp, &path).context("atomically replacing installed.json")?;
lock_file Ok(())
.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 { pub fn is_installed(&self, name: &str) -> bool {
@ -98,40 +52,16 @@ impl State {
pub fn remove(&mut self, name: &str) -> Option<InstalledPackage> { pub fn remove(&mut self, name: &str) -> Option<InstalledPackage> {
self.packages.remove(name) self.packages.remove(name)
} }
pub fn set_track(&mut self, track: Track) {
self.track = track;
}
}
fn state_base_dir() -> PathBuf {
dirs::state_dir().unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("~"))
.join(".local/state")
})
} }
fn state_path() -> PathBuf { fn state_path() -> PathBuf {
bakery_state_dir().join("installed.json") dirs::state_dir()
} .unwrap_or_else(|| {
dirs::home_dir()
/// Per-user bakery state dir (`~/.local/state/bakery`). Independent of the .unwrap_or_else(|| PathBuf::from("~"))
/// install prefix — system-prefix installs still record what this user asked for. .join(".local/state")
pub fn bakery_state_dir() -> PathBuf { })
state_base_dir().join("bakery") .join("bakery/installed.json")
}
/// 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)] #[cfg(test)]
@ -145,9 +75,6 @@ mod tests {
binaries: vec![], binaries: vec![],
services: vec![], services: vec![],
installed_at: "2026-01-01T00:00:00Z".to_string(), installed_at: "2026-01-01T00:00:00Z".to_string(),
track: Track::Stable,
previous_version: None,
binary_sha256: HashMap::new(),
} }
} }
@ -174,23 +101,6 @@ mod tests {
assert!(state.remove("nope").is_none()); assert!(state.remove("nope").is_none());
} }
#[test]
fn track_defaults_to_stable_on_old_shape_json() {
// Simulates installed.json written before the track field existed.
let old_shape = r#"{"packages":{}}"#;
let state: State = serde_json::from_str(old_shape).unwrap();
assert_eq!(state.track, Track::Stable);
}
#[test]
fn set_track_updates_and_roundtrips() {
let mut state = State::default();
state.set_track(Track::Dev);
let json = serde_json::to_string(&state).unwrap();
let restored: State = serde_json::from_str(&json).unwrap();
assert_eq!(restored.track, Track::Dev);
}
#[test] #[test]
fn json_roundtrip() { fn json_roundtrip() {
let mut state = State::default(); let mut state = State::default();
@ -200,76 +110,11 @@ mod tests {
binaries: vec!["bar".to_string()], binaries: vec!["bar".to_string()],
services: vec!["bar.service".to_string()], services: vec!["bar.service".to_string()],
installed_at: "2026-06-01T00:00:00Z".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 json = serde_json::to_string(&state).unwrap();
let restored: State = serde_json::from_str(&json).unwrap(); let restored: State = serde_json::from_str(&json).unwrap();
assert!(restored.is_installed("bar")); assert!(restored.is_installed("bar"));
assert_eq!(restored.packages["bar"].version, "2.0.0"); assert_eq!(restored.packages["bar"].version, "2.0.0");
assert_eq!(restored.packages["bar"].services, ["bar.service"]); 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

@ -1,85 +0,0 @@
use clap::ValueEnum;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::str::FromStr;
/// Which build of a package bakery follows: the tagged stable release, a
/// deliberately-promoted beta, or the continuously-published `dev` branch
/// build. Not to be confused with the *distribution* channel (bakery vs.
/// pacman) documented in `docs/release-channels.md` — that's an orthogonal,
/// pre-existing use of the word "channel", which is why this is called a
/// "track" instead.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize, ValueEnum)]
#[serde(rename_all = "lowercase")]
pub enum Track {
#[default]
Stable,
Beta,
Dev,
}
impl Track {
pub fn as_str(&self) -> &'static str {
match self {
Track::Stable => "stable",
Track::Beta => "beta",
Track::Dev => "dev",
}
}
}
impl fmt::Display for Track {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for Track {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"stable" => Ok(Track::Stable),
"beta" => Ok(Track::Beta),
"dev" => Ok(Track::Dev),
other => Err(format!("unknown track '{other}' — expected stable, beta, or dev")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_stable() {
assert_eq!(Track::default(), Track::Stable);
}
#[test]
fn display_roundtrips_through_from_str() {
for track in [Track::Stable, Track::Beta, Track::Dev] {
let s = track.to_string();
assert_eq!(s.parse::<Track>().unwrap(), track);
}
}
#[test]
fn from_str_is_case_insensitive() {
assert_eq!("DEV".parse::<Track>().unwrap(), Track::Dev);
assert_eq!("Beta".parse::<Track>().unwrap(), Track::Beta);
}
#[test]
fn from_str_rejects_unknown() {
assert!("nightly".parse::<Track>().is_err());
}
#[test]
fn json_roundtrip_uses_lowercase() {
let json = serde_json::to_string(&Track::Dev).unwrap();
assert_eq!(json, "\"dev\"");
let back: Track = serde_json::from_str(&json).unwrap();
assert_eq!(back, Track::Dev);
}
}

View file

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

View file

@ -1,20 +0,0 @@
[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"]

View file

@ -1,121 +0,0 @@
//! 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();
}
}

View file

@ -1,145 +0,0 @@
//! 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"),
}
}
}

View file

@ -1,67 +0,0 @@
//! 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};

View file

@ -1,21 +0,0 @@
[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

@ -1,233 +0,0 @@
//! 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));
}
}

View file

@ -1,262 +0,0 @@
//! 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 })
}

View file

@ -1,26 +0,0 @@
[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

@ -1,151 +0,0 @@
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()
}

View file

@ -1,297 +0,0 @@
//! 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

@ -1,126 +0,0 @@
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

@ -1,26 +0,0 @@
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

@ -1,141 +0,0 @@
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");
}
}

View file

@ -1,63 +0,0 @@
//! 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

@ -1,387 +0,0 @@
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

@ -1,50 +0,0 @@
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
}

View file

@ -1,391 +0,0 @@
//! 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,12 +32,3 @@ anyhow = { workspace = true }
[dev-dependencies] [dev-dependencies]
tempfile = "3" 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"] }

View file

@ -1,27 +0,0 @@
[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"] }

View file

@ -1,11 +0,0 @@
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

@ -1,12 +0,0 @@
[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

@ -1,10 +0,0 @@
# 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

View file

@ -1,370 +0,0 @@
//! 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);
}
}

View file

@ -1,109 +0,0 @@
//! 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."
}

View file

@ -1,205 +0,0 @@
//! `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

@ -1,128 +0,0 @@
//! 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);
}
}

View file

@ -1,10 +0,0 @@
//! 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;

View file

@ -1,99 +0,0 @@
//! 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

@ -1,49 +0,0 @@
//! 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);
}
}

View file

@ -1,286 +0,0 @@
//! 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

@ -1,14 +0,0 @@
[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

@ -1,30 +0,0 @@
//! 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,48 +1,11 @@
# bread-theme changelog # 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 ## Coordinated bump policy
`bread-theme` is consumed by `breadbar`, `breadbox`, `breadpad`, and the other `bread-theme` is consumed by `breadbar`, `breadbox`, and `breadpad` as a pinned
GTK bread apps as a pinned git dependency. A breaking change to `Palette`, git dependency. A breaking change to `Palette`, `css_vars`, or the `gtk` feature
`css_vars`, or the `gtk` feature API requires dependents to bump their API requires all three dependents to bump their `Cargo.toml` git tag and cut a
`Cargo.toml` git tag and cut a release together. Note the impact in this file release together. Note the impact in this file before tagging.
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,30 +12,12 @@ keywords = ["theming", "pywal", "gtk4", "wayland"]
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
dirs = { 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 } 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] [features]
# Enable GTK4 CSS provider helpers (breadbar, breadbox, breadpad use this). # Enable GTK4 CSS provider helpers (breadbar, breadbox, breadpad use this).
# bread (daemon) and breadcrumbs (CLI) depend on this crate without the feature. # bread (daemon) and breadcrumbs (CLI) depend on this crate without the feature.
gtk = ["dep:gtk4"] 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 # The generator CLI. It only touches the gtk-free lib API (render + write), so
# it builds without the gtk feature and stays light. # it builds without the gtk feature and stays light.

View file

@ -1,117 +0,0 @@
/* 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

@ -1,281 +0,0 @@
# 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

@ -1,86 +0,0 @@
/* 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

@ -1,198 +0,0 @@
# 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

@ -1,104 +0,0 @@
/* 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

@ -1,204 +0,0 @@
# 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

@ -1,73 +0,0 @@
/* 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

@ -1,213 +0,0 @@
# 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.

View file

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

View file

@ -1,142 +0,0 @@
//! `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,10 +9,6 @@
//! # signal every running bread GUI to recolour //! # signal every running bread GUI to recolour
//! bread-theme path # print the stylesheet path //! bread-theme path # print the stylesheet path
//! bread-theme print # render to stdout (no write) //! 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; use std::process::ExitCode;
@ -29,179 +25,6 @@ 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 { fn main() -> ExitCode {
let cmd = std::env::args().nth(1).unwrap_or_else(|| "generate".into()); let cmd = std::env::args().nth(1).unwrap_or_else(|| "generate".into());
match cmd.as_str() { match cmd.as_str() {
@ -219,16 +42,20 @@ fn main() -> ExitCode {
// the file monitor in every running bread GUI, so they all re-read the // 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. // palette and recolour live — shared widgets *and* each app's own rules.
"reload" => write_and_report("reloaded"), "reload" => write_and_report("reloaded"),
"generate-output" => generate_output_cmd(),
"layerrules" => layerrules_cmd(),
"-h" | "--help" | "help" => { "-h" | "--help" | "help" => {
print_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()
);
ExitCode::SUCCESS ExitCode::SUCCESS
} }
other => { other => {
eprintln!( eprintln!("bread-theme: unknown command '{other}' (try generate|reload|path|print)");
"bread-theme: unknown command '{other}' (try generate|reload|path|print|generate-output|layerrules)"
);
ExitCode::FAILURE ExitCode::FAILURE
} }
} }

View file

@ -1,22 +1,8 @@
use gtk4::gdk::prelude::*;
use gtk4::gio; use gtk4::gio;
use gtk4::glib::object::ObjectType;
use gtk4::prelude::*; use gtk4::prelude::*;
use gtk4::CssProvider; use gtk4::CssProvider;
use std::cell::RefCell; use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::path::Path; 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! { thread_local! {
static SHARED_PROVIDER: RefCell<Option<CssProvider>> = const { RefCell::new(None) }; static SHARED_PROVIDER: RefCell<Option<CssProvider>> = const { RefCell::new(None) };
@ -28,7 +14,8 @@ thread_local! {
} }
fn reload_shared() { 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)); SHARED_PROVIDER.with(|cell| apply_css(&css, cell));
} }
@ -127,345 +114,6 @@ 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 /// 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. /// file is absent so stale overrides don't persist across SIGHUP reloads.
pub fn apply_user_css(path: &Path, provider: &RefCell<Option<CssProvider>>) { pub fn apply_user_css(path: &Path, provider: &RefCell<Option<CssProvider>>) {

View file

@ -1,211 +0,0 @@
//! 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,35 +1,9 @@
#[cfg(feature = "adw")] pub mod palette;
pub mod adw;
#[cfg(feature = "gtk")]
pub mod anim;
#[cfg(feature = "gtk")] #[cfg(feature = "gtk")]
pub mod 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}; 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. /// Design tokens from BREAD_DESIGN_SYSTEM.md.
pub mod tokens { pub mod tokens {
pub const FONT_FAMILY: &str = "Varela Round, sans-serif"; pub const FONT_FAMILY: &str = "Varela Round, sans-serif";
@ -50,13 +24,6 @@ pub mod tokens {
pub const RADIUS_PILL: u16 = 999; 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 /// Emit the `@define-color` block that all bread apps use, plus the shared
/// font rule. /// font rule.
/// ///
@ -72,9 +39,9 @@ fn css_font_family() -> &'static str {
/// one color-block implementation and it cannot drift again. /// one color-block implementation and it cannot drift again.
pub fn css_vars(p: &Palette) -> String { pub fn css_vars(p: &Palette) -> String {
format!( format!(
"{vars}* {{ font-family: {font}; font-size: {size}px; }}\n", "{vars}* {{ font-family: '{font}'; font-size: {size}px; }}\n",
vars = define_colors(p), vars = define_colors(p),
font = css_font_family(), font = tokens::FONT_FAMILY,
size = tokens::FONT_SIZE_BASE, size = tokens::FONT_SIZE_BASE,
) )
} }
@ -84,11 +51,7 @@ pub fn luminance(hex: &str) -> f32 {
let h = hex.trim_start_matches('#'); let h = hex.trim_start_matches('#');
let lin = |i: usize| -> f32 { 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; 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 { if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}; };
0.2126 * lin(0) + 0.7152 * lin(2) + 0.0722 * lin(4) 0.2126 * lin(0) + 0.7152 * lin(2) + 0.0722 * lin(4)
} }
@ -99,101 +62,44 @@ pub fn luminance(hex: &str) -> f32 {
/// text readable no matter how light or dark pywal makes a given palette slot, /// text readable no matter how light or dark pywal makes a given palette slot,
/// without altering the palette colours themselves. /// without altering the palette colours themselves.
pub fn ink_on(hex: &str) -> &'static str { pub fn ink_on(hex: &str) -> &'static str {
if luminance(hex) > 0.179 { if luminance(hex) > 0.179 { "#11111b" } else { "#f5f5f5" }
"#11111b"
} else {
"#f5f5f5"
}
} }
/// Canonical (name, value) list: the single naming all bread apps share. /// Canonical `@define-color` block: the single naming all bread apps share.
/// `surface` = color0 (darkest surface), `overlay` = color7 (muted), and /// `surface` = color0 (darkest surface), `overlay` = color7 (muted), and
/// `accent` = color4. Apps must use these names, not raw palette slots, so the /// `accent` = color4. Apps must use these names, not raw palette slots, so the
/// whole ecosystem recolours together. /// whole ecosystem recolours together.
/// ///
/// The `on-*` colours are computed ink (black/white) guaranteed to be legible on /// The `on-*` colours are computed ink (black/white) guaranteed to be legible on
/// the matching background — use `on-surface` for text on a `surface` panel, /// the matching background — use `@on-surface` for text on a `@surface` panel,
/// `on-accent` on an `accent` button, etc. They exist because pywal can emit a /// `@on-accent` on an `@accent` button, etc. They exist because pywal can emit a
/// light value in any slot, and white text on a light surface disappears. /// light value in any slot, and white text on a light surface disappears.
///
/// [`define_colors`] (GTK `@define-color`) and [`css_custom_properties`] (web
/// `:root { --name: ... }`) both format this same list rather than each
/// hand-writing their own — see `css_vars_and_stylesheet_agree_on_color_block`
/// and `css_custom_properties_matches_define_colors_name_set` for the
/// regression tests this exists to satisfy.
fn color_pairs(p: &Palette) -> [(&'static str, String); 16] {
[
("bg", p.background.clone()),
("fg", p.foreground.clone()),
("surface", p.color0.clone()),
("overlay", p.color7.clone()),
("accent", p.color4.clone()),
("red", p.color1.clone()),
("green", p.color2.clone()),
("yellow", p.color3.clone()),
("blue", p.color4.clone()),
("pink", p.color5.clone()),
("teal", p.color6.clone()),
("on-bg", ink_on(&p.background).to_string()),
("on-surface", ink_on(&p.color0).to_string()),
("on-accent", ink_on(&p.color4).to_string()),
("on-red", ink_on(&p.color1).to_string()),
("on-overlay", ink_on(&p.color7).to_string()),
]
}
/// GTK `@define-color` block built from [`color_pairs`].
fn define_colors(p: &Palette) -> String { fn define_colors(p: &Palette) -> String {
color_pairs(p)
.iter()
.map(|(name, value)| format!("@define-color {name} {value};\n"))
.collect()
}
/// CSS custom-properties block (`:root { --bg: ...; --on-accent: ...; }`) for
/// web frontends (Tauri), using the exact same names as [`define_colors`] so
/// the GTK and web outputs cannot drift apart independently — both are
/// generated from [`color_pairs`], not two hand-written copies.
pub fn css_custom_properties(p: &Palette) -> String {
let vars: String = color_pairs(p)
.iter()
.map(|(name, value)| format!(" --{name}: {value};\n"))
.collect();
format!(":root {{\n{vars}}}\n")
}
/// CSS custom-properties for [`tokens`] (font, spacing, radii) — the web
/// counterpart to [`tokens`] being hand-read by GTK code, so a web frontend
/// isn't hand-copying the same numbers into a second source of truth.
pub fn css_tokens() -> String {
use tokens::*;
format!( format!(
":root {{\n\ "@define-color bg {bg};\n\
\x20\x20--font-family: {font};\n\ @define-color fg {fg};\n\
\x20\x20--font-size-base: {base}px;\n\ @define-color surface {c0};\n\
\x20\x20--font-size-secondary: {sec}px;\n\ @define-color overlay {c7};\n\
\x20\x20--space-xs: {xs}px;\n\ @define-color accent {c4};\n\
\x20\x20--space-sm: {sm}px;\n\ @define-color red {c1};\n\
\x20\x20--space-md: {md}px;\n\ @define-color green {c2};\n\
\x20\x20--space-lg: {lg}px;\n\ @define-color yellow {c3};\n\
\x20\x20--space-xl: {xl}px;\n\ @define-color blue {c4};\n\
\x20\x20--radius-primary: {r1}px;\n\ @define-color pink {c5};\n\
\x20\x20--radius-secondary: {r2}px;\n\ @define-color teal {c6};\n\
\x20\x20--radius-tertiary: {r3}px;\n\ @define-color on-bg {on_bg};\n\
\x20\x20--radius-pill: {pill}px;\n\ @define-color on-surface {on_surface};\n\
}}\n", @define-color on-accent {on_accent};\n\
font = css_font_family(), @define-color on-red {on_red};\n\
base = FONT_SIZE_BASE, @define-color on-overlay {on_overlay};\n",
sec = FONT_SIZE_SECONDARY, bg = p.background, fg = p.foreground,
xs = SPACE_XS, c0 = p.color0, c1 = p.color1, c2 = p.color2, c3 = p.color3,
sm = SPACE_SM, c4 = p.color4, c5 = p.color5, c6 = p.color6, c7 = p.color7,
md = SPACE_MD, on_bg = ink_on(&p.background),
lg = SPACE_LG, on_surface = ink_on(&p.color0),
xl = SPACE_XL, on_accent = ink_on(&p.color4),
r1 = RADIUS_PRIMARY, on_red = ink_on(&p.color1),
r2 = RADIUS_SECONDARY, on_overlay = ink_on(&p.color7),
r3 = RADIUS_TERTIARY,
pill = RADIUS_PILL,
) )
} }
@ -207,27 +113,16 @@ pub fn stylesheet(p: &Palette) -> String {
use tokens::*; use tokens::*;
format!( format!(
"{vars}\ "{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,\ /* 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\ button, or accent is always the legible ink for that background. Bare\
`label {{ color }}` is deliberately avoided as a type selector it\ `label {{ color }}` is deliberately avoided as a type selector it\
would override a container's colour on its own child labels. */\n\ would override a container's colour on its own child labels. */\n\
window {{ background-color: @bg; color: @on-bg; }}\n\ window {{ background-color: @bg; color: @on-bg; }}\n\
.dim-label, .dim {{ opacity: 0.6; font-size: {sec}px; }}\n\ .dim-label, .dim {{ opacity: 0.6; font-size: {sec}px; }}\n\
/* Named `.page-title`, not the more obvious `.title` - libadwaita's\ .title {{ font-size: 1.4em; font-weight: bold; }}\n\
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\ .heading {{ font-weight: bold; opacity: 0.85; }}\n\
/* Same libadwaita-collision reasoning as `.page-title` above - a bare\ .subtitle {{ opacity: 0.7; font-size: {sec}px; }}\n\
`.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;\ button {{ background-color: @surface; color: @on-surface; border: none;\
border-radius: {r1}px; padding: {sm}px {lg}px; }}\n\ border-radius: {r1}px; padding: {sm}px {lg}px; }}\n\
button:hover {{ background-color: alpha(@on-surface, 0.14); }}\n\ button:hover {{ background-color: alpha(@on-surface, 0.14); }}\n\
@ -236,15 +131,8 @@ pub fn stylesheet(p: &Palette) -> String {
button.flat {{ background-color: transparent; color: @on-bg; }}\n\ button.flat {{ background-color: transparent; color: @on-bg; }}\n\
button.suggested-action {{ background-color: @accent; color: @on-accent; }}\n\ button.suggested-action {{ background-color: @accent; color: @on-accent; }}\n\
button.suggested-action:hover {{ background-color: alpha(@accent, 0.85); }}\n\ button.suggested-action:hover {{ background-color: alpha(@accent, 0.85); }}\n\
/* Deliberately NOT @red: pywal can hand `red` any hue depending on\ button.destructive-action {{ background-color: @red; color: @on-red; }}\n\
the wallpaper (a blue-toned wallpaper's \"red\" slot can literally\ button.destructive-action:hover {{ background-color: alpha(@red, 0.85); }}\n\
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;\ entry, spinbutton {{ background-color: @surface; color: @on-surface;\
border: 1px solid @overlay; border-radius: {r2}px;\ border: 1px solid @overlay; border-radius: {r2}px;\
padding: {xs}px {sm}px; caret-color: @on-surface; }}\n\ padding: {xs}px {sm}px; caret-color: @on-surface; }}\n\
@ -255,23 +143,7 @@ pub fn stylesheet(p: &Palette) -> String {
switch {{ background-color: @overlay; border-radius: {pill}px; }}\n\ switch {{ background-color: @overlay; border-radius: {pill}px; }}\n\
switch:checked {{ background-color: @accent; }}\n\ switch:checked {{ background-color: @accent; }}\n\
switch slider {{ background-color: @on-surface; border-radius: {pill}px; }}\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\ 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 {{ border-radius: {r2}px; }}\n\
row:selected, list row:selected {{ background-color: @accent; color: @on-accent; }}\n\ row:selected, list row:selected {{ background-color: @accent; color: @on-accent; }}\n\
.sidebar {{ background-color: @surface; color: @on-surface; }}\n\ .sidebar {{ background-color: @surface; color: @on-surface; }}\n\
@ -290,7 +162,7 @@ pub fn stylesheet(p: &Palette) -> String {
textview, .mono {{ font-family: monospace; }}\n\ textview, .mono {{ font-family: monospace; }}\n\
textview text {{ background-color: @surface; color: @on-surface; }}\n", textview text {{ background-color: @surface; color: @on-surface; }}\n",
vars = define_colors(p), vars = define_colors(p),
font = css_font_family(), font = FONT_FAMILY,
base = FONT_SIZE_BASE, base = FONT_SIZE_BASE,
sec = FONT_SIZE_SECONDARY, sec = FONT_SIZE_SECONDARY,
xs = SPACE_XS, sm = SPACE_SM, md = SPACE_MD, lg = SPACE_LG, xs = SPACE_XS, sm = SPACE_SM, md = SPACE_MD, lg = SPACE_LG,
@ -309,34 +181,28 @@ pub fn render() -> String {
/// `bread-theme generate` CLI writes it. Per-session under `XDG_RUNTIME_DIR`, /// `bread-theme generate` CLI writes it. Per-session under `XDG_RUNTIME_DIR`,
/// falling back to the cache dir. /// falling back to the cache dir.
pub fn shared_css_path() -> std::path::PathBuf { pub fn shared_css_path() -> std::path::PathBuf {
output::runtime_bread_dir().join("theme.css") 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")
} }
/// Write the shared stylesheet to [`shared_css_path`] (atomic rename). Returns /// Write the shared stylesheet to [`shared_css_path`] (atomic rename). Returns
/// the path written. Used by the `bread-theme` CLI. /// the path written. Used by the `bread-theme` CLI.
pub fn write_shared_css() -> std::io::Result<std::path::PathBuf> { pub fn write_shared_css() -> std::io::Result<std::path::PathBuf> {
write_shared_css_from(&load_palette()) let path = shared_css_path();
} if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
/// `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 let tmp = path.with_extension("css.tmp");
std::fs::write(&tmp, render())?;
std::fs::rename(&tmp, &path)?;
Ok(path)
} }
/// Convert a `#rrggbb` hex colour to `rgba(r, g, b, alpha)`. /// Convert a `#rrggbb` hex colour to `rgba(r, g, b, alpha)`.
@ -355,24 +221,15 @@ mod tests {
#[test] #[test]
fn css_vars_contains_all_define_color_names() { fn css_vars_contains_all_define_color_names() {
let css = css_vars(&Palette::default()); let css = css_vars(&Palette::default());
for name in &[ for name in &["bg", "fg", "surface", "red", "green", "yellow", "blue", "pink", "teal", "overlay"] {
"bg", "fg", "surface", "red", "green", "yellow", "blue", "pink", "teal", "overlay", assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
] {
assert!(
css.contains(&format!("@define-color {name} ")),
"missing @define-color {name}"
);
} }
} }
#[test] #[test]
fn css_vars_contains_font_rule() { fn css_vars_contains_font_rule() {
let css = css_vars(&Palette::default()); let css = css_vars(&Palette::default());
assert!(css.contains("font-family: 'Varela Round', sans-serif;")); assert!(css.contains("Varela Round"));
assert!(
!css.contains("font-family: 'Varela Round, sans-serif'"),
"named face and generic fallback must not be one quoted family"
);
assert!(css.contains("14px")); assert!(css.contains("14px"));
} }
@ -385,18 +242,8 @@ mod tests {
// color name — the illegible-text bug. css_vars() must now emit // color name — the illegible-text bug. css_vars() must now emit
// exactly the same color set as the full stylesheet. // exactly the same color set as the full stylesheet.
let css = css_vars(&Palette::default()); let css = css_vars(&Palette::default());
for name in &[ for name in &["accent", "on-bg", "on-surface", "on-accent", "on-red", "on-overlay"] {
"accent", assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
"on-bg",
"on-surface",
"on-accent",
"on-red",
"on-overlay",
] {
assert!(
css.contains(&format!("@define-color {name} ")),
"missing @define-color {name}"
);
} }
} }
@ -407,16 +254,7 @@ mod tests {
let p = Palette::default(); let p = Palette::default();
let vars = css_vars(&p); let vars = css_vars(&p);
let sheet = stylesheet(&p); let sheet = stylesheet(&p);
for name in &[ for name in &["bg", "fg", "surface", "overlay", "accent", "on-bg", "on-surface", "on-accent"] {
"bg",
"fg",
"surface",
"overlay",
"accent",
"on-bg",
"on-surface",
"on-accent",
] {
let needle = format!("@define-color {name} "); let needle = format!("@define-color {name} ");
assert!(vars.contains(&needle) && sheet.contains(&needle)); assert!(vars.contains(&needle) && sheet.contains(&needle));
} }
@ -426,67 +264,13 @@ mod tests {
fn stylesheet_defines_canonical_colors_and_components() { fn stylesheet_defines_canonical_colors_and_components() {
let css = stylesheet(&Palette::default()); let css = stylesheet(&Palette::default());
for name in &["bg", "fg", "surface", "overlay", "accent", "red", "blue"] { for name in &["bg", "fg", "surface", "overlay", "accent", "red", "blue"] {
assert!( assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
css.contains(&format!("@define-color {name} ")),
"missing @define-color {name}"
);
} }
// a representative spread of the shared component selectors // a representative spread of the shared component selectors
for sel in &[ for sel in &["button", "entry", "switch:checked", ".card", ".sidebar", "scrollbar slider", ".title"] {
"button",
"entry",
"switch:checked",
".card",
".sidebar",
"scrollbar slider",
".page-title",
] {
assert!(css.contains(sel), "stylesheet missing selector: {sel}"); assert!(css.contains(sel), "stylesheet missing selector: {sel}");
} }
assert!(css.contains("font-family: 'Varela Round', sans-serif;")); assert!(css.contains("Varela Round"));
assert!(
!css.contains("font-family: 'Varela Round, sans-serif'"),
"named face and generic fallback must not be one quoted family"
);
}
#[test]
fn css_custom_properties_matches_define_colors_name_set() {
// Both must derive from the same color_pairs() list, so the web
// output can't drift from the GTK one the way css_vars/stylesheet
// used to (see css_vars_and_stylesheet_agree_on_color_block above).
let p = Palette::default();
let gtk = define_colors(&p);
let web = css_custom_properties(&p);
for (name, _) in color_pairs(&p) {
assert!(
gtk.contains(&format!("@define-color {name} ")),
"gtk missing {name}"
);
assert!(web.contains(&format!("--{name}: ")), "web missing {name}");
}
}
#[test]
fn css_custom_properties_is_valid_root_block() {
let p = Palette::default();
let css = css_custom_properties(&p);
assert!(css.starts_with(":root {\n"));
assert!(css.trim_end().ends_with('}'));
assert!(css.contains(&format!("--accent: {};", p.color4)));
}
#[test]
fn css_tokens_contains_font_and_spacing_vars() {
let css = css_tokens();
assert!(css.contains("--font-family: 'Varela Round', sans-serif;"));
assert!(
!css.contains("--font-family: 'Varela Round, sans-serif'"),
"named face and generic fallback must not be one quoted family"
);
assert!(css.contains("--font-size-base: 14px;"));
assert!(css.contains("--space-md: 12px;"));
assert!(css.contains("--radius-pill: 999px;"));
} }
#[test] #[test]
@ -513,10 +297,7 @@ mod tests {
fn stylesheet_defines_on_colors() { fn stylesheet_defines_on_colors() {
let css = stylesheet(&Palette::default()); let css = stylesheet(&Palette::default());
for name in &["on-bg", "on-surface", "on-accent", "on-red", "on-overlay"] { for name in &["on-bg", "on-surface", "on-accent", "on-red", "on-overlay"] {
assert!( assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
css.contains(&format!("@define-color {name} ")),
"missing @define-color {name}"
);
} }
} }
@ -525,60 +306,13 @@ mod tests {
// A bare `label { color: ... }` would override container colours on child // A bare `label { color: ... }` would override container colours on child
// labels — the bug that made coloured-background text illegible. // labels — the bug that made coloured-background text illegible.
let css = stylesheet(&Palette::default()); let css = stylesheet(&Palette::default());
assert!( assert!(!css.contains("label { color:"), "blanket label colour rule reintroduced");
!css.contains("label { color:"),
"blanket label colour rule reintroduced"
);
} }
#[test] #[test]
fn shared_css_path_uses_runtime_dir() { 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"); std::env::set_var("XDG_RUNTIME_DIR", "/run/user/1234");
assert_eq!( assert_eq!(shared_css_path(), std::path::PathBuf::from("/run/user/1234/bread/theme.css"));
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] #[test]

View file

@ -1,344 +0,0 @@
//! 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 /// 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 /// stops looking like a dark BOS tool and starts looking like whatever colour
/// the wallpaper happened to be. /// the wallpaper happened to be.
pub(crate) const FIXED_BACKGROUND: &str = "#0c0c0c"; const FIXED_BACKGROUND: &str = "#0c0c0c";
pub(crate) const FIXED_FOREGROUND: &str = "#e8e8e8"; const FIXED_FOREGROUND: &str = "#e8e8e8";
pub(crate) const FIXED_SURFACE: &str = "#1a1a1a"; const FIXED_SURFACE: &str = "#1a1a1a";
pub(crate) const FIXED_OVERLAY: &str = "#d8d8d8"; const FIXED_OVERLAY: &str = "#d8d8d8";
/// Accent fallback when no pywal palette exists yet (fresh install, before /// Accent fallback when no pywal palette exists yet (fresh install, before
/// any wallpaper has been set for real) — BOS's own bread-toned accents, /// any wallpaper has been set for real) — BOS's own bread-toned accents,
@ -84,10 +84,7 @@ pub fn load_palette() -> Palette {
pub(crate) fn from_wal_json(json: &str) -> Option<Palette> { pub(crate) fn from_wal_json(json: &str) -> Option<Palette> {
let wal: WalColors = serde_json::from_str(json).ok()?; let wal: WalColors = serde_json::from_str(json).ok()?;
let c = |k: &str, fallback: &str| -> String { let c = |k: &str, fallback: &str| -> String {
wal.colors wal.colors.get(k).cloned().unwrap_or_else(|| fallback.into())
.get(k)
.cloned()
.unwrap_or_else(|| fallback.into())
}; };
Some(Palette { Some(Palette {
background: FIXED_BACKGROUND.into(), background: FIXED_BACKGROUND.into(),

View file

@ -1,113 +0,0 @@
//! 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

@ -1,318 +0,0 @@
//! `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

@ -1,601 +0,0 @@
//! `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,
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,590 +0,0 @@
//! 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 = { version = "0.11", features = ["v4_12"], optional = true }
gtk4-layer-shell = { version = "0.8", optional = true } gtk4-layer-shell = { version = "0.8", optional = true }
toml_edit = { version = "0.22", optional = true } toml_edit = { version = "0.22", optional = true }
bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.8.0", optional = true } bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.7.0", optional = true }
[features] [features]
# Enable the layer-shell popup scaffold (breadbox, breadclip). Kept optional # Enable the layer-shell popup scaffold (breadbox, breadclip). Kept optional

View file

@ -14,15 +14,10 @@
//! //!
//! A sibling app must never crash or block because breadd is down, //! A sibling app must never crash or block because breadd is down,
//! restarting, or was never installed. Concretely: //! restarting, or was never installed. Concretely:
//! - [`BreadClient::emit`] and [`BreadClient::command`] are best-effort, //! - [`BreadClient::emit`] is a best-effort, fire-and-forget single-shot
//! fire-and-forget single-shot connections (mirroring `bread-emit`'s //! connection (mirroring `bread-emit`'s own stance) — if breadd is
//! own stance) — if breadd is unreachable, the event is silently //! unreachable, the event is silently dropped, not an error the caller
//! dropped, not an error the caller has to handle. //! 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 //! - [`BreadClient::subscribe`] runs its read loop on a background thread
//! that reconnects with exponential backoff on any disconnect. The //! that reconnects with exponential backoff on any disconnect. The
//! caller's callback simply stops being invoked while disconnected; it //! caller's callback simply stops being invoked while disconnected; it
@ -34,15 +29,6 @@
//! outside the app's own `bread.<app_id>.*` segment, so a misconfigured //! outside the app's own `bread.<app_id>.*` segment, so a misconfigured
//! caller fails fast instead of discovering the mistake from the daemon's //! caller fails fast instead of discovering the mistake from the daemon's
//! rejection. The daemon enforces the same rule server-side regardless. //! 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::io::{BufRead, BufReader, Write};
use std::net::Shutdown; use std::net::Shutdown;
@ -108,104 +94,25 @@ impl BreadClient {
return; return;
} }
fire_and_forget_emit(json!({
"event": event,
"source": self.app_id,
"kind": event,
"data": data,
}));
}
/// Publish `bread.command.<target_app>.<verb>` as an unsourced IPC
/// emit so another bread app (or a Lua workflow) can act on it.
///
/// Fire-and-forget: same silent-if-down stance as [`emit`]. Locally
/// refuses (eprint + return, no socket) if `target_app` or `verb` is
/// empty, or if `verb` contains `.` — a verb is one segment
/// (`clear`, not `history.clear`).
///
/// The wire payload is `{ method: "emit", params: { event, data } }`
/// with **no** `source`/`kind`. Do not add those: a sourced emit
/// would have to claim the *target's* namespace (or ours), and the
/// daemon half of this integration is specifically making unsourced
/// `bread.command.<known_app>.*` legal.
pub fn command(&self, target_app: &str, verb: &str, data: Value) {
if target_app.is_empty() || verb.is_empty() || verb.contains('.') {
eprintln!(
"bread-client: refusing to send command to '{target_app}' with verb '{verb}' \
(target and verb must be non-empty; verb must be a single segment)"
);
return;
}
let event = format!("bread.command.{target_app}.{verb}");
fire_and_forget_emit(json!({
"event": event,
"data": data,
}));
}
/// One-shot `health` IPC request. `None` if breadd is unreachable or
/// the response is malformed / an error.
///
/// Long-running daemons SHOULD log a warning when this returns
/// `None` (or when [`api_version`] is missing) and MUST NOT crash.
pub fn health(&self) -> Option<Value> {
self.request("health", json!({}))
}
/// `api_version` string from [`health`], or `None` if health failed
/// or the field is absent. Same SHOULD-warn / MUST-NOT-crash rule
/// as [`health`].
pub fn api_version(&self) -> Option<String> {
self.health()?
.get("api_version")
.and_then(Value::as_str)
.map(str::to_owned)
}
/// Send a one-shot IPC request and return its `result`, or `None` on any
/// failure — breadd unreachable, a malformed response, or an `error`
/// field in the response. Mirrors `emit`'s graceful-degradation stance:
/// a caller checks for `None` the same way it'd handle "daemon not
/// installed," not via a `Result` that forces error-path plumbing for
/// what is, for most callers (a refresh-on-connect read), an expected
/// possibility rather than an exceptional one.
///
/// Unlike `emit`, this is not restricted to the client's own namespace —
/// `method`/`params` map directly onto breadd's IPC method table (see
/// `Documentation.md`'s "Dictionary: IPC protocol"), most of which
/// (`state.get`, `widgets.list`, ...) are cross-namespace reads by
/// design. Only first-party compiled code links `bread-utils`, so this
/// carries the same trust level as `emit`'s own request construction.
pub fn request(&self, method: &str, params: Value) -> Option<Value> {
let request = json!({ let request = json!({
"id": "0", "id": "0",
"method": method, "method": "emit",
"params": params, "params": {
"event": event,
"source": self.app_id,
"kind": event,
"data": data,
}
}); });
let line = serde_json::to_string(&request).ok()?; let Ok(line) = serde_json::to_string(&request) else {
return;
};
let mut stream = UnixStream::connect(bread_shared::resolve_socket_path()).ok()?; let Ok(mut stream) = UnixStream::connect(bread_shared::resolve_socket_path()) else {
stream return;
.set_write_timeout(Some(Duration::from_millis(200))) };
.ok()?; let _ = stream.set_write_timeout(Some(Duration::from_millis(200)));
stream let _ = writeln!(stream, "{line}");
.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 /// Subscribe to events matching `pattern` (glob: `*`/`**`/`?`), invoking
@ -254,27 +161,6 @@ 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 /// Connects once, sends `events.subscribe`, and invokes `on_event` for every
/// matching line until the connection ends (cleanly or with an error). /// matching line until the connection ends (cleanly or with an error).
/// Stores the live stream in `current_stream` so [`Subscription::stop`] can /// Stores the live stream in `current_stream` so [`Subscription::stop`] can
@ -404,83 +290,6 @@ mod tests {
// integration tests for the IPC-side of namespace validation. // 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] #[test]
fn subscription_stop_joins_the_background_thread() { fn subscription_stop_joins_the_background_thread() {
let client = BreadClient::connect("clip"); let client = BreadClient::connect("clip");

View file

@ -36,17 +36,10 @@ pub enum Socket {
/// `HYPRLAND_INSTANCE_SIGNATURE` + `XDG_RUNTIME_DIR`. Returns `None` if /// `HYPRLAND_INSTANCE_SIGNATURE` + `XDG_RUNTIME_DIR`. Returns `None` if
/// `HYPRLAND_INSTANCE_SIGNATURE` isn't set (Hyprland isn't running, or we're /// `HYPRLAND_INSTANCE_SIGNATURE` isn't set (Hyprland isn't running, or we're
/// not inside a Hyprland session) — `XDG_RUNTIME_DIR` falls back to /// not inside a Hyprland session) — `XDG_RUNTIME_DIR` falls back to
/// `/run/user/<uid>` (this process's real uid, or the historical /// `/run/user/1000` if unset, matching `breadmon`'s existing fallback.
/// `/run/user/1000` if that can't be read) when unset.
pub fn socket_path(kind: Socket) -> Option<PathBuf> { pub fn socket_path(kind: Socket) -> Option<PathBuf> {
let sig = env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?; let sig = env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?;
// `XDG_RUNTIME_DIR` is normally `/run/user/<uid>`; when it's unset, let rt = env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".to_string());
// 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 { let file = match kind {
Socket::Request => ".socket.sock", Socket::Request => ".socket.sock",
Socket::Events => ".socket2.sock", Socket::Events => ".socket2.sock",
@ -54,21 +47,6 @@ pub fn socket_path(kind: Socket) -> Option<PathBuf> {
Some(PathBuf::from(format!("{rt}/hypr/{sig}/{file}"))) 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 /// Send `request` (e.g. `"j/activewindow"`, `"j/monitors"`) to the socket1
/// IPC socket and return the raw response body. Blocking/synchronous — this /// IPC socket and return the raw response body. Blocking/synchronous — this
/// matches every current consumer (breadbox, breadclip), which call it from /// matches every current consumer (breadbox, breadclip), which call it from
@ -244,17 +222,6 @@ mod tests {
assert!(win.fullscreen.is_fullscreen()); 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`/ // Both env-var-dependent cases share one test function: `set_var`/
// `remove_var` are process-global, and cargo runs tests in parallel // `remove_var` are process-global, and cargo runs tests in parallel
// threads by default, so two separate #[test] fns racing on the same // threads by default, so two separate #[test] fns racing on the same

View file

@ -19,18 +19,12 @@
//! - [`gtk_popup`] (feature `gtk`) — shared layer-shell popup window setup, //! - [`gtk_popup`] (feature `gtk`) — shared layer-shell popup window setup,
//! list navigation, and click-outside-to-close. //! list navigation, and click-outside-to-close.
//! - [`bread_client`] (feature `bread-client`) — a persistent-connection //! - [`bread_client`] (feature `bread-client`) — a persistent-connection
//! client for breadd's IPC socket (`emit`, unsourced `command`, //! client for breadd's IPC socket (emit + subscribe), for sibling
//! `health`/`api_version`, `subscribe`), for sibling `bread*` app //! `bread*` app daemons integrating with the bread automation fabric.
//! 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 atomic;
pub mod hypr; pub mod hypr;
pub mod proc; pub mod proc;
pub mod screenshot_cli;
pub mod singleton; pub mod singleton;
pub mod xdg; pub mod xdg;

View file

@ -1,156 +0,0 @@
//! 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,9 +80,6 @@ pub fn try_acquire(app: &str) -> std::io::Result<Acquire> {
.read(true) .read(true)
.write(true) .write(true)
.create(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)?; .open(&path)?;
match file.try_lock() { match file.try_lock() {

View file

@ -1,30 +0,0 @@
# 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

View file

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

View file

@ -71,20 +71,3 @@ description = "Screenshot utility for the bread ecosystem"
name = "bos-settings" name = "bos-settings"
repo = "Breadway/bos-settings" repo = "Breadway/bos-settings"
description = "System settings app for Bread OS" 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,20 +14,9 @@
# #
# scripts/doctor-channels.sh ~/Projects # 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). # Exits 0 if no drift found, 1 if any repo has drift (so it's CI-friendly).
# #
# Requires: python3 (tomllib, stdlib since 3.11); curl + a Forgejo token at # Requires: python3 (tomllib, stdlib since 3.11)
# ~/.config/forgejo/token for the secrets check (soft-skipped without one).
set -euo pipefail set -euo pipefail
@ -83,10 +72,7 @@ for dir in "${BASE_DIR}"/*/; do
# Skip bread-ecosystem itself — it's a multi-product repo the registry # 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 # membership check above doesn't map 1:1, and it's already reviewed by
# hand above (bakery + bread-theme products). # hand above (bakery + bread-theme products).
# Prefix match, not exact: bread-ecosystem-breadcast, bread-ecosystem-onboard, [[ "${name}" == "bread-ecosystem" ]] && continue
# 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)) checked=$((checked + 1))
has_bakery_toml=0 has_bakery_toml=0
@ -139,55 +125,6 @@ done
echo echo
echo "checked ${checked} repos under ${BASE_DIR}" 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 if [[ "${drift}" == 0 ]]; then
echo "no channel drift found" echo "no channel drift found"
else else

View file

@ -1,38 +1,19 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Generate dl.breadway.dev/index.json (or a track-prefixed sibling — see # Generate dl.breadway.dev/index.json from:
# TRACK below) from: # - registry/bread-ecosystem.toml (product list)
# - registry/bread-ecosystem.toml (product list) # - <DL_DIR>/<name>/bakery.toml (per-product metadata, uploaded by release.yml)
# - <PKG_ROOT>/<name>/bakery.toml (per-product metadata, uploaded by release.yml) # - <DL_DIR>/ (built binaries + sha256 files)
# - <PKG_ROOT>/ (built binaries + sha256 files)
# #
# Fallback for local dev: looks for ../name/bakery.toml (sibling repo checkout). # Fallback for local dev: looks for ../name/bakery.toml (sibling repo checkout).
# Run on hestia after each product build, before the dl server is refreshed. # Run on hestia after each product build, before the dl server is refreshed.
#
# TRACK selects which build track to generate an index for: "stable"
# (default — reads/writes DL_DIR directly, byte-for-byte the same behavior
# as before tracks existed), "beta", or "dev" (both read/write a
# DL_DIR/<track>/ subtree, so they never collide with stable's paths). A
# product with no release dir under the selected track's tree is skipped
# with a warning, same as an unreleased product is today — most products
# won't have a beta/dev build for a while after this lands.
# Requires: jq, python3 (tomllib, stdlib since 3.11), sha256sum # Requires: jq, python3 (tomllib, stdlib since 3.11), sha256sum
set -euo pipefail set -euo pipefail
SCRIPT_DIR="${SCRIPT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" SCRIPT_DIR="${SCRIPT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
DL_DIR="${DL_DIR:-/srv/breadway-dl}" DL_DIR="${DL_DIR:-/srv/breadway-dl}"
DL_BASE="${DL_BASE:-https://dl.breadway.dev}" DL_BASE="${DL_BASE:-https://dl.breadway.dev}"
TRACK="${TRACK:-stable}"
GH_BASE="https://github.com" GH_BASE="https://github.com"
OUT="${DL_DIR}/index.json"
if [[ "${TRACK}" == "stable" ]]; then
PKG_ROOT="${DL_DIR}"
URL_ROOT="${DL_BASE}"
OUT="${DL_DIR}/index.json"
else
PKG_ROOT="${DL_DIR}/${TRACK}"
URL_ROOT="${DL_BASE}/${TRACK}"
OUT="${DL_DIR}/${TRACK}/index.json"
fi
# Read the product list from the registry TOML instead of a hardcoded array. # Read the product list from the registry TOML instead of a hardcoded array.
mapfile -t products < <(python3 -c " mapfile -t products < <(python3 -c "
@ -49,8 +30,8 @@ build_package_json() {
local name="$1" local name="$1"
local repo="$2" local repo="$2"
# Find the latest version dir under PKG_ROOT/<name>/ # Find the latest version dir under DL_DIR/<name>/
local pkg_dir="${PKG_ROOT}/${name}" local pkg_dir="${DL_DIR}/${name}"
if [[ ! -d "${pkg_dir}" ]]; then if [[ ! -d "${pkg_dir}" ]]; then
echo " warning: no release dir for ${name} at ${pkg_dir}" >&2 echo " warning: no release dir for ${name} at ${pkg_dir}" >&2
return 1 return 1
@ -67,41 +48,6 @@ build_package_json() {
local version local version
version="$(basename "${version_dir}")" 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). # Collect all binaries in the version dir (executables only; skip metadata files).
local binaries_json="[]" local binaries_json="[]"
for bin_path in "${version_dir}"/*; do for bin_path in "${version_dir}"/*; do
@ -110,10 +56,6 @@ print(d.get('data_archive', ''))
[[ "${bin_path}" == *.service ]] && continue [[ "${bin_path}" == *.service ]] && continue
[[ "${bin_path}" == *.css ]] && continue [[ "${bin_path}" == *.css ]] && continue
[[ "${bin_path}" == *.txt ]] && 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 [[ -f "${bin_path}" ]] || continue
local bin_name local bin_name
bin_name="$(basename "${bin_path}")" bin_name="$(basename "${bin_path}")"
@ -122,17 +64,8 @@ print(d.get('data_archive', ''))
if [[ -f "${sha256_path}" ]]; then if [[ -f "${sha256_path}" ]]; then
sha256="$(awk '{print $1}' "${sha256_path}")" sha256="$(awk '{print $1}' "${sha256_path}")"
fi fi
local dl_url="${URL_ROOT}/${name}/${version}/${bin_name}" local dl_url="${DL_BASE}/${name}/${version}/${bin_name}"
# dev/beta builds never get a real GitHub Release (see the dev/beta local gh_url="${GH_BASE}/${repo}/releases/download/v${version}/${bin_name}"
# CI workflows — that step is intentionally skipped for those
# tracks), so github_url just mirrors dl_url rather than pointing at
# a release asset that doesn't exist.
local gh_url
if [[ "${TRACK}" == "stable" ]]; then
gh_url="${GH_BASE}/${repo}/releases/download/v${version}/${bin_name}"
else
gh_url="${dl_url}"
fi
local entry local entry
entry="$(jq -n \ entry="$(jq -n \
@ -144,6 +77,18 @@ print(d.get('data_archive', ''))
binaries_json="$(jq -n --argjson arr "${binaries_json}" --argjson e "${entry}" '$arr + [$e]')" binaries_json="$(jq -n --argjson arr "${binaries_json}" --argjson e "${entry}" '$arr + [$e]')"
done done
# Locate bakery.toml. The release workflow copies it into the version dir
# alongside the binaries (${version_dir}/bakery.toml). Fall back to a
# sibling repo checkout for local dev use.
local bakery_toml="${version_dir}/bakery.toml"
if [[ ! -f "${bakery_toml}" ]]; then
bakery_toml="${SCRIPT_DIR}/../${name}/bakery.toml"
fi
if [[ ! -f "${bakery_toml}" ]]; then
echo "ERROR: bakery.toml not found for ${name} — release.yml must copy it to \${DL_DIR}/${name}/\${VERSION}/bakery.toml" >&2
return 1
fi
local description system_deps optional_system_deps bread_deps services config post_install local description system_deps optional_system_deps bread_deps services config post_install
description="$(python3 -c " description="$(python3 -c "
@ -239,47 +184,6 @@ with open('${bakery_toml}', 'rb') as f:
print(json.dumps(d.get('install', {}).get('post_install', []))) print(json.dumps(d.get('install', {}).get('post_install', [])))
" 2>/dev/null || echo "[]")" " 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 \ jq -n \
--arg name "${name}" \ --arg name "${name}" \
--arg description "${description}" \ --arg description "${description}" \
@ -291,12 +195,6 @@ print(json.dumps(d.get('install', {}).get('post_install', [])))
--argjson services "${services}" \ --argjson services "${services}" \
--argjson config "${config}" \ --argjson config "${config}" \
--argjson post_install "${post_install}" \ --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, name: $name,
description: $description, description: $description,
@ -307,13 +205,7 @@ print(json.dumps(d.get('install', {}).get('post_install', [])))
bread_deps: $bread_deps, bread_deps: $bread_deps,
services: $services, services: $services,
config: $config, 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)
}' }'
} }
@ -333,8 +225,7 @@ jq -n \
--arg generated_at "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \ --arg generated_at "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
--argjson packages "${packages_json}" \ --argjson packages "${packages_json}" \
'{version: $version, generated_at: $generated_at, packages: $packages}' \ '{version: $version, generated_at: $generated_at, packages: $packages}' \
> "${OUT}.tmp" > "${OUT}"
mv -f "${OUT}.tmp" "${OUT}"
echo "wrote ${OUT}" echo "wrote ${OUT}"
@ -361,7 +252,7 @@ if [[ -n "${MINISIGN_SEC_KEY:-}" ]]; then
echo "ERROR: MINISIGN_SEC_KEY is set but the 'minisign' binary is not installed" >&2 echo "ERROR: MINISIGN_SEC_KEY is set but the 'minisign' binary is not installed" >&2
exit 1 exit 1
fi fi
sign_args=(-S -s "${MINISIGN_SEC_KEY}" -m "${OUT}" -x "${OUT}.minisig.tmp") sign_args=(-S -s "${MINISIGN_SEC_KEY}" -m "${OUT}" -x "${OUT}.minisig")
if [[ -n "${MINISIGN_SEC_KEY_PASSWORD:-}" ]]; then if [[ -n "${MINISIGN_SEC_KEY_PASSWORD:-}" ]]; then
MINISIGN_PASSWORD="${MINISIGN_SEC_KEY_PASSWORD}" minisign "${sign_args[@]}" </dev/null MINISIGN_PASSWORD="${MINISIGN_SEC_KEY_PASSWORD}" minisign "${sign_args[@]}" </dev/null
else else
@ -369,7 +260,6 @@ if [[ -n "${MINISIGN_SEC_KEY:-}" ]]; then
# normally generated, since there's no human to type a passphrase). # normally generated, since there's no human to type a passphrase).
minisign -W "${sign_args[@]}" </dev/null minisign -W "${sign_args[@]}" </dev/null
fi fi
mv -f "${OUT}.minisig.tmp" "${OUT}.minisig"
echo "signed ${OUT} -> ${OUT}.minisig" echo "signed ${OUT} -> ${OUT}.minisig"
else else
echo "WARNING: MINISIGN_SEC_KEY not set — index.json was NOT signed." >&2 echo "WARNING: MINISIGN_SEC_KEY not set — index.json was NOT signed." >&2

View file

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

View file

@ -1,52 +0,0 @@
#!/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

View file

@ -1,88 +0,0 @@
**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